Merge branch 'stable-3.14'

* stable-3.14:
  Set version to 3.14.5-SNAPSHOT
  Set version to 3.14.4
  Update git submodules
  Set version to 3.13.11-SNAPSHOT
  Set version to 3.13.10
  Set version to 3.12.12-SNAPSHOT
  Set version to 3.12.11
  Detect duplicate Lucene change documents
  Bump up plugin manager to the latest master
  Include project name for index tasks
  Log at INFO when project_list cache warmer is disabled
  Update git submodules
  Update git submodules
  Bump JGit to 182a14056 (stable-7.7)
  Bump JGit to f9d6f9c7c (stable-7.5)
  Fix ParserUtil to recognize UUID comment permalinks
  Redact token, secret, and raw in OAuthToken.toString()
  Update git submodules
  Update git submodules
  Fix HtmlParser to recognize Gmail's gmail_attr div
  Create h2 schema only once when SqlStore is created
  Set version to 3.14.4-SNAPSHOT
  Set version to 3.14.3
  Set version to 3.13.10-SNAPSHOT
  Set version to 3.13.9
  Set version to 3.12.11-SNAPSHOT
  Set version to 3.12.10

Release-Notes: skip
Change-Id: I06f78999611cf7f773b39e30fca97360ae762921
diff --git a/.agents/rules.json b/.agents/rules.json
new file mode 100644
index 0000000..bcd4b27
--- /dev/null
+++ b/.agents/rules.json
@@ -0,0 +1,7 @@
+{
+  "entries": [
+    {
+      "path": ".agents/rules/upload_rationale.md"
+    }
+  ]
+}
diff --git a/.agents/rules/upload_rationale.md b/.agents/rules/upload_rationale.md
new file mode 100644
index 0000000..ebd3605
--- /dev/null
+++ b/.agents/rules/upload_rationale.md
@@ -0,0 +1,11 @@
+---
+trigger: always_on
+description: Mandate uploading agent rationale for Gerrit changes
+---
+
+## Gerrit Rationale Upload Policy
+
+Whenever you create a new Gerrit change or upload a patchset (via `git push`,
+`hg upload`, `g4 upload`, or any other VCS command), check the `gerrit` skill
+(specifically the `upload-rationale` section) to determine whether an AI
+rationale should be uploaded, and follow its instructions.
diff --git a/.agents/skills.json b/.agents/skills.json
new file mode 100644
index 0000000..88387c1
--- /dev/null
+++ b/.agents/skills.json
@@ -0,0 +1,7 @@
+{
+  "entries": [
+    {
+      "path": "/google/src/files/head/depot/google3/learning/gemini/agents/skills/gerrit"
+    }
+  ]
+}
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=
new file mode 100755
index 0000000..b1e9051
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "npm", pnpm_lock = "@@//:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+package.json=259674664
+pnpm-lock.yaml=640391753
+pnpm-workspace.yaml=-408937369
+yarn.lock=742513459
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_NDEzOTg2ODE= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_NDEzOTg2ODE=
new file mode 100755
index 0000000..51238c9
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_NDEzOTg2ODE=
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "tools_npm", pnpm_lock = "@@//tools/node_tools:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+tools/node_tools/package.json=899646160
+tools/node_tools/pnpm-lock.yaml=-1121876554
+tools/node_tools/pnpm-workspace.yaml=-408937369
+tools/node_tools/yarn.lock=1865795164
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4NDE3MDEy b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4NDE3MDEy
new file mode 100755
index 0000000..d87d7e1
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4NDE3MDEy
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "ui_npm", pnpm_lock = "@@//polygerrit-ui/app:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+polygerrit-ui/app/package.json=-1741511240
+polygerrit-ui/app/pnpm-lock.yaml=-429438957
+polygerrit-ui/app/pnpm-workspace.yaml=1396467527
+polygerrit-ui/app/yarn.lock=1946148636
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4OTg1MjQ0 b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4OTg1MjQ0
new file mode 100755
index 0000000..33edcc5
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODE4OTg1MjQ0
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "ui_dev_npm", pnpm_lock = "@@//polygerrit-ui:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+polygerrit-ui/package.json=-860495337
+polygerrit-ui/pnpm-lock.yaml=201412925
+polygerrit-ui/pnpm-workspace.yaml=-408937369
+polygerrit-ui/yarn.lock=1276229348
diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg=
new file mode 100755
index 0000000..c9237ac
--- /dev/null
+++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_ODUwOTM3NTg=
@@ -0,0 +1,7 @@
+# @generated
+# Input hashes for repository rule npm_translate_lock(name = "plugins_npm", pnpm_lock = "@@//plugins:pnpm-lock.yaml").
+# This file should be checked into version control along with the pnpm-lock.yaml file.
+plugins/package.json=-603960899
+plugins/pnpm-lock.yaml=-1898375944
+plugins/pnpm-workspace.yaml=-408937369
+plugins/yarn.lock=-1311799368
diff --git a/.bazelignore b/.bazelignore
index 13bcfb8..aca15c1 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,4 +1,5 @@
 eclipse-out
+modules/gitiles
 modules/jgit
 node_modules
 polygerrit-ui/node_modules
diff --git a/.bazelproject b/.bazelproject
index 86e568d..b27b85d 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -25,5 +25,4 @@
 
 ts_config_rules:
   //tools/node_tools/node_modules_licenses:tsconfig_editor
-  //tools/node_tools/polygerrit_app_preprocessor:preprocessor_tsconfig.json
   //polygerrit-ui/app/node_modules_licenses:tsconfig_editor
diff --git a/.bazelrc b/.bazelrc
index 7a2ed39..1181b4b 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,44 +1,35 @@
-# TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel
-# https://issues.gerritcodereview.com/issues/303819949
-# Remove hybrid mode option once bzlmod migration is completed
-common --enable_workspace
-common --enable_bzlmod --lockfile_mode=error
-common --incompatible_enable_proto_toolchain_resolution
-common --@protobuf//bazel/toolchains:prefer_prebuilt_protoc
+common --lockfile_mode=error
 # Enable Gerrit-tree-only plugin checks (standalone plugin builds skip them).
 common --@com_googlesource_gerrit_bazlets//flags:in_gerrit_tree=true
 common --incompatible_disallow_struct_provider_syntax=false
 common --incompatible_disallow_empty_glob=false
+# Suppress the rules_python implicit-__init__.py deprecation warning emitted
+# for @bazel_tools//tools/jdk:proguard_allowlister (Bazel's own py_binary; the
+# Gerrit tree has no Python targets). This is the upcoming default anyway, see
+# https://github.com/bazel-contrib/rules_python/issues/2945.
+common --@rules_python//python/config_settings:incompatible_default_to_explicit_init_py=true
+common --@aspect_rules_ts//ts:default_to_tsc_transpiler
+common --repo_env=ASPECT_TOOLS_TELEMETRY=-all
 
 build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
 
-# Define configuration using remotejdk_21, executes using remotejdk_21 or local_jdk
-build:build_shared --java_language_version=21
-build:build_shared --java_runtime_version=remotejdk_21
-build:build_shared --tool_java_language_version=21
-build:build_shared --tool_java_runtime_version=remotejdk_21
+# Builds using remotejdk_25, executes using remotejdk_25 or local_jdk
+build --java_language_version=25
+build --java_runtime_version=remotejdk_25
+build --tool_java_language_version=25
+build --tool_java_runtime_version=remotejdk_25
 
-# Builds using remotejdk_21, executes using remotejdk_21 or local_jdk
-# Avoid warnings for non default configurations:
-# build --config=build_shared
-build --java_language_version=21
-build --java_runtime_version=remotejdk_21
-build --tool_java_language_version=21
-build --tool_java_runtime_version=remotejdk_21
-
-# Builds and executes on RBE using remotejdk_21
+# Enables builds and execution on RBE
 build:remote --config=config_gcp
-build:remote --config=build_shared
 
-# Define remote21 configuration alias
+# Define remote_gcp configuration alias
 build:remote_gcp --config=remote
 
-# Builds and executes on BuildBuddy RBE using remotejdk_21
+# Enables builds and execution on BuildBuddy RBE
 build:remote_bb --config=config_bb
-build:remote_bb --config=build_shared
 
 # Builds using remotejdk_21, executes using remotejdk_21 or local_jdk
 build:build_java21_shared --java_language_version=21
@@ -49,16 +40,35 @@
 build:java21 --config=build_java21_shared
 
 # Builds and executes on RBE using remotejdk_21
-build:remote21 --config=config_gcp
+build:remote21 --config=remote
 build:remote21 --config=build_java21_shared
 
 # Define remote21 configuration alias
 build:remote21_gcp --config=remote21
 
 # Builds and executes on BuildBuddy RBE using remotejdk_21
-build:remote21_bb --config=config_bb
+build:remote21_bb --config=remote_bb
 build:remote21_bb --config=build_java21_shared
 
+# Builds using remotejdk_25, executes using remotejdk_25 or local_jdk
+build:build_java25_shared --java_language_version=25
+build:build_java25_shared --java_runtime_version=remotejdk_25
+build:build_java25_shared --tool_java_language_version=25
+build:build_java25_shared --tool_java_runtime_version=remotejdk_25
+
+build:java25 --config=build_java25_shared
+
+# Builds and executes on RBE using remotejdk_25
+build:remote25 --config=remote
+build:remote25 --config=build_java25_shared
+
+# Define remote25 configuration alias
+build:remote25_gcp --config=remote25
+
+# Builds and executes on BuildBuddy RBE using remotejdk_25
+build:remote25_bb --config=remote_bb
+build:remote25_bb --config=build_java25_shared
+
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
 # This will be the new default behavior at some point (and the flag was flipped
diff --git a/.bazelversion b/.bazelversion
index acd405b..47da986 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-8.6.0
+9.1.0
diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 0000000..5e535b2
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,5 @@
+{
+  "context": {
+    "fileName": ["AGENTS.md", "GEMINI.md"]
+  }
+}
diff --git a/.gitignore b/.gitignore
index 64e0e49..f42cfc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
 js-to-ts.sh
 /.apt_generated
 /.apt_generated_tests
+/.eslintcache
 /.bazel_path
 /.classpath
 /.factorypath
@@ -43,8 +44,10 @@
 /polygerrit-ui/screenshots/Chromium/failed/
 !/plugins/.eslintignore
 !/plugins/.eslintrc.js
+!/plugins/eslint-plugin.config.js
 !/plugins/.prettierrc.js
 !/plugins/package.json
+!/plugins/pnpm-lock.yaml
 !/plugins/rollup.config.js
 !/plugins/tsconfig.json
 !/plugins/tsconfig-plugins-base.json
@@ -54,7 +57,6 @@
 !/plugins/commit-message-length-validator
 !/plugins/delete-project
 !/plugins/download-commands
-!/plugins/external_plugin_deps.bzl
 !/plugins/gitiles
 !/plugins/hooks
 !/plugins/plugin-manager
@@ -65,7 +67,6 @@
 /test_site
 /tools/format
 /tools/maven/gerrit-*_pom.xml.asc
-/tools/node_tools
 /tools/polygerrit-updater
 /.ts-out/*
 !/.ts-out/README.md
diff --git a/.gitmodules b/.gitmodules
index 7579477..40d1489 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -61,7 +61,7 @@
 	url = ../plugins/webhooks
 	branch = .
 
-[submodule "polymer-bridges"]
-	path = polymer-bridges
-	url = ../polymer-bridges
+[submodule "modules/gitiles"]
+	path = modules/gitiles
+	url = ../gitiles
 	branch = .
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..ca5c350
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+24.18.0
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 6703ebc..5d9819c 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -11,9 +11,9 @@
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
 org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=21
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=25
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=21
+org.eclipse.jdt.core.compiler.compliance=25
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
@@ -130,4 +130,4 @@
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
 org.eclipse.jdt.core.compiler.release=enabled
-org.eclipse.jdt.core.compiler.source=21
+org.eclipse.jdt.core.compiler.source=25
diff --git a/.zuul.yaml b/.zuul.yaml
index e0e92fa..b6f2c1b5 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -7,6 +7,7 @@
       This adds required projects needed for all Gerrit-related builds
       (i.e., builds of Gerrit itself or plugins) on this branch.
     required-projects:
+      - gitiles
       - java-prettify
       - jgit
 
@@ -31,7 +32,6 @@
       - plugins/reviewnotes
       - plugins/singleusergroup
       - plugins/webhooks
-      - polymer-bridges
 
 - project:
     check:
diff --git a/GEMINI.md b/AGENTS.md
similarity index 83%
rename from GEMINI.md
rename to AGENTS.md
index 9d27731..be46d98 100644
--- a/GEMINI.md
+++ b/AGENTS.md
@@ -1,12 +1,18 @@
-# Gemini Project Profile: gerrit
+# AI Agent Project Profile: gerrit
 
 ## Project Overview
 
-Gerrit is a web-based code review system for Git. Backend is Java 21, frontend is TypeScript/Lit components, built with Bazel 7.6.1.
+Gerrit is a web-based code review system for Git. Backend is Java 21, frontend is TypeScript/Lit components, built with Bazel.
+The bazel version to be used can be read from the `.bazelversion` file.
 
 ## Sub-projects
 
-- **`polygerrit-ui`**: The frontend web application. See `polygerrit-ui/GEMINI.md` for details on the frontend development environment.
+- **`polygerrit-ui`**: The frontend web application. See `polygerrit-ui/AGENTS.md` for details on the frontend development environment.
+
+## Build tool selection
+
+If `bazelisk` is installed, use `bazelisk` instead of `bazel` for all build
+commands.
 
 ## Build Commands
 
@@ -104,6 +110,8 @@
 - Java: Google Java Style Guide, use `./tools/gjf.sh run` before committing
 - Commit messages: max 72 chars/line, present tense, include Change-Id (added by git hook)
 - **Release-Notes footer required**: Every commit must have `Release-Notes:` footer. Use `Release-Notes: skip` for small fixes/refactorings, or add a summary for notable changes
+- Optional Google-Bug-Id footer: Commits might have a `Google-Bug-Id: b/<number>` footer. If your context provides a bug number, use it.
+- **Copyright headers**: All new Java files must include the Apache 2.0 license header with the current year (e.g., `// Copyright (C) 2026 The Android Open Source Project`)
 
 ## Key Patterns
 
diff --git a/BUILD b/BUILD
index 0c10d76..14c3d0d 100644
--- a/BUILD
+++ b/BUILD
@@ -1,8 +1,15 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
+load("@npm//:defs.bzl", "npm_link_all_packages")
 load("//tools/bzl:pkg_war.bzl", "pkg_war")
 
+npm_link_all_packages(name = "node_modules")
+
 package(default_visibility = ["//visibility:public"])
 
+_JGIT_STAMPED_WAR_LIBS = ["@jgit//org.eclipse.jgit:jgit-stamped"]
+
+_JGIT_NON_STAMPED_WAR_EXCLUDES = ["libjgit.jar"]
+
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
@@ -20,23 +27,31 @@
 
 pkg_war(
     name = "gerrit",
+    additional_libs = _JGIT_STAMPED_WAR_LIBS,
+    exclude_jar_prefixes = _JGIT_NON_STAMPED_WAR_EXCLUDES,
     ui = "polygerrit",
 )
 
 pkg_war(
     name = "headless",
+    additional_libs = _JGIT_STAMPED_WAR_LIBS,
+    exclude_jar_prefixes = _JGIT_NON_STAMPED_WAR_EXCLUDES,
     ui = None,
 )
 
 pkg_war(
     name = "release",
+    additional_libs = _JGIT_STAMPED_WAR_LIBS,
     context = ["//plugins:core"],
     doc = True,
+    exclude_jar_prefixes = _JGIT_NON_STAMPED_WAR_EXCLUDES,
 )
 
 pkg_war(
     name = "withdocs",
+    additional_libs = _JGIT_STAMPED_WAR_LIBS,
     doc = True,
+    exclude_jar_prefixes = _JGIT_NON_STAMPED_WAR_EXCLUDES,
 )
 
 API_DEPS = [
diff --git a/Documentation/BUILD b/Documentation/BUILD
index 9ab713a..5c4d423 100644
--- a/Documentation/BUILD
+++ b/Documentation/BUILD
@@ -1,3 +1,4 @@
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
 load("//tools/bzl:asciidoc.bzl", "documentation_attributes", "genasciidoc", "genasciidoc_zip")
 load("//tools/bzl:license.bzl", "license_map")
 load("//tools/bzl:war_checks.bzl", "war_jars_allowlist_test")
@@ -5,6 +6,7 @@
 package(default_visibility = ["//visibility:public"])
 
 exports_files([
+    "licenses.txt",
     "replace_macros.py",
 ])
 
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 6b6ab24..a2fecab 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -140,11 +140,9 @@
   requiring `Code-Review` approvals from all reviewers].
 * In the REST API, service user accounts are tagged with `SERVICE_USER` (see the
   `tags` field in link:rest-api-accounts.html#account-info[AccountInfo]).
-* Change indexing is done synchronously for service users only if
+* Change indexing is done synchronously for service users, even if
   link:config-gerrit.html#index.indexChangesAsync[asynchronous change indexing]
-  is enabled in the Gerrit config (and the
-  `GerritBackendFeature__do_change_indexing_asynchronously_for_non_service_users`
-  experiment is enabled).
+  is enabled in the Gerrit config.
 * For Gerrit servers at Google, querying the change index uses strong reads only
   for service users. Other users may get results that are stale by a few
   seconds.
@@ -265,6 +263,16 @@
 `^refs/heads/[a-z]{1,8}` matches all lower case branch names
 between 1 and 8 characters long.  Within a regular expression `.`
 is a wildcard matching any character, but may be escaped as `\.`.
+Because access section names are stored as git config subsection
+names, a literal backslash in the subsection header must itself be
+escaped as `\\`.  This means the regex `\.` must be written as `\\.`
+in `project.config`:
++
+----
+[access "^refs/heads/.*foo\\.bar"]
+    read = group Developers
+----
++
 The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
 is used for evaluation of regular expression access control
 rules. See the library documentation for details on this
@@ -455,6 +463,15 @@
 upon which we build the code review intercept before submitting a commit to
 the branch it's uploaded to.
 
+For review pushes, `refs/for/<branch-name>` is shorthand for the destination
+branch `refs/for/refs/heads/<branch-name>`. For example, pushing to
+`refs/for/master` uploads a change for review to `refs/heads/master`.
+
+Access-control section names are evaluated as written and are not normalized
+using this shorthand. To grant, deny, or block review uploads to `master`,
+configure the permission on `refs/for/refs/heads/master`; to configure all
+normal branches, use `refs/for/refs/heads/*`.
+
 Further documentation on how to push can be found on the
 link:user-upload.html#push_create[Upload changes] page.
 
@@ -1837,7 +1854,7 @@
 When determining access, first "read = DENY group A" on "refs/a" is
 encountered. The following rule to consider is "ALLOW read group A" on
 "refs/a". The latter rule applies to the same (permission,
-ref-pattern, group) tuple, so it it is ignored.
+ref-pattern, group) tuple, so it is ignored.
 
 The DENY rule does not affect the last rule for "refs/*", since that
 has a different ref pattern and a different group. If group B is a
diff --git a/Documentation/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index 65f05b1..ceca34b 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -8,20 +8,39 @@
 --
 _ssh_ -p <port> <host> _gerrit show-caches_
   [--show-jvm]
+  [--include-diskstats]
+  [--cache <NAME> ...]
 --
 
 == DESCRIPTION
 Display statistics about the size and hit ratio of in-memory caches.
 
+By default, the command displays statistics for all registered caches.
+Because collecting data for numerous large caches on a busy server
+can cause delays, the --cache option can be used (one or more times)
+to limit the output to specific caches.
+
 == OPTIONS
 --show-jvm::
 	List the name and version of the Java virtual machine, host
 	operating system, and other details about the environment
 	that Gerrit Code Review is running in.
 
+--include-diskstats::
+	Include disk stat collection for persistent caches.
+
 --show-threads::
 	Show detailed counts for Gerrit specific threads.
 
+--cache <NAME>::
+	Show statistics only for the cache called <NAME>. May be
+	supplied more than once to display multiple caches in a single
+	command execution. Cache names are matched case-insensitively
+	against the full registered cache name (including any plugin
+	prefix). Unknown cache names are silently ignored. The
+	summary sections (SSH, tasks, memory, threads, JVM) are not
+	affected by this option.
+
 --width::
 -w::
 	Width of the output table.
@@ -84,6 +103,12 @@
 Threads: 4 CPUs available, 371 threads
 ----
 
+Show statistics for only the "accounts" and "projects" caches:
+
+----
+$ ssh -p 29418 review.example.com gerrit show-caches --cache accounts --cache projects
+----
+
 == SEE ALSO
 
 * link:cmd-flush-caches.html[gerrit flush-caches]
diff --git a/Documentation/cmd-show-queue.txt b/Documentation/cmd-show-queue.txt
index 305dc39..36f5998 100644
--- a/Documentation/cmd-show-queue.txt
+++ b/Documentation/cmd-show-queue.txt
@@ -48,6 +48,18 @@
 -Q::
         Show only the tasks from the specified queue.
 
+--state::
+-s::
+	Show only the tasks that are in the specified state. May be
+	given multiple times to show the tasks matching any of the
+	states. The state names are matched case insensitively.
+	Except for the aliases in parentheses, they are the same ones
+	reported by the `state` field of
+	link:rest-api-config.html#task-info[TaskInfo]: `DONE`,
+	`CANCELLED` (alias `KILLED`), `STOPPING`, `RUNNING`,
+	`STARTING`, `PARKED`, `READY` (alias `WAITING`), `SLEEPING`
+	and `OTHER`.
+
 == DISPLAY
 
 Task::
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 5976ae7..9087a74 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -56,6 +56,14 @@
   url = jdbc:postgresql://<host>:<port>/<db_name>?user=<user>&password=<password>
 ----
 
+Use the following format to create a h2 file at a different path than default
+
+---
+[accountPatchReviewDb]
+  url = jdbc:h2:file:/path/to/db
+---
+
+
 [[accountPatchReviewDb.poolLimit]]accountPatchReviewDb.poolLimit::
 +
 Maximum number of open database connections.  If the server needs
@@ -101,6 +109,62 @@
 If a unit suffix is not specified, `milliseconds` is assumed.
 Default is `30 seconds`.
 
+[[accountPatchReviewDb.h2LockType]]accountPatchReviewDb.h2LockType::
++
+Selects the locking mechanism used to serialise access to the H2 database file,
+replacing H2's built-in file locking.
++
+When set, no connection pool is maintained. Each operation opens a fresh
+connection, holds the lock for the duration of that operation, and closes the
+connection immediately on completion.
++
+Supported values:
++
+--
+`jgit`:::
+Uses jgit-style lock files for inter-process mutual exclusion. H2 is opened with
+`FILE_LOCK=NO`; a `.lock` sidecar file is atomically created before opening each
+connection and deleted when the connection is closed. The maximum time to spend
+retrying the lock before failing is controlled by
+<<accountPatchReviewDb.h2LockTimeout, accountPatchReviewDb.h2LockTimeout>>.
+When using this option dont specify any custom options to the
+<<accountPatchReviewDb.url, accountPatchReviewDb.url>>.
++
+For this locking to work correctly across multiple Gerrit primaries, the db and
+`.lock` file must reside on a shared filesystem (e.g. NFS) accessible to all
+primaries. The lock file is placed next to the H2 database file (derived from
+`accountPatchReviewDb.url`, or `<site>/db/account_patch_reviews.lock` by default).
+--
++
+Default is unset (H2's built-in file locking is used).
+
+[[accountPatchReviewDb.h2LockTimeout]]accountPatchReviewDb.h2LockTimeout::
++
+Maximum time to spend retrying the external lock before giving up with an error.
+Only applies when `accountPatchReviewDb.h2LockType` is set. Retries use
+exponential backoff starting at 1 ms, capped at 500 ms per sleep.
++
+Default is `30 seconds`.
+
+[[accountPatchReviewDb.h2LockBatchSize]]accountPatchReviewDb.h2LockBatchSize::
++
+Maximum number of callers that may share one held external H2 lock.
+Only applies when `accountPatchReviewDb.h2LockType` is set.
++
+When multiple Gerrit threads are waiting for the H2 lock, Gerrit acquires the
+external lock once and admits up to this many waiting in-process callers into the
+same lock batch. Each admitted caller opens its own H2 connection and commits or
+rolls back independently. The external lock remains held until all callers in the
+batch have closed their connections, then Gerrit releases it and a later batch
+may acquire the lock.
++
+This reduces repeated lock-file acquire/release cycles when many local threads
+are accessing the AccountPatchReviewDb concurrently. It does not allow callers
+from other Gerrit processes to enter the same batch; other processes must still
+wait for the external lock to be released.
++
+Default is `32`.
+
 [[accounts]]
 === Section accounts
 
@@ -732,6 +796,47 @@
 in the form `plugin-name:provider-name`. Consult the respective plugin
 documentation for details.
 
+[[auth.tokenEncryptionKey]]auth.tokenEncryptionKey::
++
+Base64-encoded AES key used to encrypt the sensitive fields (`token`, `secret`,
+`raw`) of stored OAuth tokens in the `oauth_tokens` cache. The key must decode
+to a 128-, 192-, or 256-bit AES key (16, 24, or 32 bytes); it is expanded with
+HKDF-SHA256 and each value is stored as AES-256-GCM with `expiresAt`/`providerId`
+authenticated as additional data.
++
+Generate a 256-bit key, for example with `openssl rand -base64 32`.
++
+Because it is a secret, store it in `'$site_path'/etc/secure.config`, not
+`gerrit.config`. For sites using `auth.type = OAUTH`, `init` (including the
+upgrade `init` run) generates this key into `secure.config` automatically when
+it is not already set, so tokens are encrypted by default.
++
+When `auth.type` is `OAUTH` and this is unset, tokens are stored in cleartext and
+Gerrit logs a warning at startup. Setting it is strongly recommended, especially
+when a provider issues refresh tokens. A value written while unset stays readable
+after the key is set (cleartext entries are returned unchanged). This cleartext
+fallback is intended only for entries written before this key was configured;
+encryption of the stored tokens protects against read exposure of the cache file, not an
+attacker with write access to `oauth_tokens`.
++
+By default, unset. For sites using `auth.type = OAUTH`, `init` generates
+a 256-bit key into `secure.config` when this value is not already set.
+
+[[auth.oauthTokenRefreshInterval]]auth.oauthTokenRefreshInterval::
++
+When an OAuth access-token refresh fails (for example, the identity provider is
+briefly unavailable), Gerrit will not attempt to refresh that account's token
+again until this interval has elapsed, so a provider outage is not retried on
+every request. Values should use common unit suffixes to express their setting:
++
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+If a unit suffix is not specified, `minutes` is assumed.
++
+By default, 5 minutes.
+
 [[auth.userNameToLowerCase]]auth.userNameToLowerCase::
 +
 If set the username that is received to authenticate a git operation
@@ -932,6 +1037,32 @@
 Valid values are 0, and positive integers. Setting this to 0 will
 cause the filter to never be rebuilt.
 +
+[[cache.startupThreads]]cache.startupThreads::
++
+Number of threads used to open persistent caches in parallel during startup.
+Each persistent cache is backed by its own H2 database file and builds a
+BloomFilter by reading all keys from disk when it is opened. With the default
+of 1, caches are opened sequentially. Increasing this value allows the
+BloomFilter build phase to run concurrently across caches.
++
+Setting this value greater than 1 creates threads in a dedicated pool, separate
+from the H2 thread pool (`DiskCache-Store`) that handles cache updates.
++
+Default is 1.
+
+[[cache.preWarmForBloomFilter]]cache.preWarmForBloomFilter::
++
+When enabled, each persistent cache reads its H2 database file into the OS
+page cache before building its BloomFilter. The BloomFilter build requires
+a full table scan of all keys which involves reading close to the entire file
+in a scattered fashion. Doing such a read tends to not perform well I/O wise as
+it generally cannot take advantage of operating system level file system
+readahead. Reading the file sequentially up front tends to take advantage of
+readahead to fully populate the file system caches which then can make scattered
+reads much faster.
++
+Default is true.
+
 [[cache.openFiles]]cache.openFiles::
 +
 The number of file descriptors to add to the limit set by the Gerrit daemon.
@@ -1154,6 +1285,24 @@
 filtering out files that are untouched by both commits because they were purely
 modified between the parent commits.
 
+[[cache.oauth_tokens]]cache `"oauth_tokens"`::
++
+Stores OAuth access and refresh tokens for users that sign in with
+`auth.type = OAUTH`.
++
+To avoid persisting OAuth tokens to disk, set
+`cache.oauth_tokens.diskLimit = 0`. Tokens are then kept only in memory and
+users may need to sign in again after a Gerrit restart.
++
+To disable OAuth token caching entirely, also set
+`cache.oauth_tokens.memoryLimit = 0`. When disabled, OAuth sign-in may still
+create a web session, but REST requests to `GET /accounts/{id}/oauthtoken`
+fail with `409 Conflict` and flows that rely on cached OAuth tokens are not
+available.
++
+If tokens were previously written to disk, stop Gerrit and remove any existing
+`oauth_tokens` persistent cache files from the cache directory.
+
 cache `"git_file_diff"`::
 +
 Each item caches the pure git diff between two git trees for a specific file
@@ -1505,6 +1654,14 @@
 +
 Defaults to `true`.
 
+[[cachePruning.minimumInitialDelay]]cachePruning.minimumInitialDelay::
++
+The link:#schedule-configuration-minimumInitialDelay[minimum initial delay]
+after Gerrit starts before periodic cache pruning may run.
++
+Set `cachePruning.pruneOnStartup` to `false` to prevent all cache pruning
+during this delay.
+
 [[cachePruning.startTime]]cachePruning.startTime::
 +
 The link:#schedule-configuration-startTime[start time] for running
@@ -1651,6 +1808,9 @@
 Maximum number of files allowed per change. Larger changes are rejected and must
 be split up. For merge changes we are comparing against the auto-merge commit,
 so we allow large merges, if they merge cleanly.
+Note that rename detection is disabled during validation for performance
+reasons, so renaming a file counts as two changed files (one deletion and one
+addition).
 +
 By default 100,000.
 
@@ -2004,7 +2164,7 @@
 include predicates derived from other cleanup options such as
 link:#changeCleanup.abandonAfter[abandonAfter] and
 link:#changeCleanup.abandonIfMergeable[abandonIfMergeable].
-
++
 Any valid link:user-search.html[change search] expression is accepted. This
 allows limiting the batch size, excluding specific changes, or combining both:
 +
@@ -2252,6 +2412,16 @@
 called with the '--replica' switch, enabling replica mode. If no value is
 set (or any other value), Gerrit defaults to primary mode enabling write
 operations.
++
+Due to its strictly replica read-only architecture, a replica operates with heavily
+restricted functionality compared to a fully-fledged Gerrit server. It strictly
+rejects all Git write operations over the Git protocol (such as `git push`),
+requiring all repository modifications to be routed to the primary instance.
+Furthermore, a replica provides absolutely no REST-API support and exposes only
+a highly limited subset of SSH commands. Finally, because a replica does not
+host the core review metadata, it lacks full secondary indexing support; the
+only index maintained on a replica is the `groups` index, which is strictly
+necessary for evaluating repository read access controls.
 
 [[container.slave]]container.slave::
 +
@@ -2902,6 +3072,22 @@
   primaryWeblinkName = gitiles
 ----
 
+[[gerrit.submitCommitUrl]]gerrit.submitCommitUrl::
++
+URL used to link commit hashes in change messages generated when a change is
+submitted or cherry-picked. The URL must use the `http` or `https` scheme.
+The optional `${commit}` placeholder is replaced with the commit hash. If the
+placeholder is omitted, the commit hash is appended as a path segment.
++
+By default unset. In this case Gerrit derives the URL from the revision's
+configured code browser weblinks when possible.
++
+Example:
+----
+[gerrit]
+  submitCommitUrl = https://chromiumdash.appspot.com/commit/${commit}
+----
+
 [[gerrit.reportBugUrl]]gerrit.reportBugUrl::
 +
 URL to direct users to when they need to report a bug.
@@ -3604,8 +3790,22 @@
 +
 Number of worker threads dedicated to accepting new incoming TCP
 connections and allocating them connection-specific resources.
+<<httpd.selectorThreads,Selector threads>> handle I/O on accepted connections
+separately.
 +
-By default, 2, which should be suitable for most high-traffic sites.
+By default, 0, which causes selector threads to also accept new incoming
+connections. Sites that need dedicated acceptor threads can increase this
+value.
+
+[[httpd.selectorThreads]]httpd.selectorThreads::
++
+Number of NIO selector threads used to dispatch I/O events for existing
+connections. Each selector thread runs a `java.nio.channels.Selector` loop and
+is shared across all open connections.
++
+By default, 2, which should be suitable for most high-traffic sites. When
+<<httpd.acceptorThreads,`httpd.acceptorThreads`>> is 0, selector threads also
+accept new incoming connections.
 
 [[httpd.minThreads]]httpd.minThreads::
 +
@@ -3778,6 +3978,14 @@
 using the link:pgm-reindex.html[reindex program] before restarting the
 Gerrit server.
 
+[[index.directory]]index.directory::
++
+Path to the directory where Gerrit stores its index.
++
+If not absolute, the path is resolved relative to `$site_path`.
++
+Default is `$site_path/index`.
+
 [[index.type]]index.type::
 +
 *(DEPRECATED)* The only supported value is `LUCENE`, which is the default,
@@ -3983,10 +4191,10 @@
 [[index.indexChangesAsync]]index.indexChangesAsync::
 +
 On BatchUpdate, do not await indexing completion before returning the request
-to the user (WEB_BROWSER requests only).
-This has an advantage of faster UI (because indexing latency does not contribute
-to the write request latency) and disadvantage that the indexing result might not be
-immediately available after the write request.
+to the user (WEB_BROWSER requests and non-service user requests, e.g. git push).
+This has an advantage of faster response times (because indexing latency does not
+contribute to the write request latency) and a disadvantage that the indexing result
+might not be immediately available after the write request.
 +
 Defaults to `false`.
 
@@ -4144,6 +4352,40 @@
 link:#schedule-configuration-examples[Schedule examples] can be found
 in the link:#schedule-configuration[Schedule Configuration] section.
 
+[[index.staleChangeRecovery]]index.staleChangeRecovery::
++
+Whether to enable automatic recovery of change index updates that were not
+completed due to a user interrupt or a crash.
++
+When enabled, Gerrit writes a per-change intent file under
+`$site_path/data/pending-index/<pid>_<startTime>/<threadId>/<hash>` before each
+NoteDb update and removes it once the index write succeeds. On startup, any
+intent files left behind by a previously crashed process are recovered
+immediately. A background scanner then periodically picks up intent files whose
+writer thread within the current process is no longer alive, reindexing the
+affected changes automatically.
++
+Disabling this also disables both the startup recovery and the background
+scanner, so any index inconsistencies caused by a crash must be resolved with a
+manual link:cmd-index-changes.html[reindex].
++
+> **NOTE**: The stale change recovery is enabled only when `index.commitWithin`
+> is set to zero and `index.indexChangesAsync` is false. By default, Lucene flush
+> to disk is deferred until the `commitWithin` interval elapses, making the stale
+> change recovery ineffective.
++
+Defaults to `false`.
+
+[[index.staleChangeRecoveryInterval]]index.staleChangeRecoveryInterval::
++
+How often the background scanner checks for dead-thread intent files within the
+current process. Recovery of intent files from a previously crashed process
+happens once at startup and is not affected by this interval.
++
+Only used when link:#index.staleChangeRecovery[index.staleChangeRecovery] is `true`.
++
+Defaults to `5m`.
+
 ==== Lucene configuration
 
 Open and closed changes are indexed in separate indexes named
@@ -4151,6 +4393,24 @@
 
 The following settings are only used when the index type is `LUCENE`.
 
+[[index.lockFactory]]index.lockFactory::
++
+The lock implementation that Lucene uses to prevent concurrent writers from
+corrupting an index directory. Can be either `NATIVE`, which uses
+link:https://lucene.apache.org/core/api/core/org/apache/lucene/store/NativeFSLockFactory.html[
+NativeFSLockFactory,role=external,window=_blank], or `SIMPLE`, which uses
+link:https://lucene.apache.org/core/api/core/org/apache/lucene/store/SimpleFSLockFactory.html[
+SimpleFSLockFactory,role=external,window=_blank]. Lucene recommends `SIMPLE`
+for indexes on filesystems where OS-level locking is unreliable, such as NFS.
++
+> **NOTE**: Before changing this setting, stop all processes writing to the
+> index and delete the leftover `write.lock` file of every index directory
+> under `<site_dir>/index`, including the `open` and `closed` subdirectories
+> of the changes index. Processes configured with different lock factories do
+> not lock against each other and can corrupt the index.
++
+Defaults to `NATIVE`.
+
 [[index.name.ramBufferSize]]index.name.ramBufferSize::
 +
 Determines the amount of RAM that may be used for buffering added documents
@@ -5100,6 +5360,7 @@
 [[plugins.loadPriority]]plugins.loadPriority::
 +
 List of `pluginName`s required to have a specific loading order during Gerrit startup.
+Plugins will be unloaded in reverse order.
 +
 Each entry should contain a plugin name defined in the `MANIFEST.MF` under
 `Gerrit-PluginName` or a plugin JAR file name. During the Gerrit startup
@@ -6440,6 +6701,45 @@
 By default false.
 +
 
+[[submitRequirement.executionTimeout]]submitRequirement.executionTimeout::
+Maximum time allowed for evaluating a submit requirement while
+accessing a change.
+Supports time units (e.g. 500ms, 2s etc).
+If the evaluation exceeds this timeout, the evaluation is aborted and the
+submit requirement is marked as `TIMEOUT`.
+This helps to prevent long-running or expensive submit requirement expressions
+evaluation.
+- If set to 0 or config is missing, there is no timeout and the evaluation
+will run until evaluation is completed, regardless of how long it takes.
+- If set to to a positive value, evaluation will timeout the SR and change
+cannot be submitted.
++
+By default 0.
++
+----
+Note:
+Timeout configured is applicable per Submit Requirement i.e. it
+applies collectively to the total time needed to evaluate all of these
+expressions: applicableIf, submittableIf, overrideIf.
+
+If submitRequirement.evaluationThreads is set to negative value
+submitRequirement.executionTimeout is not applicable and the evaluation will
+run until evaluation is completed.
+----
+
+[[submitRequirement.evaluationThreads]]submitRequirement.evaluationThreads::
+Maximum number of threads to be use for evaluating submit requirements for a
+change.
+- If the config property is set to 0, then the number of threads is set to the
+number of available CPUs.
+- If the value is negative, then direct executor is used and the evaluation is
+executed in the same thread as the request.
+- If the value is positive, then the specified number of threads will be used
+for evaluation.
++
+By default -1.
++
+
 
 [[suggest]]
 === Section suggest
@@ -6924,10 +7224,25 @@
 +
 By default, no jitter is applied.
 
+[[schedule-configuration-minimumInitialDelay]]
+* `minimumInitialDelay`
+Minimum time after schedule creation before the first execution of the
+periodic background job. If the execution selected by `startTime` is earlier
+than this delay, it is advanced by whole `interval` periods until the minimum
+delay is met. This preserves the configured periodic schedule.
++
+This setting does not affect additional startup executions that a background
+job may schedule separately from its periodic schedule.
++
+The same suffixes as link:#schedule-configuration-interval[interval] are
+supported. The value must not be negative.
++
+Defaults to `0` (no minimum delay).
+
 The section (and optionally the subsection) in which the `interval`,
-`startTime` and `jitter` keys must be set depends on the background job for
-which a schedule should be configured. E.g. for the change cleanup job the keys
-must be set in the link:#changeCleanup[changeCleanup] section:
+`startTime`, `minimumInitialDelay`, and `jitter` keys must be set depends on the
+background job for which a schedule should be configured. For example, change
+cleanup keys must be set in the link:#changeCleanup[changeCleanup] section.
 
 ----
   [changeCleanup]
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 49ec3f4..c440eb3 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -226,7 +226,7 @@
 
 $change.shortOriginalSubject::
 +
-The original subject limited to 72 characters, with an ellipsis if it exceeds
+The original subject limited to 72 characters, with an ellipsis if it exceeds.
 that.
 
 $change.sizeBucket::
@@ -251,7 +251,7 @@
 
 $instanceAndProjectName::
 +
-The Gerrit instance name, followed by the short project name
+The Gerrit instance name, followed by the short project name.
 
 $addInstanceNameInSubject::
 +
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 81f9d9f..5029482 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -348,7 +348,7 @@
   (e.g. `label-Code-Review`).
 * `(block|deny)?`: `block` defines a link:access-control.html#block-rule[BLOCK]
   rule, `deny` defines a link:access-control.html#deny-rule[DENY] rule, if
-  neither `block` or `deny` is specified an link:access-control.html#allow-rule[
+  neither `block` nor `deny` is specified, an link:access-control.html#allow-rule[
   ALLOW] rule is defined.
 * `<range>?`: Only set for label permission. The voting range in the format
   `<min-vote>..<max-vote>` (e.g. `-1..+1`).
@@ -649,19 +649,21 @@
 
 [[receive.rejectImplicitMerges]]receive.rejectImplicitMerges::
 +
-Controls whether a check for implicit merges will be performed when changes are
-pushed for review. An implicit merge is a case where merging an open change
-would implicitly merge another branch into the target branch. Typically, this
-happens when a change is done on master and, by mistake, pushed to a stable branch
-for review. When submitting such change, master would be implicitly merged into
-stable without anyone noticing that. When this option is set to 'true' Gerrit
-will reject the push if an implicit merge is detected.
+Controls whether a check for implicit merges will be performed when
+changes are pushed for review or submitted. An implicit merge is a case
+where merging an open change would implicitly merge another branch into
+the target branch. Typically, this happens when a change is done on
+master and, by mistake, pushed to a stable branch for review. When
+submitting such change, master would be implicitly merged into stable
+without anyone noticing that. When this option is set to 'true' Gerrit
+will reject the push or submit if an implicit merge is detected.
 +
 This check is only done for non-merge commits, merge commits are not subject of
 the implicit merge check.
 +
 Default is `INHERIT`, which means that this property is inherited from
-the parent project.
+the parent project. If no project overrides it, the effective default is
+`true`.
 
 [[receive.createNewChangeForAllNotInTarget]]receive.createNewChangeForAllNotInTarget::
 +
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 239ccf6..82e9948 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -365,6 +365,31 @@
 
 The fields that can be set for submit requirements are explained below.
 
+[[submit-requirement-template-subsection]]
+== submit-requirement-template subsection
+
+Each `submit-requirement-template` subsection defines a submit requirement
+template that project owners can choose from in the UI when creating submit
+requirements.
+
+Submit requirement templates must be defined in the `project.config`
+file and are configured with the same fields as regular
+submit requirements.
+
+The template name is defined by the subsection name and is required. Empty
+template names are invalid.
+
+`submittableIf` is also mandatory for submit requirement templates.
+
+.Example:
+----
+[submit-requirement-template "Code-Review-2"]
+  description = Require Code-Review +2 before submit
+  applicableIf = -branch:refs/meta/config
+  submittableIf = label:Code-Review=+2
+  canOverrideInChildProjects = true
+----
+
 [[submit_requirement_description]]
 === submit-requirement.Name.description
 
@@ -505,7 +530,7 @@
 +
 'label:Code-Review=MAX,users=human_reviewers' can be used to
 implement "Want-Code-Review-From-All" functionality, see
-link#require-code-review-approvals-from-all-human-reviewers-example[examples
+link:#require-code-review-approvals-from-all-human-reviewers-example[examples
 below].
 
 [[operator_is_true]]
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 3361ebc..bc01846 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -18,7 +18,7 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported)
-* A JDK for Java 21
+* A JDK for Java 25
 * Python 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
 * Yarn (`npm install -g yarn`)
@@ -54,10 +54,10 @@
 
 `java -version`
 
-[[java-21]]
-==== Java 21 support
+[[java-25]]
+==== Java 25 support
 
-To build Gerrit with Java 21 language level, run:
+To build Gerrit with Java 25 language level, run:
 
 ```
   $ bazelisk build :release
@@ -202,7 +202,7 @@
   bazelisk build plugins/<name>
 ----
 
-The output JAR file will be be placed in:
+The output JAR file will be placed in:
 
 ----
   bazel-bin/plugins/<name>/<name>.jar
@@ -519,34 +519,6 @@
   bazelisk test //plugins/replication/...
 ----
 
-[[known-issues]]
-=== Known Issues
-
-[[byte-buddy-not-initialized-or-unavailable]]
-==== The Byte Buddy agent is not initialized or unavailable
-
-If running tests that make use of mocks fail with the exception below, set the
-`sandbox_tmpfs_path` flag for running tests in `.bazelrc` as described in this
-link:https://github.com/mockito/mockito/issues/1879#issuecomment-922459131[
-issue], e.g. add this line: `test --sandbox_tmpfs_path=/tmp`
-
-.Exception:
-----
-...
-Caused by: org.mockito.exceptions.base.MockitoInitializationException:
-Could not initialize inline Byte Buddy mock maker.
-
-It appears as if your JDK does not supply a working agent attachment mechanism.
-...
-Caused by: java.lang.IllegalStateException: The Byte Buddy agent is not initialized or unavailable
-at net.bytebuddy.agent.ByteBuddyAgent.getInstrumentation(ByteBuddyAgent.java:230)
-at net.bytebuddy.agent.ByteBuddyAgent.install(ByteBuddyAgent.java:617)
-at net.bytebuddy.agent.ByteBuddyAgent.install(ByteBuddyAgent.java:568)
-at net.bytebuddy.agent.ByteBuddyAgent.install(ByteBuddyAgent.java:545)
-at org.mockito.internal.creation.bytebuddy.InlineDelegateByteBuddyMockMaker.<clinit>(InlineDelegateByteBuddyMockMaker.java:115)
-... 47 more
-----
-
 [[debugging-tests]]
 == Debugging Unit Tests
 In some cases it may be necessary to debug a test while running it in bazel. For example, when we
@@ -628,6 +600,78 @@
 REPIN=1 bazel run @external_deps//:pin
 ----
 
+After repinning, verify that the new or updated artifacts are available
+on Gerrit's Maven mirror (see <<mirror-coverage-check>>), so the build
+does not depend on Maven Central. A missing artifact has to be uploaded
+to the mirror before the dependency can be relied on.
+
+[[gerrit-maven-mirror]]
+== Gerrit Maven mirror
+
+Gerrit's `maven.install()` prefers Gerrit's Maven mirror before Maven
+Central. Maven Central remains configured as a fallback while the mirror
+is being populated.
+
+The repository order is part of `external_deps.lock.json`. After
+changing the repository order in `tools/java_deps.MODULE.bazel`, repin
+the RJE lock; changing `MODULE.bazel` alone does not reorder generated
+`http_file.urls`.
+
+[[mirror-coverage-check]]
+=== Checking mirror coverage
+
+To verify that every artifact pinned in `external_deps.lock.json` is
+present on the mirror, run the coverage checker through Bazel so it
+behaves identically locally and in CI:
+
+[source,bash]
+----
+bazel run //tools/bzl:gerrit-maven-mirror-check
+----
+
+`bazel run` sets `BUILD_WORKSPACE_DIRECTORY`, so the checker locates the
+lock file at the workspace root regardless of the current directory. It
+sends parallel `HEAD` requests and never downloads artifacts, so it puts
+negligible load on the mirror; the checker only probes Gerrit's Maven
+mirror and does not fetch artifacts from Maven Central. The command exits
+with:
+
+* `0` when all locked artifacts are mirrored;
+* `1` when one or more artifacts are genuinely missing from the mirror (a
+  `404`). Each non-mirrored artifact is printed as a tab-separated
+  `status`, `coordinate` and `url`;
+* `2` on an infrastructure or tool error: the lock file cannot be read or
+  parsed, or a probe failed with a network or mirror error (timeout,
+  connection refused, `5xx`, `429`). This is inconclusive rather than a
+  coverage miss, so retry or investigate CI/network rather than treating
+  it as missing.
+
+A summary is printed to stderr, for example `checked=298 mirrored=298
+missing=0 errors=0`. Use `--include-ok` to also print mirrored artifacts,
+`--mirror` to check another repository, and `--lock-file` to check a
+different RJE lock. The script can also be invoked directly from the
+repository root as `./tools/bzl/gerrit_maven_mirror_check.py`.
+
+To verify that a known mirrored artifact can be fetched without using
+Maven Central, create a Bazel downloader config that blocks Central and
+run a cold-cache fetch:
+
+[source,bash]
+----
+cat >/tmp/download.cfg <<'EOF'
+block repo1.maven.org
+EOF
+
+repo='@@rules_jvm_external++maven+'
+repo="${repo}com_google_guava_guava_33_5_0_jre"
+target="${repo}//file:file"
+
+bazelisk --batch --output_user_root=/tmp/gerrit-mirror-fetch \
+  fetch --repository_cache=/tmp/gerrit-mirror-cache \
+  --downloader_config=/tmp/download.cfg \
+  "$target"
+----
+
 == Building against unpublished Maven JARs
 
 To build against unpublished Maven JARs, like PrologCafe, the custom JARs must
@@ -640,8 +684,8 @@
       artifacts = [],
       repositories = [
           "file://$HOME/.m2/repository",
-          "https://repo1.maven.org/maven2",
           "https://gerrit-maven.storage.googleapis.com",
+          "https://repo1.maven.org/maven2",
       ],
       ...
   )
@@ -660,8 +704,8 @@
       artifacts = [],
       repositories = [
           GERRIT_FORGE,
-          "https://repo1.maven.org/maven2",
           "https://gerrit-maven.storage.googleapis.com",
+          "https://repo1.maven.org/maven2",
       ],
       ...
   )
@@ -809,15 +853,15 @@
     --disk-size=200
 ```
 
-Note, that we are using Ubuntu2204 docker image from bazel project:
+Note, that we are using Ubuntu2404 docker image from bazel project:
 
 
 ```
-docker pull gcr.io/bazel-public/ubuntu2204-java17@sha256:ffe37746a34537d8e73cef5a20ccd3a4e3ec7af3e7410cba87387ba97c0e520f
+docker pull gcr.io/bazel-public/ubuntu2404@sha256:a5e969f2208ae4ab3cbe18cd85971f1f602345a650495674736a66b3ef9a55d1
 ```
 
 Re-build rbe_autoconfig project, conduct a new release and switch to using it
-in `WORKSPACE` file. For more details see this
+in `MODULE.bazel` file. For more details see this
 link:https://github.com/davido/rbe_autoconfig[repository,role=external,window=_blank]
 
 Note, to authenticate to the gcr.io registry, the following command must be
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index c0cf52e..cc43706 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -83,13 +83,6 @@
 
 === Plugins with external dependencies ===
 
-[NOTE]
-As of Gerrit 3.14 using the `external_plugin_deps.bzl` file for adding external
-dependencies of plugins to the build has been deprecated. This feature will be
-removed with Gerrit 3.15. Please migrate to using Bazel modules as described
-below. The documentation of the deprecated `external_plugin_deps.bzl`
-functionality has been moved to a dedicated section below.
-
 If a plugin requires external Java dependencies, it can install them in its
 `MODULE.bazel` using `rules_jvm_external`. The Maven repository containing the
 plugin runtime dependencies must be plugin-scoped (for example
@@ -269,51 +262,6 @@
 bazel build reviewers
 ----
 
-== Managing external dependencies using `external_plugin_deps.bzl` ==
-
-[NOTE]
-This functionality has been deprecated.
-
-If the plugin has external dependencies, then they can be included from Gerrit's
-own WORKSPACE file. This can be achieved by including them in `external_plugin_deps.bzl`.
-During the build in Gerrit tree, this file must be copied over the dummy one in
-`plugins` directory.
-
-Example for content of `external_plugin_deps.bzl` file:
-
-----
-load("//tools/bzl:maven_jar.bzl", "maven_jar")
-
-def external_plugin_deps():
-  maven_jar(
-      name = 'org_apache_tika_tika_core',
-      artifact = 'org.apache.tika:tika-core:1.12',
-      sha1 = '5ab95580d22fe1dee79cffbcd98bb509a32da09b',
-  )
-----
-
-If the plugin(s) being bundled in the release have external dependencies, include them
-in `plugins/external_plugin_deps`. Create symbolic link from plugin's own
-`external_plugin_deps()` file in plugins directory and prefix the file with
-plugin name, e.g.:
-
-----
-  $ cd plugins
-  $ ln -s oauth/external_plugin_deps.bzl oauth_external_plugin_deps.bzl
-  $ ln -s uploadvalidator/external_plugin_deps.bzl uploadvalidator_external_plugin_deps.bzl
-----
-
-Now the plugin specific dependency files can be imported:
-
-----
-load(":oauth_external_plugin_deps.bzl", oauth_deps="external_plugin_deps")
-load(":uploadvalidator_external_plugin_deps.bzl", uploadvalidator_deps="external_plugin_deps")
-
-def external_plugin_deps():
-  oauth_deps()
-  uploadvalidator_deps()
-----
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
index ffce6c2..c41b709 100644
--- a/Documentation/dev-core-plugins.txt
+++ b/Documentation/dev-core-plugins.txt
@@ -142,8 +142,8 @@
    link:access-control.html#category_submit[Submit] or
    link:access-control.html#category_review_labels[Code-Review+2]
    permissions for non-Gerrit maintainers.
-** Create a component for the plugin in
-   link:https://bugs.chromium.org/p/gerrit/adminComponents[Monorail] and assign
+** Have a Googler create a component for the plugin in the
+   link:https://issues.gerritcodereview.com[issue tracker] and assign
    all issues that already exist for the plugin to this component.
 ** Add the plugin as
    link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/.gitmodules[Git
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 9a97aad..a00cdca 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -147,9 +147,9 @@
 
 To format Java source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`,role=external,window=_blank]
-tool (version 1.24.0), and to format Bazel BUILD, WORKSPACE and .bzl files the
+tool (version 1.35.0), and to format Bazel BUILD, MODULE.bazel and .bzl files the
 link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`,role=external,window=_blank]
-tool (version 4.0.0). Unused dependencies are found and removed using the
+tool (version 8.0.0). Unused dependencies are found and removed using the
 link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`,role=external,window=_blank]
 build tool, a sibling of `buildifier`.
 
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 3d4563b..bf26fc2 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -131,7 +131,7 @@
 
 To format source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`,role=external,window=_blank]
-tool (version 1.24.0), which automatically formats code to follow the
+tool (version 1.35.0), which automatically formats code to follow the
 style guide. See link:dev-crafting-changes.html#style[Code Style] for the
 instruction how to set up command line tool that uses this formatter.
 The Eclipse plugin is provided that allows to format with the same
diff --git a/Documentation/dev-plugins-lifecycle.txt b/Documentation/dev-plugins-lifecycle.txt
index 7933743..5be6faa 100644
--- a/Documentation/dev-plugins-lifecycle.txt
+++ b/Documentation/dev-plugins-lifecycle.txt
@@ -209,6 +209,65 @@
 requesting to be more involved and making them maintainers of his plugin,
 adding them to the list of the project owners.
 
+[[branching_strategy]]
+== Branching Strategy
+
+Plugin maintainers are encouraged to adopt a minimal-branching approach that
+balances compatibility across Gerrit core versions with sustainable maintenance
+overhead.
+
+[[development_branch_strategy]]
+=== Development Branch Strategy
+
+The plugin `master` branch is the primary development branch when no stable
+branches exist. The goal is to keep `master` compatible with the current
+Gerrit core release for as long as possible, avoiding premature branch
+proliferation.
+
+When stable branches exist, new features should be developed on the oldest
+supported stable branch that can support them, then merged forward through
+any intermediate stable branches and finally into `master`. This ensures the
+feature is immediately available to users on older Gerrit versions without
+requiring a separate backport.
+
+[[reactive_branching]]
+=== Just-in-Time Branching
+
+Plugin branches are created reactively, not proactively. There is no
+requirement to create a plugin branch for every Gerrit core release.
+
+The decision flow is:
+
+. Develop on plugin `master`.
+. Test plugin `master` against the target Gerrit core version.
+. If it builds and works → continue using `master`; no new branch is needed.
+. If it breaks:
+  .. Identify the last commit on `master` that was compatible.
+  .. Create a stable branch from that commit.
+  .. Continue development for the new Gerrit core version on `master`.
+
+A single plugin branch may remain compatible with multiple consecutive Gerrit
+core versions. Branches should only diverge when API or behavioral
+incompatibilities make it unavoidable.
+
+[[core_version_relationship]]
+=== Relationship to Gerrit Core Versions
+
+There is no required one-to-one mapping between plugin branches and Gerrit
+core versions. A single plugin branch can cover multiple Gerrit releases as
+long as no incompatible API changes are introduced. Creating a dedicated branch
+for every Gerrit release is discouraged, as it increases backport complexity
+and maintenance overhead.
+
+[[ci_limitations]]
+=== CI Limitations and Expectations
+
+Plugins are not continuously built against multiple Gerrit core versions
+simultaneously. CI coverage does not guarantee early detection of
+incompatibilities between a plugin and a given Gerrit core version. Some
+breakages may only surface at runtime. This is an accepted trade-off given the
+overhead of maintaining full multi-version CI pipelines.
+
 [[plugin_release]]
 == Plugin Release
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index be39822..ab52c66 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -52,8 +52,8 @@
 [[steering-committee-election]]
 === Election of non-Google steering committee members
 
-The election of the non-Google steering committee members happens once
-a year in June. Non-Google link:dev-roles.html#maintainer[maintainers]
+The election of the non-Google steering committee members and the community managers
+happens once a year in June. Non-Google link:dev-roles.html#maintainer[maintainers]
 can nominate themselves by posting an informal application on the
 non-public mailto:gerritcodereview-community-managers@googlegroups.com[
 community manager mailing list] when the call for nominations is sent to
@@ -396,7 +396,7 @@
 +
 The Gerrit maintainers should discuss if there are any learnings from the security
 vulnerability and define action items to follow up in the
-link:https://bugs.chromium.org/p/gerrit[issue tracker,role=external,window=_blank].
+link:https://issues.gerritcodereview.com[issue tracker,role=external,window=_blank].
 
 [[core-plugins]]
 == Core Plugins
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index d1fee3f..a3101cf 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -175,7 +175,7 @@
   link:dev-design-docs.html[design docs] and raising concerns during
   the design review
 * serving as link:#mentor[mentor]
-* doing releases (see link#release-manager[release manager])
+* doing releases (see link:#release-manager[release manager])
 
 Maintainers can:
 
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index 173f709..8701c3c 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -6,9 +6,9 @@
 ====
 This document aims to provide a concise description of the core principles of
 code review in Gerrit for people that were previously using Pull Requests on
-Github or similar concepts. Nothing in this document is meant to state that
+GitHub or similar concepts. Nothing in this document is meant to state that
 one or the other might be better, but only aims to help new users understand
-Gerrit more readily. We use Github as the point of comparison since it seems
+Gerrit more readily. We use GitHub as the point of comparison since it seems
 to be the most popular service.
 ====
 
@@ -123,7 +123,7 @@
 
 Next, you would go and visit your Gerrit change in the Web UI to get your change
 ready for review (choose reviewers, cc people, check for failing CI builds or
-tests, etc.), very similar to what you do on Github. Reviewers will be notified
+tests, etc.), very similar to what you do on GitHub. Reviewers will be notified
 via email once you add them. By default, anyone can add reviewers to a Gerrit
 change. In GitHub, this ability is reserved for certain users, so you may have
 relied on others adding reviewers for you before. This can be the case in a
@@ -139,7 +139,7 @@
 The dashboard is the central overview of changes going on within a Gerrit
 instance. By default, the dashboard shows changes that you are involved in, in
 any way. You can also see all changes on a Gerrit server by using the top menu
-(“Changes” -> “Open”). This view is more similar to what you see on Github, when
+(“Changes” -> “Open”). This view is more similar to what you see on GitHub, when
 you navigate to the Pull Requests tab of the project/repository you are working
 on. Note, however, that a single Gerrit instance can host multiple projects
 (also referred to as repositories; a list can be found, for example, https://gerrit-review.googlesource.com/admin/repos[here,role=external,window=_blank]). Your
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 345eb1c..61aae4f5 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -176,7 +176,7 @@
 She uses the *Cover Message* text box to provide Max with some additional
 feedback. When she is satisfied with her review, Hannah clicks the
 *SEND* button. At this point, her vote and cover message become
-visible to to all users.
+visible to all users.
 
 == Reworking the Change
 
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index eed18fb..cf4efb2 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -206,7 +206,7 @@
 To push a commit for review it must be pushed to
 link:access-control.html#refs_for[refs/for/<branch-name>]. This means
 the link:access-control.html#category_push_review[Push] access right
-must be assigned on `refs/for/<branch-name>`.
+must be assigned on `refs/for/refs/heads/<branch-name>`.
 
 To allow direct pushes and bypass code review, the
 link:access-control.html#category_push_direct[Push] access right is
@@ -721,7 +721,7 @@
 request the deletion of your project.
 
 Instead of deleting a project you may set the
-link:project-configuration.html#project-state[project state] to `ReadOnly` or
+link:config-project-config.html#project-section[project state] to `ReadOnly` or
 `Hidden`.
 
 [[project-rename]]
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 9cfed09..94b3a69 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -712,6 +712,7 @@
 Lit
 
 * @lit-labs/ssr-dom-shim
+* @lit/context
 * @lit/reactive-element
 * lit
 * lit-element
@@ -755,7 +756,6 @@
 Polymer-2015
 
 * @polymer/font-roboto-local - only the following file(s):
-** README.md
 ** bower.json
 ** demo/index.d.ts
 ** demo/index.html
@@ -766,6 +766,7 @@
 ** package.json
 ** roboto.js
 ** update-fonts.sh
+* @webcomponents/shadycss
 
 [[Polymer-2015_license]]
 ----
@@ -807,100 +808,6 @@
 ----
 
 
-[[Polymer-2017]]
-Polymer-2017
-
-* @polymer/decorators
-* @polymer/polymer
-* @webcomponents/shadycss
-
-[[Polymer-2017_license]]
-----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 6774ba2..3509413 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -2840,6 +2840,7 @@
 Lit
 
 * @lit-labs/ssr-dom-shim
+* @lit/context
 * @lit/reactive-element
 * lit
 * lit-element
@@ -2883,7 +2884,6 @@
 Polymer-2015
 
 * @polymer/font-roboto-local - only the following file(s):
-** README.md
 ** bower.json
 ** demo/index.d.ts
 ** demo/index.html
@@ -2894,6 +2894,7 @@
 ** package.json
 ** roboto.js
 ** update-fonts.sh
+* @webcomponents/shadycss
 
 [[Polymer-2015_license]]
 ----
@@ -2935,100 +2936,6 @@
 ----
 
 
-[[Polymer-2017]]
-Polymer-2017
-
-* @polymer/decorators
-* @polymer/polymer
-* @webcomponents/shadycss
-
-[[Polymer-2017_license]]
-----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 7da1c18..5c13aef 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -208,12 +208,20 @@
 * `caches/memory_eviction_count`: Memory eviction count.
 ** `cache_name`:
    The name of the cache.
+* `caches/memory_request_count`: Total number of lookups (hits + misses) against
+  the in-memory cache.
+** `cache_name`:
+   The name of the cache.
 * `caches/disk_cached`: Disk entries used by persistent cache.
 ** `cache_name`:
    The name of the cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
 ** `cache_name`:
    The name of the cache.
+* `caches/disk_request_count`: Total number of lookups (hits + misses) against
+  the persistent disk cache.
+** `cache_name`:
+   The name of the cache.
 * `caches/refresh_count`: The number of refreshes per cache with an indicator if
   a reload was necessary.
 ** `cache`:
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 8e6873c..ed63caa 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -68,6 +68,20 @@
 current revision displayed, an instance of
 link:rest-api-changes.html#revision-info[RevisionInfo]
 
+=== change-view-commit-aside
+The `change-view-commit-aside` extension point is located on the change view
+page, next to the commit message above the related changes list.
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
+
 === change-metadata-item
 The `change-metadata-item` extension point is located on the change view
 left panel, below the `Submit Requirements` and `Links` sections by default.
@@ -124,6 +138,21 @@
 The end point contains the `<gr-formatted-text>` element holding the
 `CheckResult.message` (if any was set).
 
+=== check-result-feedback
+The `check-result-feedback` extension point is located at the bottom of the
+result details in the expanded state of the
+link:pg-plugin-checks-api.html[ChecksAPI]. It wraps the default feedback area
+(thumbs up/down controls). This can be used by plugins to fully customize or
+replace the result-level interaction mechanisms.
+
+In addition to default parameters, the following are available:
+
+* `result`
++
+The `CheckResult` object for the currently expanded result row.
+
+The end point contains the default feedback controls.
+
 === repo-command
 This endpoint is situated among the repository commands.
 
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 560fb92..a3412cd 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -61,6 +61,16 @@
     or updates existing ones) or link:cmd-review.html[review]
     (sets approve marks) are disabled.
 +
+Due to its strictly replica read-only architecture, a replica operates with heavily
+restricted functionality compared to a fully-fledged Gerrit server. It strictly
+rejects all Git write operations over the Git protocol (such as `git push`),
+requiring all repository modifications to be routed to the primary instance.
+Furthermore, a replica provides absolutely no REST-API support and exposes only
+a highly limited subset of SSH commands. Finally, because a replica does not
+host the core review metadata, it lacks full secondary indexing support; the
+only index maintained on a replica is the `groups` index, which is strictly
+necessary for evaluating repository read access controls.
++
 This option automatically implies '--enable-sshd'.
 
 --console-log::
diff --git a/Documentation/quota.txt b/Documentation/quota.txt
index 3f2f37d..287f961 100644
--- a/Documentation/quota.txt
+++ b/Documentation/quota.txt
@@ -22,9 +22,6 @@
 to specific endpoints while knowing the general context (user and top-level entity such as
 change, project or account).
 
-If the quota enforcer wants to throttle HTTP requests, they should use
-link:quota.html#http-requests[HTTP Requests] instead.
-
 The quota groups used for checking follow the exact definition of the endpoint in the REST
 API, but remove all IDs. The schema is:
 
diff --git a/Documentation/release_war_jars.txt b/Documentation/release_war_jars.txt
index 953e2dc..87f09e0 100644
--- a/Documentation/release_war_jars.txt
+++ b/Documentation/release_war_jars.txt
@@ -14,7 +14,6 @@
 bcpkix-jdk18on
 bcprov-jdk18on
 bcutil-jdk18on
-blame-cache
 caffeine
 caffeine-guava
 commons-codec
@@ -35,7 +34,7 @@
 gson
 guava-33.5.0-jre
 guava-retrying
-guice
+guice-6.0.0-classes
 guice-assistedinject
 guice-servlet
 h2
@@ -63,6 +62,8 @@
 jgit
 jsoup
 jsr305
+libcache
+libinsecure_cipher_factory
 lucene-analysis-common
 lucene-backward-codecs
 lucene-core
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 2e1bc43..a154052 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -352,7 +352,7 @@
 |`rules`        ||
 The rules assigned for this permission as a map that maps the UUIDs of
 the groups for which the permission are assigned to
-link:#permission-info[PermissionRuleInfo] entities.
+link:#permission-rule-info[PermissionRuleInfo] entities.
 |==================================
 
 [[permission-rule-info]]
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index fc5db7c5..1055c5e 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -740,9 +740,19 @@
     }
 ----
 
-If there is no token available, or the token has already expired,
-"`404 Not Found`" is returned as response. Requests to obtain an access
-token of another user are rejected with "`403 Forbidden`".
+If there is no token available, or the token has expired and could not be
+renewed, "`404 Not Found`" is returned as response. Requests to obtain an
+access token of another user are rejected with "`403 Forbidden`". If the
+link:config-gerrit.html#cache.oauth_tokens[`oauth_tokens` cache] is disabled
+with `cache.oauth_tokens.memoryLimit = 0`, "`409 Conflict`" is returned as
+response.
+
+An expired token is renewed on read from its refresh token when the
+account's OAuth provider supports and has enabled token refresh, so the
+endpoint keeps returning a valid token (usable as a Git-over-HTTP
+password). If it cannot be renewed, the endpoint reports "`404 Not
+Found`" without evicting the cached entry (its refresh token is retained
+for a later `oauth-token evict` or `revoke`).
 
 [[get-state]]
 === Get Account State
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 341b485..306ba33 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7908,7 +7908,7 @@
 Notify handling that defines to whom email notifications should be sent
 after the change is created. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `ALL`.
+If not set, the default is `OWNER` for WIP changes and `ALL` otherwise.
 |`notify_details`     |optional|
 Additional information about whom to notify about the change creation
 as a map of link:user-notify.html#recipient-types[recipient type] to
@@ -9846,7 +9846,8 @@
   * `PASS` - expression was evaluated and result is true. +
   * `FAIL` - expression was evaluated and result is false. +
   * `ERROR` - an error occurred while evaluating the expression. +
-  * `NOT_EVALUATED` - expression was not evaluated.
+  * `NOT_EVALUATED` - expression was not evaluated. +
+  * `TIMEOUT` - expression was TIMEOUT while evaluating.
 |`passing_atoms`|optional|
 A list of passing atoms as strings. For the above expression,
 `passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
@@ -9903,7 +9904,7 @@
 |`status`||
 Status describing the result of evaluating the submit requirement. The status
 is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`,
-`FORCED`).
+`FORCED`, `TIMEOUT`).
 |`is_legacy`||
 If true, this submit requirement result was created from a legacy
 link:#submit-record[SubmitRecord]. Otherwise, it was created by evaluating a
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 8c3c972..c0f0b21 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -330,11 +330,25 @@
 
 The entries in the map are sorted by cache name.
 
+.Query Options
+[options="header",cols="1,3"]
+|===
+|Field Name          |Description
+|`include-diskstats` |If `true`, disk stat collection is included for persistent caches.
+|===
+
 .Request
 ----
   GET /config/server/caches/ HTTP/1.0
 ----
 
+To include disk stats for persistent caches:
+
+.Request
+----
+  GET /config/server/caches/?include-diskstats=true HTTP/1.0
+----
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -1051,6 +1065,21 @@
 The entries in the list are sorted by task state, remaining delay and
 command.
 
+==== Task Options
+State(s)::
+Only list the tasks that are in the specified state. May be repeated to
+list the tasks matching any of the states. The state names are matched
+case insensitively, and apart from the two aliases below are the same
+ones reported by the `state` field of link:#task-info[TaskInfo]:
+`DONE`, `CANCELLED` (alias `KILLED`), `STOPPING`, `RUNNING`,
+`STARTING`, `PARKED`, `READY` (alias `WAITING`), `SLEEPING` and
+`OTHER`.
++
+.Request
+----
+  GET /config/server/tasks/?state=RUNNING&state=PARKED HTTP/1.0
+----
+
 .Request
 ----
   GET /config/server/tasks/ HTTP/1.0
@@ -1548,8 +1577,7 @@
   {
     "changes": [
       "foo~101",
-      "bar~202",
-      "303"
+      "bar~202"
     ],
     "delete_missing": "true"
   }
@@ -1561,8 +1589,13 @@
   Content-Disposition: attachment
 ----
 
-When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
-will be deleted in the index.
+[NOTE]
+When `delete_missing` is set to `true`, only Change-IDs in the format
+project~changeNumber are accepted. Changes to be reindexed which are
+missing in NoteDb will be deleted in the index. If any of the Change-IDs
+are in an incorrect format, the server will return a 400 - Bad Request,
+and none of the operations will be executed.
+
 
 [[list-indexes]]
 === List Indexes
@@ -2573,11 +2606,11 @@
 |================================
 |Field Name         ||Description
 |`changes`   ||
-List of link:rest-api-changes.html#change-id[change-ids]
+List of link:rest-api-changes.html#change-id[change-ids]. When `delete_missing` is `true`, each entry must be in `project~changeNumber` format.
 |`delete_missing`  |optional|
 Delete changes which are missing in NoteDb from the index. This can be used
 to get rid of stale index entries. Possible values are `true` and `false`.
-By default set to `false`.
+By default set to `false`. When `true`, all entries in `changes` must be in `project~changeNumber` format; otherwise the request fails with `400 Bad Request` and no operations are executed.
 |================================
 
 [[jvm-summary-info]]
@@ -2826,8 +2859,8 @@
 |Field Name   ||Description
 |`id`         ||The ID of the task.
 |`state`      ||
-The state of the task, can be `DONE`, `CANCELLED`, `RUNNING`, `READY`,
-`SLEEPING` and `OTHER`.
+The state of the task, can be `DONE`, `CANCELLED`, `STOPPING`,
+`RUNNING`, `STARTING`, `PARKED`, `READY`, `SLEEPING` and `OTHER`.
 |`start_time` ||The start time of the task.
 |`delay`      ||The remaining delay of the task.
 |`command`    ||The command of the task.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index e265eaa..0f0444b 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1669,6 +1669,113 @@
 
 ----
 
+[[diff-commits]]
+=== Diff Between Commits
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/diff'
+--
+
+Lists the files that differ between two commits. This is useful for comparing
+commits across multiple changes (similar to a pull request diff).
+
+The `base` query parameter is required and must be a 40-character
+SHA-1 commit ID. The commits must be in an ancestor/descendant relationship.
+All commits in the path between the base commit and the target commit must be
+visible to the caller.
+Private changes that the caller cannot access will result in a 404 error.
+
+The response format is identical to the
+link:rest-api-changes.html#list-files[List Files] endpoint for changes.
+
+.Request
+----
+  GET /projects/my-project/commits/6d2a3adb10e844c33617fc948dbeb88e868d396e/diff?base=a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "/COMMIT_MSG": {
+      "status": "A",
+      "lines_inserted": 15,
+      "size_delta": 450,
+      "size": 450
+    },
+    "src/main/java/Example.java": {
+      "lines_inserted": 10,
+      "lines_deleted": 5,
+      "size_delta": 150,
+      "size": 2500
+    }
+  }
+----
+
+[[diff-file-content]]
+=== Get Diff for File
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/files/\{file-id\}/diff'
+--
+
+Gets the diff for a single file between two commits. The `base`
+query parameter is required (same as <<diff-commits,Diff Between Commits>>).
+
+The response format is identical to the
+link:rest-api-changes.html#get-diff[Get Diff] endpoint for changes.
+
+.Request
+----
+  GET /projects/my-project/commits/6d2a3adb10e844c33617fc948dbeb88e868d396e/files/src%2Fmain%2Fjava%2FExample.java/diff?base=a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "meta_a": {
+      "name": "src/main/java/Example.java",
+      "content_type": "text/x-java",
+      "lines": 50
+    },
+    "meta_b": {
+      "name": "src/main/java/Example.java",
+      "content_type": "text/x-java",
+      "lines": 55
+    },
+    "change_type": "MODIFIED",
+    "content": [
+      {
+        "ab": [
+          "// Copyright header",
+          "package com.example;"
+        ]
+      },
+      {
+        "a": [
+          "  private int oldField;"
+        ],
+        "b": [
+          "  private int newField;",
+          "  private String additionalField;"
+        ]
+      }
+    ]
+  }
+----
+
+.Options
+* `--whitespace`: Controls whitespace handling. Valid values are `IGNORE_NONE`,
+  `IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` (default), and `IGNORE_ALL`.
+* `--intraline`: If set, includes intra-line diffs.
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -2028,6 +2135,89 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+[[create-commit]]
+=== Create Commit
+--
+'POST /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/commit'
+--
+
+Creates a single commit that applies a set of file operations (create/update,
+delete, rename) directly to the branch, for CI and automation use cases (no clone
+or multi-step change workflow required).
+
+This is a server-side branch update, not a `git push`. The caller needs the
+link:access-control.html#category_push[Push] access right (a fast-forward
+`UPDATE`) on a writable, ordinary branch (not `HEAD`, a tag, or `refs/meta/*`).
+Writing to `HEAD` is rejected with "`405 Method Not Allowed`"; the caller must
+target the branch `HEAD` points to instead.
+Ref-update and commit validators run, including validators contributed by
+plugins, and validation failures are returned as an error. There is no
+receive-pack, so push-only policies are not applied: signed-push /
+push-certificate verification is skipped, and receive-pack object checks such
+as link:config-gerrit.html#receive.maxObjectSizeLimit[receive.maxObjectSizeLimit]
+are not enforced. Matching open changes are not auto-closed.
+
+The request body is a link:#create-commit-input[CreateCommitInput] entity that
+lists the file operations and the commit message.
+
+.Request
+----
+  POST /projects/MyProject/branches/master/commit HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update configuration files",
+    "files": {
+      "conf/app.config": {
+        "content": "a2V5ID0gdmFsdWUK"
+      },
+      "bin/run.sh": {
+        "content": "IyEvYmluL3NoCmVjaG8gaGkK",
+        "file_mode": 100755
+      },
+      "conf/obsolete.config": {
+        "delete": true
+      },
+      "conf/renamed.config": {
+        "rename_from": "conf/old.config"
+      }
+    }
+  }
+----
+
+As response the link:rest-api-changes.html#commit-info[CommitInfo] of the new
+commit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": "84276d7ab324c9a50f8db21375e1a49f2a2e970f",
+    "subject": "Update configuration files",
+    "message": "Update configuration files\n"
+  }
+----
+
+The file operations are applied atomically: the request either creates a single
+commit containing all listed operations and advances the branch to it, or fails
+without modifying the branch.
+
+If `base_revision` is set in the input and the branch no longer points at that
+commit (for example because of a concurrent update), the request is rejected with
+"`409 Conflict`", so callers can retry without clobbering the other change. A
+request that would not change the tree is rejected with "`400 Bad Request`".
+
+The new commit is authored and committed by the calling user, using the
+account's full name and preferred email address. If the account has no
+preferred email, a generic `username@host` identity is used instead. Author
+and committer timestamps are the server time at which the commit was created.
+Neither the identities nor the timestamps can be overridden through this
+endpoint.
+
 [[validation-options]]
 === Get Validation Options
 --
@@ -4149,6 +4339,80 @@
   }
 ----
 
+[[get-submit-requirement-templates]]
+=== Get Submit Requirement Templates
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements_templates'
+--
+
+Retrieves submit requirement templates configured in `project.config` and in its parent projects.
+
+The calling user must be authenticated and must have read access to the
+`refs/meta/config` branch of the requested project and all its parent
+projects.
+
+As response a list of link:#submit-requirement-info[SubmitRequirementInfo]
+entities is returned. If no templates are configured, an empty list is
+returned.
+
+.Request
+----
+  GET /projects/My-Project/submit_requirements_templates HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "description": "Require Code-Review +2 before submit",
+      "project_name": "All-Projects",
+      "applicability_expression": "-branch:refs/meta/config",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": true
+    }
+  ]
+----
+
+[[get-submit-requirement-template]]
+=== Get Submit Requirement Template
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements_templates/link:#submit-requirement-name[\{submit-requirement-template-name\}]'
+--
+
+Retrieves a single effective submit requirement template for a project. The
+returned template may be defined in the project itself or inherited from one of
+its parent projects.
+
+The calling user must be authenticated and must have read access to the
+`refs/meta/config` branch of the requested project and all its parent
+projects.
+
+.Request
+----
+  GET /projects/My-Project/submit_requirements_templates/Code-Review HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "project_name": "All-Projects",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
 [[migrate-labels]]
 === Migrate label functions to submit requirements
 --
@@ -4190,7 +4454,7 @@
 
 .Request
 ----
-  POST /projects/testproj/migrate-labels HTTP/1.0
+  POST /projects/testproj/migrate-labels:review HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 ----
 
@@ -4507,7 +4771,8 @@
 signed push validation is required on the project.
 |`reject_implicit_merges`|optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
-implicit merges should be rejected on changes pushed to the project.
+implicit merges should be rejected on changes pushed to or submitted in
+the project.
 |`private_by_default`         ||
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 all new changes are set as private by default.
@@ -4549,6 +4814,56 @@
 Whether to skip adding the Git commit author and committer as reviewers for a new change.
 |=======================================================
 
+[[create-commit-input]]
+=== CreateCommitInput
+The `CreateCommitInput` entity describes the file operations to apply to a
+branch as a single commit, used by link:#create-commit[Create Commit].
+
+[options="header",cols="1,^2,4"]
+|======================================================
+|Field Name    ||Description
+|`commit_message` ||
+The commit message. Must be non-empty.
+|`base_revision`|optional|
+The commit (SHA-1) the target branch is expected to point at: the request is
+rejected with "`409 Conflict`" if the branch tip is any other commit (optimistic
+concurrency). This is a compare-and-swap check only; the new commit is always
+created on top of the current branch tip, and `base_revision` does not select an
+older base to commit onto. A value that is not a full 40-character SHA-1 is
+rejected with "`400 Bad Request`".
+|`files`       ||
+A map of file path to link:#file-change[FileChange] describing the operation to
+apply at that path. Applied together as one commit.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the ref-operation and
+commit validation listeners (e.g. to skip certain validations). Which options are
+supported depends on the installed validation listeners; Gerrit core supports
+none. Unknown options are silently ignored.
+|======================================================
+
+[[file-change]]
+=== FileChange
+The `FileChange` entity describes a single file operation within a
+link:#create-commit-input[CreateCommitInput]. Exactly one of `content`, `delete`, or
+`rename_from` must be set.
+
+[options="header",cols="1,^2,4"]
+|======================================================
+|Field Name    ||Description
+|`content`     |optional|
+The new file content, base64-encoded, for a create or update. For a `120000`
+(symlink) entry, the decoded content is the symlink target path.
+|`file_mode`   |optional|
+The file mode in octal format (`100644` regular file, `100755` executable,
+`120000` symlink). If not set, new files are created as `100644` and existing
+files keep their mode.
+|`delete`      |optional|
+If `true`, deletes the file at this path.
+|`rename_from` |optional|
+Source path to rename from. The file at `rename_from` is moved to this entry's
+path.
+|======================================================
+
 [[config-input]]
 === ConfigInput
 The `ConfigInput` entity describes a new project configuration.
@@ -4590,8 +4905,8 @@
 This property is deprecated and will be removed in
 a future release.
 |`reject_implicit_merges`                  |optional|
-Whether a check for implicit merges will be performed when changes
-are pushed for review. +
+Whether a check for implicit merges will be performed when changes are
+pushed for review or submitted. +
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
 |`max_object_size_limit`                   |optional|
diff --git a/Documentation/user-changeid.txt b/Documentation/user-changeid.txt
index 2227707..143b56a 100644
--- a/Documentation/user-changeid.txt
+++ b/Documentation/user-changeid.txt
@@ -164,11 +164,14 @@
 and cherry-picking individual changes prior to submission, such as
 by link:cmd-cherry-pick.html[gerrit-cherry-pick].
 
-Or, you may wish to delete the Change-Id line and force a new
-Change-Id to be generated automatically, thus creating an entirely
-new change record for review.  This may be useful when backporting
-a change from the current development branch to a maintenance
-release branch.
+When backporting a change from a development branch to a maintenance
+release branch, leaving the Change-Id unchanged is the typical approach.
+Gerrit treats the backport as a separate change because the repository and
+branch differ, so there is no conflict, and reviewers can easily correlate
+the backport with the original change.  If you instead want the backport
+to be completely independent with no association to the original review,
+you may delete the Change-Id line and let a new one be generated
+automatically.
 
 [[update-old]]
 === Updating an old commit
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 8197a96..6270db0 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -205,7 +205,7 @@
 True if the number of reviewers satisfies the given relation
 for the given number of reviewers.
 +
-For example, reviewers:>2 will be true for any change which has at least
+For example, reviewercount:>2 will be true for any change which has at least
 3 reviewers.
 +
 Valid relations are >=, >, \<=, <, or no relation, which will match if the
@@ -413,6 +413,19 @@
 
 * `-path:^path/.*` - changes that do not modify files from `path/`.
 
+[[onlypaths]]
+onlypaths:'PATH_LIST'::
++
+Matches changes touching the exact set of files in 'PATH_LIST' (comma-separated
+list). By default exact path matching is used, but regular expressions can be
+enabled by starting with `^`.  For example, to match all XML files use
+`file:"^.*\.xml$"`.
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns. In that case, all files changed by
+the change have to be matched by the provided regex for it to be returned as a
+result. For regex values, only a single item can be provided, since regex can be
+used to reflect multiple different paths.
+
 [[file]]
 file:'NAME', f:'NAME'::
 +
@@ -425,6 +438,18 @@
 Regular expression matching can be enabled by starting the string
 with `^`. In this mode `file:` is an alias of `path:` (see above).
 
+[[filecount]]
+filecount:'RELATION''COUNT'::
++
+True if the number of files touched by the latest patchset of a
+change satisfies the given relation for the given number of files.
++
+For example, files:>2 will be true for any change which touches at least
+3 files.
++
+Valid relations are >=, >, \<=, <, or no relation, which will match if the
+number of files is exactly equal.
+
 [[extension]]
 extension:'EXT', ext:'EXT'::
 +
@@ -498,6 +523,10 @@
 +
 True if the change has attention by the current user.
 
+has:hashtag::
++
+True if the change has at least one hashtag.
+
 
 [[is]]
 [[is-starred]]
@@ -720,6 +749,22 @@
 Valid relations are >=, >, \<=, <, or no relation, which will match if the number of unresolved
 comments is exactly equal.
 
+[[unmet_requirement]]
+unmet_requirement:'SUBMIT_REQUIREMENT_NAME'::
++
+Matches changes where the given submit requirement is evaluated and unmet (its result is UNSATISFIED, ERROR, or TIMEOUT). The requirement name comparison is case-insensitive.
+
+[[unsatisfied_requirement_count]]
+unsatisfied_requirement_count:'RELATION''NUMBER'::
++
+True if the number of unsatisfied submit requirements satisfies the given relation for the given number.
++
+For example, unsatisfied_requirement_count:>0 will be true for any change which has at least one unsatisfied
+submit requirement while unsatisfied_requirement_count:0 will be true for any change which has all submit requirements resolved.
++
+Valid relations are >=, >, \<=, <, or no relation, which will match if the number of unsatisfied
+submit requirements is exactly equal.
+
 == Argument Quoting
 
 Operator values that are not bare words (roughly A-Z, a-z, 0-9, @,
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 16ab0d7..f4c4d53 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -25,10 +25,12 @@
 value of link:#auth.gitBasicAuthPolicy[auth.gitBasicAuthPolicy],
 credentials are validated using:
 
-* The randomly generated HTTP password on the `HTTP Password` tab
-  in the user settings page if `gitBasicAuthPolicy` is `HTTP`.
+* Randomly generated HTTP authentication tokens on the `HTTP Credentials` tab
+  in the user settings page if `gitBasicAuthPolicy` is `HTTP`. Administrators
+  can limit the maximum lifetime of HTTP authentication tokens by setting
+  the option link:config-gerrit.html#auth.maxAuthTokenLifetime[auth.maxAuthTokenLifetime].
 * The LDAP password if `gitBasicAuthPolicy` is `LDAP`
-* Both, the HTTP and the LDAP passwords (in this order) if `gitBasicAuthPolicy`
+* Both, the HTTP and the LDAP credentials (in this order) if `gitBasicAuthPolicy`
   is `HTTP_LDAP`.
 
 When gitBasicAuthPolicy is set to `LDAP` or `HTTP_LDAP` and the user
@@ -440,6 +442,18 @@
 if one of the specified reviewers or CC addresses had also requested
 to receive all new change notifications.
 
+Reviewers and CC addresses can also be added silently without sending them an
+email notification by appending `:silent` to the reviewer or CC option when
+passed via push options (`-o`):
+
+----
+  git push -o r=a@example.com:silent -o cc=b@example.com:silent ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental
+----
+
+Note that `:silent` only suppresses the email notification for the current push
+transaction. The reviewers will still receive notifications for subsequent actions
+on the change as normal.
+
 If you are frequently sending changes to the same parties and/or
 branches, consider adding a custom remote block to your project's
 `.git/config` file:
@@ -636,9 +650,25 @@
 
 This requires the caller to have link:access-control.html#category_submit[Submit]
 permission on `refs/for/<ref>` (e.g. on `refs/for/refs/heads/master`).
-Note how this is different from the `Submit` permission on `refs/heads/<ref>`,
-and in particular you typically do not want to apply the `Submit` permission
-on `refs/*` (unless you are ok with bypassing submit rules).
+
+[NOTE]
+Granting `Submit` permission to `refs/for/<ref>` is very different from
+granting it on `refs/heads/<ref>`.
+Most importantly, the `Submit` to `refs/for/<ref>` permission does not
+refer to submitting changes according to the regular submit
+requirements, but rather creating a new change and merging it immediately
+bypassing the entire code-review process and its associated security gates.
+Never grant `Submit` permission on `refs/*` or `refs/for/*` as it would
+automatically provide the ability to push a new change and merge it
+immediately to any refs without review gates, and therefore may bypass
+the company's security and compliance checks.
+Never ever grant `Submit` permissions to `refs/*` or `refs/for/*` to the
+on the `All-Projects` repository, as it would automatically involve also the
+ability to create and submit any change to `/refs/meta/config`
+by any regular users, allowing any user to change the common configuration
+of all projects hosted on Gerrit, including the ability to self-assign
+the server administration role, and being able to upload and load any
+custom code through the plugin administration APIs.
 
 [[base]]
 === Selecting Merge Base
diff --git a/MODULE.bazel b/MODULE.bazel
index c530bce..ea2eb42 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -1,14 +1,21 @@
-# TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel
-# https://issues.gerritcodereview.com/issues/303819949
 module(name = "gerrit")
 
 # Core Bazel deps.
-bazel_dep(name = "bazel_features", version = "1.39.0")
-bazel_dep(name = "platforms", version = "1.0.0")
-bazel_dep(name = "rules_jvm_external", version = "6.10")
+bazel_dep(name = "bazel_features", version = "1.51.0")
+bazel_dep(name = "platforms", version = "1.1.0")
+bazel_dep(name = "rules_jvm_external", version = "7.1")
 
 # Language rules.
-bazel_dep(name = "rules_java", version = "8.16.1")
+bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
+bazel_dep(name = "aspect_rules_js", version = "3.2.3")
+bazel_dep(name = "aspect_rules_rollup", version = "2.0.1")
+bazel_dep(name = "aspect_rules_ts", version = "3.9.2")
+bazel_dep(name = "rules_android", version = "0.7.3")
+bazel_dep(name = "rules_go", version = "0.62.0")
+bazel_dep(name = "rules_java", version = "9.5.0")
+bazel_dep(name = "rules_nodejs", version = "6.7.5")
+bazel_dep(name = "rules_python", version = "2.2.0")
+bazel_dep(name = "rules_shell", version = "0.8.0")
 
 # Pin rules_java to Gerrit's chosen version. Transitive bzlmod modules can
 # otherwise raise it through MVS, which changes the Java toolchain inputs
@@ -16,15 +23,11 @@
 # but update the version to the target branch's chosen rules_java version.
 single_version_override(
     module_name = "rules_java",
-    version = "8.16.1",
+    version = "9.5.0",
 )
 
-bazel_dep(name = "rules_proto", version = "7.1.0")
-bazel_dep(name = "rules_python", version = "1.8.0-rc1")
-bazel_dep(name = "rules_shell", version = "0.6.1")
-
 # Libraries / toolchains.
-bazel_dep(name = "protobuf", version = "33.4")
+bazel_dep(name = "protobuf", version = "36.1.bcr.1")
 
 # In-tree modules.
 bazel_dep(name = "jgit")
@@ -34,13 +37,6 @@
 )
 
 # Toolchain setup.
-bazel_dep(name = "rbe_autoconfig")
-git_override(
-    module_name = "rbe_autoconfig",
-    commit = "c4d733d0399ebf2ee1ffb897be5fbaba23738e04",
-    remote = "https://github.com/davido/rbe_autoconfig.git",
-)
-
 register_toolchains("//tools:all")
 
 # Plugin packaging support: bazlets pin and generated Gerrit API version repo.
@@ -52,5 +48,8 @@
 # External dependency wiring is split out.
 include("//tools:java_deps.MODULE.bazel")
 
+# PolyGerrit dependency wiring is split out.
+include("//polygerrit-ui:polygerrit.MODULE.bazel")
+
 # Wiring for external dependencies contributed by in-tree plugins.
 include("//plugins:external_plugin_deps.MODULE.bazel")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index d47d4ab..ed5d99a 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -1,5 +1,5 @@
 {
-  "lockFileVersion": 24,
+  "lockFileVersion": 26,
   "registryFileHashes": {
     "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497",
     "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2",
@@ -12,16 +12,38 @@
     "https://bcr.bazel.build/modules/abseil-cpp/20250127.0/MODULE.bazel": "d1086e248cda6576862b4b3fe9ad76a214e08c189af5b42557a6e1888812c5d5",
     "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1",
     "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215",
-    "https://bcr.bazel.build/modules/abseil-cpp/20250814.0/MODULE.bazel": "c43c16ca2c432566cdb78913964497259903ebe8fb7d9b57b38e9f1425b427b8",
-    "https://bcr.bazel.build/modules/abseil-cpp/20250814.0/source.json": "b88bff599ceaf0f56c264c749b1606f8485cec3b8c38ba30f88a4df9af142861",
+    "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2",
+    "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c",
     "https://bcr.bazel.build/modules/abseil-py/2.1.0/MODULE.bazel": "5ebe5bf853769c65707e5c28f216798f7a4b1042015e6a36e6d03094d94bec8a",
     "https://bcr.bazel.build/modules/abseil-py/2.1.0/source.json": "0e8fc4f088ce07099c1cd6594c20c7ddbb48b4b3c0849b7d94ba94be88ff042b",
     "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896",
     "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85",
-    "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442",
-    "https://bcr.bazel.build/modules/apple_support/1.23.1/source.json": "d888b44312eb0ad2c21a91d026753f330caa48a25c9b2102fae75eb2b0dcfdd2",
+    "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e",
+    "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1",
+    "https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8",
+    "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4",
+    "https://bcr.bazel.build/modules/apple_support/2.3.0/MODULE.bazel": "d48f824ae8eeea5f837eb3038cef3615075d996d3e82eb9187192c2820605a81",
+    "https://bcr.bazel.build/modules/apple_support/2.3.0/source.json": "d99b0a50918c4484856d773026b2fd60a694668c70fc0e19d592786dc7e5e469",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/MODULE.bazel": "30dfabbfae0139b1f0036e01c201dd4c0167da3017f0b7ef3820d78e07622989",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/MODULE.bazel": "004ba890363d05372a97248c37205ae64b6fa31047629cd2c0895a9d0c7779e8",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/source.json": "ac2c3213df8f985785f1d0aeb7f0f73d5324e6e67d593d9b9470fb74a25d4a9b",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
+    "https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
+    "https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
+    "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/MODULE.bazel": "3a0363a5b8ec4931488dc327c577a78c1f5a725293ebffaa5d2613283701aa8a",
+    "https://bcr.bazel.build/modules/aspect_rules_js/3.2.3/source.json": "fa3187962f5fdeee0c6226792904aaafb2cf585717a264fa4393a767bffb99fe",
+    "https://bcr.bazel.build/modules/aspect_rules_rollup/2.0.1/MODULE.bazel": "296e3a053658c2af989ba9bd62a205e6d1fa84bdd6dd5249196546e6b84770ec",
+    "https://bcr.bazel.build/modules/aspect_rules_rollup/2.0.1/source.json": "2fe8ac1ccb4de74bf884761e070010280b272d94e3997205b361b91c75409726",
+    "https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/MODULE.bazel": "feeb6c45b69c995eca3e5ca5872658c80df658022e01044eca00cf472bb89142",
+    "https://bcr.bazel.build/modules/aspect_rules_ts/3.9.2/source.json": "cf3075502f798f71a9c5707a7684eaf0b86da11ed440fd90ea34385dc297676a",
+    "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/MODULE.bazel": "f31aa84151d31e98cffd43eb7217ccff5ec52bdd5f2d10db8f053aeb23342eca",
+    "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/source.json": "d027d264e6b6e7fc421e38189f4374fcd14a67e0bd6e0e705de8c2185c3787e1",
     "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
     "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
+    "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101",
     "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8",
     "https://bcr.bazel.build/modules/bazel_features/1.13.0/MODULE.bazel": "c14c33c7c3c730612bdbe14ebbb5e61936b6f11322ea95a6e91cd1ba962f94df",
     "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d",
@@ -30,15 +52,29 @@
     "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
     "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
     "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a",
+    "https://bcr.bazel.build/modules/bazel_features/1.25.0/MODULE.bazel": "e2e60a10a6da64bbf533f15ca652bf61a033e41c2ed734d79a9a08ba87f68c1a",
     "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
     "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
     "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
     "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
+    "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc",
     "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6",
+    "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
+    "https://bcr.bazel.build/modules/bazel_features/1.36.0/MODULE.bazel": "596cb62090b039caf1cad1d52a8bc35cf188ca9a4e279a828005e7ee49a1bec3",
     "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d",
-    "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93",
     "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
+    "https://bcr.bazel.build/modules/bazel_features/1.41.0/MODULE.bazel": "6e0f87fafed801273c371d41e22a15a6f8abf83fdd7f87d5e44ad317b94433d0",
+    "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080",
+    "https://bcr.bazel.build/modules/bazel_features/1.43.0/MODULE.bazel": "defa2226f06ba20550d6548c3a2ea2a7929634437a52973869c20c225450eb91",
+    "https://bcr.bazel.build/modules/bazel_features/1.51.0/MODULE.bazel": "8e1310d09db6ee2e4f19f9994e360aca941ddc32083edc8d9bdc19c83c94cee4",
+    "https://bcr.bazel.build/modules/bazel_features/1.51.0/source.json": "d5af1f1748d2b4ace5f04087030c0a1cfd93f5be6bdf0b5c1beb24ed0fd24955",
+    "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
     "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
+    "https://bcr.bazel.build/modules/bazel_lib/3.0.0-rc.0/MODULE.bazel": "d6e00979a98ac14ada5e31c8794708b41434d461e7e7ca39b59b765e6d233b18",
+    "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
+    "https://bcr.bazel.build/modules/bazel_lib/3.1.0/MODULE.bazel": "6809765c14e3c766a9b9286c7b0ec56ed87a73326e48fe01749f0c0fdcfe3287",
+    "https://bcr.bazel.build/modules/bazel_lib/3.2.2/MODULE.bazel": "e2c890c8a515d6bca9c66d47718aa9e44b458fde64ec7204b8030bf2d349058c",
+    "https://bcr.bazel.build/modules/bazel_lib/3.2.2/source.json": "9e84e115c20e14652c5c21401ae85ff4daa8702e265b5c0b3bf89353f17aa212",
     "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
     "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
     "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -50,23 +86,33 @@
     "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917",
     "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
     "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
+    "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c",
     "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
     "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
     "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
     "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
     "https://bcr.bazel.build/modules/bazel_worker_api/0.0.1/MODULE.bazel": "02a13b77321773b2042e70ee5e4c5e099c8ddee4cf2da9cd420442c36938d4bd",
+    "https://bcr.bazel.build/modules/bazel_worker_api/0.0.11/MODULE.bazel": "fe09a8d3fb25c95414249f72cb0936201bf7e3c3f0e302175b2fc81e68bc6069",
+    "https://bcr.bazel.build/modules/bazel_worker_api/0.0.11/source.json": "7867c70965fccd202c57796de69fcbdf0555da34e3b09a14b06d74afb0f2f26d",
     "https://bcr.bazel.build/modules/bazel_worker_api/0.0.4/MODULE.bazel": "460aa12d01231a80cce03c548287b433b321d205b0028ae596728c35e5ee442e",
-    "https://bcr.bazel.build/modules/bazel_worker_api/0.0.4/source.json": "d353c410d47a8b65d09fa98e83d57ebec257a2c2b9c6e42d6fda1cb25e5464a5",
+    "https://bcr.bazel.build/modules/bazel_worker_api/0.0.8/MODULE.bazel": "396c1ef53835aafe3d42ce6619080531ee770648303731f16cfaa33fa056bf0c",
+    "https://bcr.bazel.build/modules/bazel_worker_java/0.0.11/MODULE.bazel": "1ee9e4c28ce455dbacdff8e007e133ea209e6f673a9e97cf20e99b161bc17548",
+    "https://bcr.bazel.build/modules/bazel_worker_java/0.0.11/source.json": "48b9b9c55f69cc360a54a712f47b889dd3d367ce2d45c3ccec242e9af86fe9cd",
     "https://bcr.bazel.build/modules/bazel_worker_java/0.0.4/MODULE.bazel": "82494a01018bb7ef06d4a17ec4cd7a758721f10eb8b6c820a818e70d669500db",
-    "https://bcr.bazel.build/modules/bazel_worker_java/0.0.4/source.json": "a2d30458fd86cf022c2b6331e652526fa08e17573b2f5034a9dbcacdf9c2583c",
-    "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84",
-    "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8",
+    "https://bcr.bazel.build/modules/bazel_worker_java/0.0.8/MODULE.bazel": "e76479eae70bd4e8f5f4c2dfc5d03ab971cfb18750246c7b3f3454c5c2ee6629",
+    "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f",
+    "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd",
+    "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
+    "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/MODULE.bazel": "f1b7bb2dd53e8f2ef984b39485ec8a44e9076dda5c4b8efd2fb4c6a6e856a31d",
+    "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/source.json": "ebe931bfe362e4b41e59ee00a528db6074157ff2ced92eb9e970acab2e1089c9",
     "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
     "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350",
     "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
     "https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
     "https://bcr.bazel.build/modules/gazelle/0.40.0/MODULE.bazel": "42ba5378ebe845fca43989a53186ab436d956db498acde790685fe0e8f9c6146",
-    "https://bcr.bazel.build/modules/gazelle/0.40.0/source.json": "1e5ef6e4d8b9b6836d93273c781e78ff829ea2e077afef7a57298040fa4f010a",
+    "https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc",
+    "https://bcr.bazel.build/modules/gazelle/0.51.3/MODULE.bazel": "618a729142f66de1e2cb776d026413763be5b80d5e3d29ffb9d3d90c5defde90",
+    "https://bcr.bazel.build/modules/gazelle/0.51.3/source.json": "fbe5312a01fb4a2a58caff4a0d40dcf384b6f0bda75e85546663360da1d7538a",
     "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
     "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
     "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
@@ -74,12 +120,18 @@
     "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108",
     "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46",
     "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713",
+    "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
+    "https://bcr.bazel.build/modules/jq.bzl/0.4.0/MODULE.bazel": "a7b39b37589f2b0dad53fd6c1ccaabbdb290330caa920d7ef3e6aad068cd4ab2",
+    "https://bcr.bazel.build/modules/jq.bzl/0.4.0/source.json": "52ec7530c4618e03f634b30ff719814a68d7d39c235938b7aa2abbfe1eb1c52c",
     "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
+    "https://bcr.bazel.build/modules/jsoncpp/1.9.6.bcr.2/MODULE.bazel": "64f508885f907ac2518039b3d0c5c1703a8f6137d9f5d635067ba93d33c2670a",
+    "https://bcr.bazel.build/modules/jsoncpp/1.9.6.bcr.2/source.json": "13199fa0a267ca46814e9e6ab313a71890c8f1822e57376fdc23a2d2cd384051",
     "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0",
-    "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000",
     "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
     "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74",
-    "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9",
+    "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
+    "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
+    "https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
     "https://bcr.bazel.build/modules/package_metadata/0.0.7/MODULE.bazel": "7adb03933fc8401f495800cf4eafcff0edc6da0ff55c7db223ef69d19f689486",
     "https://bcr.bazel.build/modules/package_metadata/0.0.7/source.json": "50639625e937b56115012674c797cca7a05a96b4878c87d803c13dc2b31de8a0",
     "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -91,39 +143,48 @@
     "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d",
     "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc",
     "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580",
-    "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96",
+    "https://bcr.bazel.build/modules/platforms/1.1.0/MODULE.bazel": "1c0c09f5bdcf4b3f924720d2478a3711cb39f4977019ca5988685e5b7e18b3d2",
+    "https://bcr.bazel.build/modules/platforms/1.1.0/source.json": "fcf351c47596c939140ab0d333dfdd08ed1ea6ce33c2fe70c12493a301cf1344",
     "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7",
     "https://bcr.bazel.build/modules/protobuf/23.1/MODULE.bazel": "88b393b3eb4101d18129e5db51847cd40a5517a53e81216144a8c32dfeeca52a",
     "https://bcr.bazel.build/modules/protobuf/24.4/MODULE.bazel": "7bc7ce5f2abf36b3b7b7c8218d3acdebb9426aeb35c2257c96445756f970eb12",
     "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c",
     "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d",
     "https://bcr.bazel.build/modules/protobuf/27.2/MODULE.bazel": "32450b50673882e4c8c3d10a83f3bc82161b213ed2f80d17e38bece8f165c295",
+    "https://bcr.bazel.build/modules/protobuf/29.0-rc2.bcr.1/MODULE.bazel": "52f4126f63a2f0bbf36b99c2a87648f08467a4eaf92ba726bc7d6a500bbf770c",
     "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df",
     "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92",
     "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e",
     "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95",
+    "https://bcr.bazel.build/modules/protobuf/29.3/MODULE.bazel": "77480eea5fb5541903e49683f24dc3e09f4a79e0eea247414887bb9fc0066e94",
     "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0",
     "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573",
     "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858",
     "https://bcr.bazel.build/modules/protobuf/31.1/MODULE.bazel": "379a389bb330b7b8c1cdf331cc90bf3e13de5614799b3b52cdb7c6f389f6b38e",
     "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d",
+    "https://bcr.bazel.build/modules/protobuf/33.1/MODULE.bazel": "982c8a0cceab4d790076f72b7677faf836b0dfadc2b66a34aab7232116c4ae39",
     "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42",
-    "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79",
+    "https://bcr.bazel.build/modules/protobuf/36.1.bcr.1/MODULE.bazel": "4cc1928927b3460ec07e35623ae3d52c8d77ebed515681e0400d9b14b221a586",
+    "https://bcr.bazel.build/modules/protobuf/36.1.bcr.1/source.json": "566e7f47e4efaa1b6b5197d0cda7a228fe3ee6324d29463b0a43fc6f56ead03d",
     "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e",
     "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34",
-    "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680",
+    "https://bcr.bazel.build/modules/pybind11_bazel/3.0.0/MODULE.bazel": "a2bfa6020ed603a00d944161c63173c7f109774e99bee0c2cd8dbf24159f8134",
+    "https://bcr.bazel.build/modules/pybind11_bazel/3.0.0/source.json": "d8f5104d4c21d272bf327ebe44366fb0b4c036cdaa1f5cceb21a408ca4ef2ef8",
     "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206",
     "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a",
-    "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4",
     "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa",
+    "https://bcr.bazel.build/modules/re2/2025-11-05.bcr.1/MODULE.bazel": "3d9d4995833fc0334fc5c88b56a05288dd25d651544cd7b2233bbd6357bbeba0",
+    "https://bcr.bazel.build/modules/re2/2025-11-05.bcr.1/source.json": "7df1394aabda1c9bc188a302f5d54b1c657924edd04ebc57d2be29dbd7efd141",
     "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
+    "https://bcr.bazel.build/modules/rules_android/0.6.4/MODULE.bazel": "b4cde12d506dd65d82b2be39761f49f5797303343a3d5b4ee191c0cdf9ef387c",
     "https://bcr.bazel.build/modules/rules_android/0.6.6/MODULE.bazel": "b0fb569752aab65ab1a9db0a8f6cfaf5aa1754965e17e95dcf0e4d88e192a68d",
-    "https://bcr.bazel.build/modules/rules_android/0.6.6/source.json": "a9d8dc2d5a102dc03269a94acc886a4cab82cdcb9ccbc77b0f665d6d17a6ae09",
+    "https://bcr.bazel.build/modules/rules_android/0.7.1/MODULE.bazel": "a806fc382a774252f228a40e3b11b9fcc6276f8778c7fb33e9f72937c6258363",
+    "https://bcr.bazel.build/modules/rules_android/0.7.3/MODULE.bazel": "1fbc49fb397d31d74be83fe63eb0a5dc1a79df172ca90772166c35ab4cd67e7b",
+    "https://bcr.bazel.build/modules/rules_android/0.7.3/source.json": "80ffccd224a7f9b665f32360a64ba4228ba85c75fd0f6af43e290660147bb151",
     "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
-    "https://bcr.bazel.build/modules/rules_apple/3.16.0/source.json": "d8b5fe461272018cc07cfafce11fe369c7525330804c37eec5a82f84cd475366",
+    "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68",
     "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
     "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191",
-    "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac",
     "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
     "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
     "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
@@ -132,11 +193,19 @@
     "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
     "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
     "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
+    "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
     "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
     "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37",
     "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
-    "https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.15/MODULE.bazel": "6a0a4a75a57aa6dc888300d848053a58c6b12a29f89d4304e1c41448514ec6e8",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.18/MODULE.bazel": "4460ec36adc8f722a6a2a4ac9374cb91f2acebadaa93fc37966129afb3dece87",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.18/source.json": "abad668ff2fd63ada1ac49bf386d37e27048b89a3465a6fd968bb832b00a09d3",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc",
     "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.9/MODULE.bazel": "34263f1dca62ea664265438cef714d7db124c03e1ed55ebb4f1dc860164308d1",
     "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
     "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
     "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8",
@@ -144,29 +213,42 @@
     "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd",
     "https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0",
     "https://bcr.bazel.build/modules/rules_go/0.51.0-rc2/MODULE.bazel": "edfc3a9cea7bedb0eaaff37b0d7817c1a4bf72b3c615580b0ffcee6c52690fd4",
-    "https://bcr.bazel.build/modules/rules_go/0.51.0-rc2/source.json": "6b5cd0b3da2bd0e6949580851db990a04af0a285f072b9a0f059424457cd8cc9",
-    "https://bcr.bazel.build/modules/rules_java/8.16.1/MODULE.bazel": "0f20b1cecaa8e52f60a8f071e59a20b4e3b9a67f6c56c802ea256f6face692d3",
-    "https://bcr.bazel.build/modules/rules_java/8.16.1/source.json": "072f8d11264edc499621be2dc9ea01d6395db5aa6f8799c034ae01a3e857f2e4",
+    "https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a",
+    "https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
+    "https://bcr.bazel.build/modules/rules_go/0.60.0/MODULE.bazel": "4a57ff2ffc2a3570e3c5646575c5a4b07287e91bcdac5d1f72383d51502b48cb",
+    "https://bcr.bazel.build/modules/rules_go/0.62.0/MODULE.bazel": "8ee616065c3d2b2f7ac0880108316ce8d0c332b3a30aad24e95c0bc124ec853e",
+    "https://bcr.bazel.build/modules/rules_go/0.62.0/source.json": "36d781c558eb7d3bd49dc5e190455714f174232372f15207ab200b4348bda3e6",
+    "https://bcr.bazel.build/modules/rules_java/9.5.0/MODULE.bazel": "1c2f1c9e2fc75db13164da56012ff626cfb525c1a3de04329286c46829f2d6d9",
+    "https://bcr.bazel.build/modules/rules_java/9.5.0/source.json": "ba5c06fe7b6a31922e8b9c11947177ebed8fa46b45b70a5f810d97ac74bf888b",
     "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7",
     "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909",
     "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036",
     "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d",
     "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
     "https://bcr.bazel.build/modules/rules_jvm_external/6.10/MODULE.bazel": "33e636ca6bc9ee0fa090a38aa33c631ded2d8cf6fead4124181d1b35dc474f7c",
-    "https://bcr.bazel.build/modules/rules_jvm_external/6.10/source.json": "c191249787625db72616a3fb3cc2786ab57355a2e3b615402b8b3b66b0f995b7",
     "https://bcr.bazel.build/modules/rules_jvm_external/6.2/MODULE.bazel": "36a6e52487a855f33cb960724eb56547fa87e2c98a0474c3acad94339d7f8e99",
     "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
     "https://bcr.bazel.build/modules/rules_jvm_external/6.6/MODULE.bazel": "153042249c7060536dc95b6bb9f9bb8063b8a0b0cb7acdb381bddbc2374aed55",
     "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd",
+    "https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274",
+    "https://bcr.bazel.build/modules/rules_jvm_external/7.0/MODULE.bazel": "421482bdbcf05709f933c96b867a599deb517f2804ceb3e74511880610cfbf71",
+    "https://bcr.bazel.build/modules/rules_jvm_external/7.1/MODULE.bazel": "e151b1beaa45bfc9e5de4df637d980e27039eb2b1092a95972bf2adbf99ac1a0",
+    "https://bcr.bazel.build/modules/rules_jvm_external/7.1/source.json": "8cb571a9631c39bf29c1842958542f14bba1c704fe2bcde4fa54679b2a6b3d6f",
     "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
     "https://bcr.bazel.build/modules/rules_kotlin/1.9.5/MODULE.bazel": "043a16a572f610558ec2030db3ff0c9938574e7dd9f58bded1bb07c0192ef025",
     "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
     "https://bcr.bazel.build/modules/rules_kotlin/2.1.3/MODULE.bazel": "ce7def6d576aa8d3a9c6d10e13b4d157296229674371f67dbf788dae0afae3d5",
-    "https://bcr.bazel.build/modules/rules_kotlin/2.1.3/source.json": "0b0dc9400f14b5fbb13d278ad3bf0413cdbaf0da0db337e055b855e35b878a3b",
+    "https://bcr.bazel.build/modules/rules_kotlin/2.2.2/MODULE.bazel": "00d39c5e0fa78cd86193946265bb849e7878c24e44260f9525108428852b315c",
+    "https://bcr.bazel.build/modules/rules_kotlin/2.3.20/MODULE.bazel": "3443d53d275e14fecfebd0b491f01d06ea3883c04a1b3336e7ae9d5ec9066bef",
+    "https://bcr.bazel.build/modules/rules_kotlin/2.3.20/source.json": "5a5553cffea43f2c5156c8ad0de4a14ad95413ceb39cd4d08f50e2aea86927e8",
     "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0",
     "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d",
     "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c",
     "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb",
+    "https://bcr.bazel.build/modules/rules_nodejs/6.2.0/MODULE.bazel": "ec27907f55eb34705adb4e8257952162a2d4c3ed0f0b3b4c3c1aad1fac7be35e",
+    "https://bcr.bazel.build/modules/rules_nodejs/6.7.3/MODULE.bazel": "c22a48b2a0dbf05a9dc5f83837bbc24c226c1f6e618de3c3a610044c9f336056",
+    "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/MODULE.bazel": "97e6794043821d23c013baa4a50fd1c599f2e6ae92b06e2c5f1cd7074fd83e7c",
+    "https://bcr.bazel.build/modules/rules_nodejs/6.7.5/source.json": "d60ee5a76258b1c8f99545ed24172b44d43ba64ca1a2dfc04371ef203df19fdf",
     "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
     "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
     "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a",
@@ -187,26 +269,33 @@
     "https://bcr.bazel.build/modules/rules_python/0.37.1/MODULE.bazel": "3faeb2d9fa0a81f8980643ee33f212308f4d93eea4b9ce6f36d0b742e71e9500",
     "https://bcr.bazel.build/modules/rules_python/0.37.2/MODULE.bazel": "b5ffde91410745750b6c13be1c5dc4555ef5bc50562af4a89fd77807fdde626a",
     "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
-    "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
     "https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43",
+    "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
     "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6",
     "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8",
+    "https://bcr.bazel.build/modules/rules_python/1.6.3/MODULE.bazel": "a7b80c42cb3de5ee2a5fa1abc119684593704fcd2fec83165ebe615dec76574f",
     "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8",
-    "https://bcr.bazel.build/modules/rules_python/1.8.0-rc1/MODULE.bazel": "d5348333fd8be9589c3ea8d9110fd00b1c7d84bc7b505668307cbe9f105e9c8f",
-    "https://bcr.bazel.build/modules/rules_python/1.8.0-rc1/source.json": "d26719d5b92a569eaa387bad9667a042d4810193f279ea64590c9d76ae38f3ba",
+    "https://bcr.bazel.build/modules/rules_python/2.0.2/MODULE.bazel": "9d26ecb43f248c2663fb29f3c9b833aa86c31e736096e58c7077f80038f8d32f",
+    "https://bcr.bazel.build/modules/rules_python/2.2.0/MODULE.bazel": "9ce85518b14625a3abec3c95e3fa739ab0578e58240ef829af20efebcdebc41a",
+    "https://bcr.bazel.build/modules/rules_python/2.2.0/source.json": "274b1ca2363520292527f21b8237aa0f562003ea5912ad07d96d98e87738f618",
     "https://bcr.bazel.build/modules/rules_robolectric/4.14.1.2/MODULE.bazel": "d44fec647d0aeb67b9f3b980cf68ba634976f3ae7ccd6c07d790b59b87a4f251",
     "https://bcr.bazel.build/modules/rules_robolectric/4.14.1.2/source.json": "37c10335f2361c337c5c1f34ed36d2da70534c23088062b33a8bdaab68aa9dea",
+    "https://bcr.bazel.build/modules/rules_rust/0.69.0/MODULE.bazel": "4326fec48f2fef0d514de46346f7f77e200c82936dd08b91c9ef039fbdad5c10",
+    "https://bcr.bazel.build/modules/rules_rust/0.69.0/source.json": "0d094307d690cc18b3ab003998697be8070a206f65592c5c8476999796f11c4b",
     "https://bcr.bazel.build/modules/rules_shell/0.1.2/MODULE.bazel": "66e4ca3ce084b04af0b9ff05ff14cab4e5df7503973818bb91cbc6cda08d32fc",
     "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
     "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
     "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
     "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b",
-    "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c",
+    "https://bcr.bazel.build/modules/rules_shell/0.8.0/MODULE.bazel": "f6a89f1d6a669a26f28fe814503857055d76306b79cfc11d12399af08d0b80ae",
+    "https://bcr.bazel.build/modules/rules_shell/0.8.0/source.json": "eb53cc815bc503c6683c5fe12d943f98883f81fc22f51403ec8a95610cba4195",
     "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
     "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
-    "https://bcr.bazel.build/modules/rules_swift/2.1.1/source.json": "40fc69dfaac64deddbb75bd99cdac55f4427d9ca0afbe408576a65428427a186",
+    "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
+    "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400",
     "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
     "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
+    "https://bcr.bazel.build/modules/stardoc/0.5.4/MODULE.bazel": "6569966df04610b8520957cb8e97cf2e9faac2c0309657c537ab51c16c18a2a4",
     "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef",
     "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd",
     "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c",
@@ -214,9 +303,19 @@
     "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5",
     "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
     "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
-    "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
+    "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f",
+    "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9",
+    "https://bcr.bazel.build/modules/tar.bzl/0.10.4/source.json": "20143442376c03426f6135292ba02d825cb75308aa47e6bf42dd4cc5a435c2ff",
+    "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
+    "https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351",
+    "https://bcr.bazel.build/modules/toml.bzl/0.3.0/MODULE.bazel": "5016e5dd1ad2200e119a4b28b2b3935e276c4b480f2fe3e952bea7eeba88f578",
+    "https://bcr.bazel.build/modules/toml.bzl/0.4.1/MODULE.bazel": "6bc0b938f03ade8d58c2fca0ad5c3fa12b4764e1e1927ad50b0c860286db2167",
+    "https://bcr.bazel.build/modules/toml.bzl/0.4.1/source.json": "86a90afd8b43c9b69ad31f5c03998c3adcf4b08175e621addb93e3a38eec538b",
     "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
     "https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9",
+    "https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
+    "https://bcr.bazel.build/modules/yq.bzl/0.3.4/MODULE.bazel": "d3a270662f5d766cd7229732d65a5a5bc485240c3007343dd279edfb60c9ae27",
+    "https://bcr.bazel.build/modules/yq.bzl/0.3.4/source.json": "786dafdc2843722da3416e4343ee1a05237227f068590779a6e8496a2064c0f9",
     "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
     "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
     "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca",
@@ -225,545 +324,112 @@
   },
   "selectedYankedVersions": {},
   "moduleExtensions": {
-    "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": {
+    "@@aspect_tools_telemetry+//:extension.bzl%telemetry": {
       "general": {
-        "bzlTransitiveDigest": "NFQjcZF+fAvf5fDH+pqsx4JrfzP9PuHBz6S6ZutIbnw=",
-        "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=",
-        "recordedFileInputs": {
-          "@@pybind11_bazel+//MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34"
-        },
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "bzlTransitiveDigest": "4w9RM0xjdKo1crk5zL20a/TuhqO0P1z1LsuXDneBXD4=",
+        "usagesDigest": "VuWbxUOa4PEE7KktNjLNLRMFWZRv30gglkSl7mxZj9w=",
+        "recordedInputs": [
+          "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+",
+          "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+",
+          "ENV:ASPECT_TOOLS_TELEMETRY_TEST \\0"
+        ],
         "generatedRepoSpecs": {
-          "pybind11": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+          "aspect_tools_telemetry_report": {
+            "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository",
             "attributes": {
-              "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel",
-              "strip_prefix": "pybind11-2.12.0",
-              "urls": [
-                "https://github.com/pybind/pybind11/archive/v2.12.0.zip"
-              ]
+              "deps": {
+                "aspect_rules_js": "3.2.3",
+                "aspect_rules_ts": "3.9.2",
+                "aspect_tools_telemetry": "0.4.2"
+              },
+              "last_notice": 1
             }
           }
         },
-        "recordedRepoMappingEntries": [
-          [
-            "pybind11_bazel+",
-            "bazel_tools",
-            "bazel_tools"
-          ]
-        ]
+        "moduleExtensionMetadata": {
+          "useAllRepos": "NO",
+          "reproducible": false
+        }
+      }
+    },
+    "@@protobuf+//python/dist:system_python.bzl%system_python_extension": {
+      "general": {
+        "bzlTransitiveDigest": "qh0n9IrXU/xS94wxKQrG1J63zrLkA1Wy2Y3BQxptPcI=",
+        "usagesDigest": "5nmtRivsScwzftla7fWqD7lfrvKOqRvRmabq+cY3KLU=",
+        "recordedInputs": [],
+        "generatedRepoSpecs": {
+          "system_python": {
+            "repoRuleId": "@@protobuf+//python/dist:system_python.bzl%system_python",
+            "attributes": {
+              "minimum_python_version": "3.9"
+            }
+          }
+        }
       }
     },
     "@@rules_android+//bzlmod_extensions:apksig.bzl%apksig_extension": {
       "general": {
-        "bzlTransitiveDigest": "By9qVNN7G4oL1vYOJXye7Dp/CbR2ar9oxAW8WXAVcVw=",
-        "usagesDigest": "xq6OVkELeJvOgYo3oY/sUBsGFbcqdV+9BYiNgSPV/po=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "bzlTransitiveDigest": "O/gCjP4/VVnaP+zTRGN3DFrkkxLE5GMbE4M1HG3noGQ=",
+        "usagesDigest": "wLrLUdBOQnztFcFQraEsWu+M4xtuF82/ZGmnhyxO5/Q=",
+        "recordedInputs": [
+          "REPO_MAPPING:rules_android+,bazel_tools bazel_tools"
+        ],
         "generatedRepoSpecs": {
           "apksig": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
-              "url": "https://android.googlesource.com/platform/tools/apksig/+archive/24e3075e68ebe17c0b529bb24bfda819db5e2f3b.tar.gz",
+              "urls": [
+                "https://mirror.bazel.build/android.googlesource.com/platform/tools/apksig/+archive/24e3075e68ebe17c0b529bb24bfda819db5e2f3b.tar.gz"
+              ],
+              "sha256": "12e44fdbd219c5e1cc62099c2a01d775957603d2d4f693f8285f9d95d9a04e77",
               "build_file": "@@rules_android+//bzlmod_extensions:apksig.BUILD"
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_android+",
-            "bazel_tools",
-            "bazel_tools"
-          ]
-        ]
+        }
       }
     },
     "@@rules_android+//bzlmod_extensions:com_android_dex.bzl%com_android_dex_extension": {
       "general": {
-        "bzlTransitiveDigest": "rvWbJQc8jInfIAaXIMhSOqUlwM9HVeLey6q0ISvg08Y=",
-        "usagesDigest": "toF8IFMu98H/VU2p1sfVC5fVXVYJunpbbmtM6tOsQXY=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "bzlTransitiveDigest": "fVTI/3B6KjJ93jRf+pOa6ExSALb1hgRndpTPBrJKoZQ=",
+        "usagesDigest": "QffKrRLRrfyxMU5u/o3mDLJNoWRk/D5i2SIumoSgm3M=",
+        "recordedInputs": [
+          "REPO_MAPPING:rules_android+,bazel_tools bazel_tools"
+        ],
         "generatedRepoSpecs": {
           "com_android_dex": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
-              "url": "https://android.googlesource.com/platform/dalvik/+archive/5a81c499a569731e2395f7c8d13c0e0d4e17a2b6.tar.gz",
-              "build_file": "@@rules_android+//bzlmod_extensions:com_android_dex.BUILD"
+              "urls": [
+                "https://mirror.bazel.build/android.googlesource.com/platform/dalvik/+archive/5a81c499a569731e2395f7c8d13c0e0d4e17a2b6.tar.gz"
+              ],
+              "build_file": "@@rules_android+//bzlmod_extensions:com_android_dex.BUILD",
+              "sha256": "86b4848c038bf687fadc812239cb01fb8d1d15cef3125b480a0448360992b95d"
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_android+",
-            "bazel_tools",
-            "bazel_tools"
-          ]
-        ]
+        }
       }
     },
     "@@rules_android+//rules/android_sdk_repository:rule.bzl%android_sdk_repository_extension": {
       "general": {
-        "bzlTransitiveDigest": "NAy+0M15JNVEBb8Tny6t7j3lKqTnsAMjoBB6LJ+C370=",
-        "usagesDigest": "g9Ur6X6qhf9a8MmY9qXU/jFjkyk/aZVBegI0yVMF0z4=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "bzlTransitiveDigest": "qHbR00gVzVzkxX+PRtv4UGcUFMtBz7TK9CNYUWH8nIE=",
+        "usagesDigest": "x1HLqlqAwv0KTxojlsMXLqzK2rEGmh0n16qUmbSgEak=",
+        "recordedInputs": [],
         "generatedRepoSpecs": {
           "androidsdk": {
             "repoRuleId": "@@rules_android+//rules/android_sdk_repository:rule.bzl%_android_sdk_repository",
             "attributes": {}
           }
-        },
-        "recordedRepoMappingEntries": []
-      }
-    },
-    "@@rules_apple+//apple:apple.bzl%provisioning_profile_repository_extension": {
-      "general": {
-        "bzlTransitiveDigest": "DBjF8z9KnkAVkDon8si62fhfjje60FibbeIt+zE+BWw=",
-        "usagesDigest": "vsJl8Rw5NL+5Ag2wdUDoTeRF/5klkXO8545Iy7U1Q08=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
-        "generatedRepoSpecs": {
-          "local_provisioning_profiles": {
-            "repoRuleId": "@@rules_apple+//apple/internal:local_provisioning_profiles.bzl%provisioning_profile_repository",
-            "attributes": {}
-          }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "apple_support+",
-            "bazel_skylib",
-            "bazel_skylib+"
-          ],
-          [
-            "bazel_tools",
-            "rules_cc",
-            "rules_cc+"
-          ],
-          [
-            "rules_apple+",
-            "bazel_skylib",
-            "bazel_skylib+"
-          ],
-          [
-            "rules_apple+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_apple+",
-            "build_bazel_apple_support",
-            "apple_support+"
-          ],
-          [
-            "rules_apple+",
-            "build_bazel_rules_swift",
-            "rules_swift+"
-          ],
-          [
-            "rules_cc+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_cc+",
-            "cc_compatibility_proxy",
-            "rules_cc++compatibility_proxy+cc_compatibility_proxy"
-          ],
-          [
-            "rules_cc+",
-            "rules_cc",
-            "rules_cc+"
-          ],
-          [
-            "rules_cc++compatibility_proxy+cc_compatibility_proxy",
-            "rules_cc",
-            "rules_cc+"
-          ],
-          [
-            "rules_swift+",
-            "bazel_skylib",
-            "bazel_skylib+"
-          ],
-          [
-            "rules_swift+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_swift+",
-            "build_bazel_apple_support",
-            "apple_support+"
-          ],
-          [
-            "rules_swift+",
-            "build_bazel_rules_swift",
-            "rules_swift+"
-          ],
-          [
-            "rules_swift+",
-            "build_bazel_rules_swift_local_config",
-            "rules_swift++non_module_deps+build_bazel_rules_swift_local_config"
-          ]
-        ]
-      }
-    },
-    "@@rules_apple+//apple:extensions.bzl%non_module_deps": {
-      "general": {
-        "bzlTransitiveDigest": "4xtddSlWIQdtVNVuvOI62fJfQVETHZCVWFvYYwQHMR4=",
-        "usagesDigest": "M3VqFpeTCo4qmrNKGZw0dxBHvTYDrfV3cscGzlSAhQ4=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
-        "generatedRepoSpecs": {
-          "xctestrunner": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "urls": [
-                "https://github.com/google/xctestrunner/archive/b7698df3d435b6491b4b4c0f9fc7a63fbed5e3a6.tar.gz"
-              ],
-              "strip_prefix": "xctestrunner-b7698df3d435b6491b4b4c0f9fc7a63fbed5e3a6",
-              "sha256": "ae3a063c985a8633cb7eb566db21656f8db8eb9a0edb8c182312c7f0db53730d"
-            }
-          }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_apple+",
-            "bazel_tools",
-            "bazel_tools"
-          ]
-        ]
-      }
-    },
-    "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": {
-      "general": {
-        "bzlTransitiveDigest": "HJP3wKFbPhB1mSYjJS6kbXEiP+OQxvsBdqpvyJN6I3s=",
-        "usagesDigest": "qTwqmKKUfWcPdvM0waG+CPWrxsbeAWVeUxavm7tEk9E=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
-        "generatedRepoSpecs": {
-          "com_github_jetbrains_kotlin_git": {
-            "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository",
-            "attributes": {
-              "urls": [
-                "https://github.com/JetBrains/kotlin/releases/download/v2.1.0/kotlin-compiler-2.1.0.zip"
-              ],
-              "sha256": "b6698d5728ad8f9edcdd01617d638073191d8a03139cc538a391b4e3759ad297"
-            }
-          },
-          "com_github_jetbrains_kotlin": {
-            "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository",
-            "attributes": {
-              "git_repository_name": "com_github_jetbrains_kotlin_git",
-              "compiler_version": "2.1.0"
-            }
-          },
-          "com_github_google_ksp": {
-            "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository",
-            "attributes": {
-              "urls": [
-                "https://github.com/google/ksp/releases/download/2.1.0-1.0.28/artifacts.zip"
-              ],
-              "sha256": "fc27b08cadc061a4a989af01cbeccb613feef1995f4aad68f2be0f886a3ee251",
-              "strip_version": "2.1.0-1.0.28"
-            }
-          },
-          "com_github_pinterest_ktlint": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file",
-            "attributes": {
-              "sha256": "a9f923be58fbd32670a17f0b729b1df804af882fa57402165741cb26e5440ca1",
-              "urls": [
-                "https://github.com/pinterest/ktlint/releases/download/1.3.1/ktlint"
-              ],
-              "executable": true
-            }
-          },
-          "kotlinx_serialization_core_jvm": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar",
-            "attributes": {
-              "sha256": "29c821a8d4e25cbfe4f2ce96cdd4526f61f8f4e69a135f9612a34a81d93b65f1",
-              "urls": [
-                "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-core-jvm/1.6.3/kotlinx-serialization-core-jvm-1.6.3.jar"
-              ]
-            }
-          },
-          "kotlinx_serialization_json": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar",
-            "attributes": {
-              "sha256": "8c0016890a79ab5980dd520a5ab1a6738023c29aa3b6437c482e0e5fdc06dab1",
-              "urls": [
-                "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json/1.6.3/kotlinx-serialization-json-1.6.3.jar"
-              ]
-            }
-          },
-          "kotlinx_serialization_json_jvm": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar",
-            "attributes": {
-              "sha256": "d3234179bcff1886d53d67c11eca47f7f3cf7b63c349d16965f6db51b7f3dd9a",
-              "urls": [
-                "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json-jvm/1.6.3/kotlinx-serialization-json-jvm-1.6.3.jar"
-              ]
-            }
-          }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_kotlin+",
-            "bazel_tools",
-            "bazel_tools"
-          ]
-        ]
-      }
-    },
-    "@@rules_python+//python/extensions:config.bzl%config": {
-      "general": {
-        "bzlTransitiveDigest": "TRGIl0CDmorwyNiblOYyhWuyKzi/kWFHT2uIofq7o9Y=",
-        "usagesDigest": "tIEieEA/gbsjNF3L/Oouyg6UdqGOVxFsPqBjFxkTAKM=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
-        "generatedRepoSpecs": {
-          "rules_python_internal": {
-            "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo",
-            "attributes": {
-              "transition_setting_generators": {},
-              "transition_settings": []
-            }
-          },
-          "pypi__build": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl",
-              "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__click": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl",
-              "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__colorama": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl",
-              "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__importlib_metadata": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl",
-              "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__installer": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl",
-              "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__more_itertools": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl",
-              "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__packaging": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl",
-              "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__pep517": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl",
-              "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__pip": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl",
-              "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__pip_tools": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl",
-              "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__pyproject_hooks": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl",
-              "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__setuptools": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl",
-              "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__tomli": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl",
-              "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__wheel": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl",
-              "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          },
-          "pypi__zipp": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
-            "attributes": {
-              "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl",
-              "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e",
-              "type": "zip",
-              "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
-            }
-          }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_python+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_python+",
-            "pypi__build",
-            "rules_python++config+pypi__build"
-          ],
-          [
-            "rules_python+",
-            "pypi__click",
-            "rules_python++config+pypi__click"
-          ],
-          [
-            "rules_python+",
-            "pypi__colorama",
-            "rules_python++config+pypi__colorama"
-          ],
-          [
-            "rules_python+",
-            "pypi__importlib_metadata",
-            "rules_python++config+pypi__importlib_metadata"
-          ],
-          [
-            "rules_python+",
-            "pypi__installer",
-            "rules_python++config+pypi__installer"
-          ],
-          [
-            "rules_python+",
-            "pypi__more_itertools",
-            "rules_python++config+pypi__more_itertools"
-          ],
-          [
-            "rules_python+",
-            "pypi__packaging",
-            "rules_python++config+pypi__packaging"
-          ],
-          [
-            "rules_python+",
-            "pypi__pep517",
-            "rules_python++config+pypi__pep517"
-          ],
-          [
-            "rules_python+",
-            "pypi__pip",
-            "rules_python++config+pypi__pip"
-          ],
-          [
-            "rules_python+",
-            "pypi__pip_tools",
-            "rules_python++config+pypi__pip_tools"
-          ],
-          [
-            "rules_python+",
-            "pypi__pyproject_hooks",
-            "rules_python++config+pypi__pyproject_hooks"
-          ],
-          [
-            "rules_python+",
-            "pypi__setuptools",
-            "rules_python++config+pypi__setuptools"
-          ],
-          [
-            "rules_python+",
-            "pypi__tomli",
-            "rules_python++config+pypi__tomli"
-          ],
-          [
-            "rules_python+",
-            "pypi__wheel",
-            "rules_python++config+pypi__wheel"
-          ],
-          [
-            "rules_python+",
-            "pypi__zipp",
-            "rules_python++config+pypi__zipp"
-          ]
-        ]
+        }
       }
     },
     "@@rules_python+//python/uv:uv.bzl%uv": {
       "general": {
-        "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=",
-        "usagesDigest": "s63+dBGiTSbvuV/QBtGNrbYox+e7K5QXThW1NgBreis=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "bzlTransitiveDigest": "ELjwPp2kLku5M3S/gpjjVjy3TwT760/zVEQ70nJreHU=",
+        "usagesDigest": "LCPgc6OYAryd0HQJS9CtnCxvca1YfrjIZ67iPax2aRs=",
+        "recordedInputs": [
+          "REPO_MAPPING:rules_python+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_python+,platforms platforms"
+        ],
         "generatedRepoSpecs": {
           "uv": {
             "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo",
@@ -783,180 +449,1148 @@
               "toolchain_target_settings": {}
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_python+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_python+",
-            "platforms",
-            "platforms"
-          ]
-        ]
+        }
       }
     },
-    "@@rules_swift+//swift:extensions.bzl%non_module_deps": {
+    "@@rules_rust+//crate_universe:extension.bzl%crate": {
       "general": {
-        "bzlTransitiveDigest": "6axDCXf6fQoPav8hojnUBxGA0FAMqLvtpC1cRsisCdw=",
-        "usagesDigest": "mhACFnrdMv9Wi0Mt67bxocJqviRkDSV+Ee5Mqdj5akA=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "bzlTransitiveDigest": "U90M45KtFnjTe6xNBAsNewMYPMQwkk4TgsTSlRLuLEc=",
+        "usagesDigest": "iRH26LGhchbWPEi1YexMZWF2hNpipOdcshkFuFTVThY=",
+        "recordedInputs": [
+          "ENV:CARGO_BAZEL_DEBUG \\0",
+          "ENV:CARGO_BAZEL_GENERATOR_SHA256 \\0",
+          "ENV:CARGO_BAZEL_GENERATOR_URL \\0",
+          "ENV:CARGO_BAZEL_ISOLATED \\0",
+          "ENV:CARGO_BAZEL_REPIN \\0",
+          "ENV:CARGO_BAZEL_REPIN_ONLY \\0",
+          "ENV:CARGO_BAZEL_TIMEOUT \\0",
+          "ENV:REPIN \\0",
+          "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals",
+          "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version",
+          "REPO_MAPPING:rules_cc+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:rules_cc+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_cc+,cc_compatibility_proxy rules_cc++compatibility_proxy+cc_compatibility_proxy",
+          "REPO_MAPPING:rules_cc+,platforms platforms",
+          "REPO_MAPPING:rules_cc+,rules_cc rules_cc+",
+          "REPO_MAPPING:rules_cc++compatibility_proxy+cc_compatibility_proxy,rules_cc rules_cc+",
+          "REPO_MAPPING:rules_rust+,bazel_features bazel_features+",
+          "REPO_MAPPING:rules_rust+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:rules_rust+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_rust+,rules_cc rules_cc+",
+          "REPO_MAPPING:rules_rust+,rules_rust rules_rust+"
+        ],
         "generatedRepoSpecs": {
-          "com_github_apple_swift_protobuf": {
-            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+          "crates": {
+            "repoRuleId": "@@rules_rust+//crate_universe:extensions.bzl%_generate_repo",
             "attributes": {
-              "urls": [
-                "https://github.com/apple/swift-protobuf/archive/1.20.2.tar.gz"
-              ],
-              "sha256": "3fb50bd4d293337f202d917b6ada22f9548a0a0aed9d9a4d791e6fbd8a246ebb",
-              "strip_prefix": "swift-protobuf-1.20.2/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_protobuf/BUILD.overlay"
+              "contents": {
+                "BUILD.bazel": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\npackage(default_visibility = [\"//visibility:public\"])\n\nexports_files(\n    [\n        \"cargo-bazel.json\",\n        \"crates.bzl\",\n        \"defs.bzl\",\n    ] + glob(\n        allow_empty = True,\n        include = [\"*.bazel\"],\n    ),\n)\n\nfilegroup(\n    name = \"srcs\",\n    srcs = glob(\n        allow_empty = True,\n        include = [\n            \"*.bazel\",\n            \"*.bzl\",\n        ],\n    ),\n)\n\n# Workspace Member Dependencies\nalias(\n    name = \"googletest-0.14.3\",\n    actual = \"@crates__googletest-0.14.3//:googletest\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"googletest\",\n    actual = \"@crates__googletest-0.14.3//:googletest\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"linkme-0.3.37\",\n    actual = \"@crates__linkme-0.3.37//:linkme\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"linkme\",\n    actual = \"@crates__linkme-0.3.37//:linkme\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"paste-1.0.15\",\n    actual = \"@crates__paste-1.0.15//:paste\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"paste\",\n    actual = \"@crates__paste-1.0.15//:paste\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"quote-1.0.47\",\n    actual = \"@crates__quote-1.0.47//:quote\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"quote\",\n    actual = \"@crates__quote-1.0.47//:quote\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"syn-2.0.119\",\n    actual = \"@crates__syn-2.0.119//:syn\",\n    tags = [\"manual\"],\n)\n\nalias(\n    name = \"syn\",\n    actual = \"@crates__syn-2.0.119//:syn\",\n    tags = [\"manual\"],\n)\n",
+                "alias_rules.bzl": "\"\"\"Alias that transitions its target to `compilation_mode=opt`.  Use `transition_alias=\"opt\"` to enable.\"\"\"\n\nload(\"@rules_cc//cc:defs.bzl\", \"CcInfo\")\nload(\"@rules_rust//rust:rust_common.bzl\", \"COMMON_PROVIDERS\")\n\ndef _transition_alias_impl(ctx):\n    # `ctx.attr.actual` is a list of 1 item due to the transition\n    providers = [ctx.attr.actual[0][provider] for provider in COMMON_PROVIDERS]\n    if CcInfo in ctx.attr.actual[0]:\n        providers.append(ctx.attr.actual[0][CcInfo])\n    return providers\n\ndef _change_compilation_mode(compilation_mode):\n    def _change_compilation_mode_impl(_settings, _attr):\n        return {\n            \"//command_line_option:compilation_mode\": compilation_mode,\n        }\n\n    return transition(\n        implementation = _change_compilation_mode_impl,\n        inputs = [],\n        outputs = [\n            \"//command_line_option:compilation_mode\",\n        ],\n    )\n\ndef _transition_alias_rule(compilation_mode):\n    return rule(\n        implementation = _transition_alias_impl,\n        provides = COMMON_PROVIDERS,\n        attrs = {\n            \"actual\": attr.label(\n                mandatory = True,\n                doc = \"`rust_library()` target to transition to `compilation_mode=opt`.\",\n                providers = COMMON_PROVIDERS,\n                cfg = _change_compilation_mode(compilation_mode),\n            ),\n            \"_allowlist_function_transition\": attr.label(\n                default = \"@bazel_tools//tools/allowlists/function_transition_allowlist\",\n            ),\n        },\n        doc = \"Transitions a Rust library crate to the `compilation_mode=opt`.\",\n    )\n\ntransition_alias_dbg = _transition_alias_rule(\"dbg\")\ntransition_alias_fastbuild = _transition_alias_rule(\"fastbuild\")\ntransition_alias_opt = _transition_alias_rule(\"opt\")\n",
+                "defs.bzl": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\"\"\"\n# `crates_repository` API\n\n- [aliases](#aliases)\n- [crate_deps](#crate_deps)\n- [all_crate_deps](#all_crate_deps)\n- [crate_repositories](#crate_repositories)\n\n\"\"\"\n\nload(\"@bazel_tools//tools/build_defs/repo:git.bzl\", \"new_git_repository\")\nload(\"@bazel_tools//tools/build_defs/repo:http.bzl\", \"http_archive\")\nload(\"@bazel_tools//tools/build_defs/repo:utils.bzl\", \"maybe\")\nload(\"@bazel_skylib//lib:selects.bzl\", \"selects\")\nload(\"@rules_rust//crate_universe/private:local_crate_mirror.bzl\", \"local_crate_mirror\")\n\n###############################################################################\n# MACROS API\n###############################################################################\n\n# An identifier that represent common dependencies (unconditional).\n_COMMON_CONDITION = \"\"\n\ndef _flatten_dependency_maps(all_dependency_maps):\n    \"\"\"Flatten a list of dependency maps into one dictionary.\n\n    Dependency maps have the following structure:\n\n    ```python\n    DEPENDENCIES_MAP = {\n        # The first key in the map is a Bazel package\n        # name of the workspace this file is defined in.\n        \"workspace_member_package\": {\n\n            # Not all dependencies are supported for all platforms.\n            # the condition key is the condition required to be true\n            # on the host platform.\n            \"condition\": {\n\n                # An alias to a crate target.     # The label of the crate target the\n                # Aliases are only crate names.   # package name refers to.\n                \"package_name\":                   \"@full//:label\",\n            }\n        }\n    }\n    ```\n\n    Args:\n        all_dependency_maps (list): A list of dicts as described above\n\n    Returns:\n        dict: A dictionary as described above\n    \"\"\"\n    dependencies = {}\n\n    for workspace_deps_map in all_dependency_maps:\n        for pkg_name, conditional_deps_map in workspace_deps_map.items():\n            if pkg_name not in dependencies:\n                non_frozen_map = dict()\n                for key, values in conditional_deps_map.items():\n                    non_frozen_map.update({key: dict(values.items())})\n                dependencies.setdefault(pkg_name, non_frozen_map)\n                continue\n\n            for condition, deps_map in conditional_deps_map.items():\n                # If the condition has not been recorded, do so and continue\n                if condition not in dependencies[pkg_name]:\n                    dependencies[pkg_name].setdefault(condition, dict(deps_map.items()))\n                    continue\n\n                # Alert on any miss-matched dependencies\n                inconsistent_entries = []\n                for crate_name, crate_label in deps_map.items():\n                    existing = dependencies[pkg_name][condition].get(crate_name)\n                    if existing and existing != crate_label:\n                        inconsistent_entries.append((crate_name, existing, crate_label))\n                    dependencies[pkg_name][condition].update({crate_name: crate_label})\n\n    return dependencies\n\ndef crate_deps(deps, package_name = None):\n    \"\"\"Finds the fully qualified label of the requested crates for the package where this macro is called.\n\n    Args:\n        deps (list): The desired list of crate targets.\n        package_name (str, optional): The package name of the set of dependencies to look up.\n            Defaults to `native.package_name()`.\n\n    Returns:\n        list: A list of labels to generated rust targets (str)\n    \"\"\"\n\n    if not deps:\n        return []\n\n    if package_name == None:\n        package_name = native.package_name()\n\n    # Join both sets of dependencies\n    dependencies = _flatten_dependency_maps([\n        _NORMAL_DEPENDENCIES,\n        _NORMAL_DEV_DEPENDENCIES,\n        _PROC_MACRO_DEPENDENCIES,\n        _PROC_MACRO_DEV_DEPENDENCIES,\n        _BUILD_DEPENDENCIES,\n        _BUILD_PROC_MACRO_DEPENDENCIES,\n    ]).pop(package_name, {})\n\n    # Combine all conditional packages so we can easily index over a flat list\n    # TODO: Perhaps this should actually return select statements and maintain\n    # the conditionals of the dependencies\n    flat_deps = {}\n    for deps_set in dependencies.values():\n        for crate_name, crate_label in deps_set.items():\n            flat_deps.update({crate_name: crate_label})\n\n    missing_crates = []\n    crate_targets = []\n    for crate_target in deps:\n        if crate_target not in flat_deps:\n            missing_crates.append(crate_target)\n        else:\n            crate_targets.append(flat_deps[crate_target])\n\n    if missing_crates:\n        fail(\"Could not find crates `{}` among dependencies of `{}`. Available dependencies were `{}`\".format(\n            missing_crates,\n            package_name,\n            dependencies,\n        ))\n\n    return crate_targets\n\ndef all_crate_deps(\n        normal = False, \n        normal_dev = False, \n        proc_macro = False, \n        proc_macro_dev = False,\n        build = False,\n        build_proc_macro = False,\n        package_name = None):\n    \"\"\"Finds the fully qualified label of all requested direct crate dependencies \\\n    for the package where this macro is called.\n\n    If no parameters are set, all normal dependencies are returned. Setting any one flag will\n    otherwise impact the contents of the returned list.\n\n    Args:\n        normal (bool, optional): If True, normal dependencies are included in the\n            output list.\n        normal_dev (bool, optional): If True, normal dev dependencies will be\n            included in the output list.\n        proc_macro (bool, optional): If True, proc_macro dependencies are included\n            in the output list.\n        proc_macro_dev (bool, optional): If True, dev proc_macro dependencies are\n            included in the output list.\n        build (bool, optional): If True, build dependencies are included\n            in the output list.\n        build_proc_macro (bool, optional): If True, build proc_macro dependencies are\n            included in the output list.\n        package_name (str, optional): The package name of the set of dependencies to look up.\n            Defaults to `native.package_name()` when unset.\n\n    Returns:\n        list: A list of labels to generated rust targets (str)\n    \"\"\"\n\n    if package_name == None:\n        package_name = native.package_name()\n\n    # Determine the relevant maps to use\n    all_dependency_maps = []\n    if normal:\n        all_dependency_maps.append(_NORMAL_DEPENDENCIES)\n    if normal_dev:\n        all_dependency_maps.append(_NORMAL_DEV_DEPENDENCIES)\n    if proc_macro:\n        all_dependency_maps.append(_PROC_MACRO_DEPENDENCIES)\n    if proc_macro_dev:\n        all_dependency_maps.append(_PROC_MACRO_DEV_DEPENDENCIES)\n    if build:\n        all_dependency_maps.append(_BUILD_DEPENDENCIES)\n    if build_proc_macro:\n        all_dependency_maps.append(_BUILD_PROC_MACRO_DEPENDENCIES)\n\n    # Default to always using normal dependencies\n    if not all_dependency_maps:\n        all_dependency_maps.append(_NORMAL_DEPENDENCIES)\n\n    dependencies = _flatten_dependency_maps(all_dependency_maps).pop(package_name, None)\n\n    if not dependencies:\n        if dependencies == None:\n            fail(\"Tried to get all_crate_deps for package \" + package_name + \" but that package had no Cargo.toml file\")\n        else:\n            return []\n\n    crate_deps = list(dependencies.pop(_COMMON_CONDITION, {}).values())\n    for condition, deps in dependencies.items():\n        crate_deps += selects.with_or({\n            tuple(_CONDITIONS[condition]): deps.values(),\n            \"//conditions:default\": [],\n        })\n\n    return crate_deps\n\ndef aliases(\n        normal = False,\n        normal_dev = False,\n        proc_macro = False,\n        proc_macro_dev = False,\n        build = False,\n        build_proc_macro = False,\n        package_name = None):\n    \"\"\"Produces a map of Crate alias names to their original label\n\n    If no dependency kinds are specified, `normal` and `proc_macro` are used by default.\n    Setting any one flag will otherwise determine the contents of the returned dict.\n\n    Args:\n        normal (bool, optional): If True, normal dependencies are included in the\n            output list.\n        normal_dev (bool, optional): If True, normal dev dependencies will be\n            included in the output list..\n        proc_macro (bool, optional): If True, proc_macro dependencies are included\n            in the output list.\n        proc_macro_dev (bool, optional): If True, dev proc_macro dependencies are\n            included in the output list.\n        build (bool, optional): If True, build dependencies are included\n            in the output list.\n        build_proc_macro (bool, optional): If True, build proc_macro dependencies are\n            included in the output list.\n        package_name (str, optional): The package name of the set of dependencies to look up.\n            Defaults to `native.package_name()` when unset.\n\n    Returns:\n        dict: The aliases of all associated packages\n    \"\"\"\n    if package_name == None:\n        package_name = native.package_name()\n\n    # Determine the relevant maps to use\n    all_aliases_maps = []\n    if normal:\n        all_aliases_maps.append(_NORMAL_ALIASES)\n    if normal_dev:\n        all_aliases_maps.append(_NORMAL_DEV_ALIASES)\n    if proc_macro:\n        all_aliases_maps.append(_PROC_MACRO_ALIASES)\n    if proc_macro_dev:\n        all_aliases_maps.append(_PROC_MACRO_DEV_ALIASES)\n    if build:\n        all_aliases_maps.append(_BUILD_ALIASES)\n    if build_proc_macro:\n        all_aliases_maps.append(_BUILD_PROC_MACRO_ALIASES)\n\n    # Default to always using normal aliases\n    if not all_aliases_maps:\n        all_aliases_maps.append(_NORMAL_ALIASES)\n        all_aliases_maps.append(_PROC_MACRO_ALIASES)\n\n    aliases = _flatten_dependency_maps(all_aliases_maps).pop(package_name, None)\n\n    if not aliases:\n        return dict()\n\n    common_items = aliases.pop(_COMMON_CONDITION, {}).items()\n\n    # If there are only common items in the dictionary, immediately return them\n    if not len(aliases.keys()) == 1:\n        return dict(common_items)\n\n    # Build a single select statement where each conditional has accounted for the\n    # common set of aliases.\n    crate_aliases = {\"//conditions:default\": dict(common_items)}\n    for condition, deps in aliases.items():\n        condition_triples = _CONDITIONS[condition]\n        for triple in condition_triples:\n            if triple in crate_aliases:\n                crate_aliases[triple].update(deps)\n            else:\n                crate_aliases.update({triple: dict(deps.items() + common_items)})\n\n    return select(crate_aliases)\n\n###############################################################################\n# WORKSPACE MEMBER DEPS AND ALIASES\n###############################################################################\n\n_NORMAL_DEPENDENCIES = {\n    \"\": {\n        _COMMON_CONDITION: {\n            \"googletest\": Label(\"@crates//:googletest-0.14.3\"),\n            \"linkme\": Label(\"@crates//:linkme-0.3.37\"),\n            \"quote\": Label(\"@crates//:quote-1.0.47\"),\n            \"syn\": Label(\"@crates//:syn-2.0.119\"),\n        },\n    },\n}\n\n\n_NORMAL_ALIASES = {\n    \"\": {\n        _COMMON_CONDITION: {\n        },\n    },\n}\n\n\n_NORMAL_DEV_DEPENDENCIES = {\n    \"\": {\n    },\n}\n\n\n_NORMAL_DEV_ALIASES = {\n    \"\": {\n    },\n}\n\n\n_PROC_MACRO_DEPENDENCIES = {\n    \"\": {\n        _COMMON_CONDITION: {\n            \"paste\": Label(\"@crates//:paste-1.0.15\"),\n        },\n    },\n}\n\n\n_PROC_MACRO_ALIASES = {\n    \"\": {\n    },\n}\n\n\n_PROC_MACRO_DEV_DEPENDENCIES = {\n    \"\": {\n    },\n}\n\n\n_PROC_MACRO_DEV_ALIASES = {\n    \"\": {\n    },\n}\n\n\n_BUILD_DEPENDENCIES = {\n    \"\": {\n    },\n}\n\n\n_BUILD_ALIASES = {\n    \"\": {\n    },\n}\n\n\n_BUILD_PROC_MACRO_DEPENDENCIES = {\n    \"\": {\n    },\n}\n\n\n_BUILD_PROC_MACRO_ALIASES = {\n    \"\": {\n    },\n}\n\n\n_CONDITIONS = {\n    \"aarch64-apple-darwin\": [\"@rules_rust//rust/platform:aarch64-apple-darwin\"],\n    \"aarch64-unknown-linux-gnu\": [\"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\"],\n    \"wasm32-unknown-unknown\": [\"@rules_rust//rust/platform:wasm32-unknown-unknown\"],\n    \"wasm32-wasip1\": [\"@rules_rust//rust/platform:wasm32-wasip1\"],\n    \"x86_64-pc-windows-msvc\": [\"@rules_rust//rust/platform:x86_64-pc-windows-msvc\"],\n    \"x86_64-unknown-linux-gnu\": [\"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\"],\n    \"x86_64-unknown-nixos-gnu\": [\"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\"],\n}\n\n###############################################################################\n\ndef crate_repositories():\n    \"\"\"A macro for defining repositories for all generated crates.\n\n    Returns:\n      A list of repos visible to the module through the module extension.\n    \"\"\"\n    maybe(\n        http_archive,\n        name = \"crates__aho-corasick-1.1.5\",\n        sha256 = \"c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/aho-corasick/1.1.5/download\"],\n        strip_prefix = \"aho-corasick-1.1.5\",\n        build_file = Label(\"@crates//crates:BUILD.aho-corasick-1.1.5.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__autocfg-1.5.1\",\n        sha256 = \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/autocfg/1.5.1/download\"],\n        strip_prefix = \"autocfg-1.5.1\",\n        build_file = Label(\"@crates//crates:BUILD.autocfg-1.5.1.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__googletest-0.14.3\",\n        sha256 = \"f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/googletest/0.14.3/download\"],\n        strip_prefix = \"googletest-0.14.3\",\n        build_file = Label(\"@crates//crates:BUILD.googletest-0.14.3.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__googletest_macro-0.14.3\",\n        sha256 = \"2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/googletest_macro/0.14.3/download\"],\n        strip_prefix = \"googletest_macro-0.14.3\",\n        build_file = Label(\"@crates//crates:BUILD.googletest_macro-0.14.3.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__linkme-0.3.37\",\n        sha256 = \"3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/linkme/0.3.37/download\"],\n        strip_prefix = \"linkme-0.3.37\",\n        build_file = Label(\"@crates//crates:BUILD.linkme-0.3.37.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__linkme-impl-0.3.37\",\n        sha256 = \"77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/linkme-impl/0.3.37/download\"],\n        strip_prefix = \"linkme-impl-0.3.37\",\n        build_file = Label(\"@crates//crates:BUILD.linkme-impl-0.3.37.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__memchr-2.8.3\",\n        sha256 = \"cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/memchr/2.8.3/download\"],\n        strip_prefix = \"memchr-2.8.3\",\n        build_file = Label(\"@crates//crates:BUILD.memchr-2.8.3.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__num-traits-0.2.19\",\n        sha256 = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/num-traits/0.2.19/download\"],\n        strip_prefix = \"num-traits-0.2.19\",\n        build_file = Label(\"@crates//crates:BUILD.num-traits-0.2.19.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__paste-1.0.15\",\n        sha256 = \"57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/paste/1.0.15/download\"],\n        strip_prefix = \"paste-1.0.15\",\n        build_file = Label(\"@crates//crates:BUILD.paste-1.0.15.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__proc-macro2-1.0.107\",\n        sha256 = \"985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/proc-macro2/1.0.107/download\"],\n        strip_prefix = \"proc-macro2-1.0.107\",\n        build_file = Label(\"@crates//crates:BUILD.proc-macro2-1.0.107.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__quote-1.0.47\",\n        sha256 = \"1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/quote/1.0.47/download\"],\n        strip_prefix = \"quote-1.0.47\",\n        build_file = Label(\"@crates//crates:BUILD.quote-1.0.47.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__regex-1.13.1\",\n        sha256 = \"f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/regex/1.13.1/download\"],\n        strip_prefix = \"regex-1.13.1\",\n        build_file = Label(\"@crates//crates:BUILD.regex-1.13.1.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__regex-automata-0.4.18\",\n        sha256 = \"ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/regex-automata/0.4.18/download\"],\n        strip_prefix = \"regex-automata-0.4.18\",\n        build_file = Label(\"@crates//crates:BUILD.regex-automata-0.4.18.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__regex-syntax-0.8.11\",\n        sha256 = \"d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/regex-syntax/0.8.11/download\"],\n        strip_prefix = \"regex-syntax-0.8.11\",\n        build_file = Label(\"@crates//crates:BUILD.regex-syntax-0.8.11.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__rustversion-1.0.23\",\n        sha256 = \"cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/rustversion/1.0.23/download\"],\n        strip_prefix = \"rustversion-1.0.23\",\n        build_file = Label(\"@crates//crates:BUILD.rustversion-1.0.23.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__syn-2.0.119\",\n        sha256 = \"872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/syn/2.0.119/download\"],\n        strip_prefix = \"syn-2.0.119\",\n        build_file = Label(\"@crates//crates:BUILD.syn-2.0.119.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__syn-3.0.5\",\n        sha256 = \"12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/syn/3.0.5/download\"],\n        strip_prefix = \"syn-3.0.5\",\n        build_file = Label(\"@crates//crates:BUILD.syn-3.0.5.bazel\"),\n    )\n\n    maybe(\n        http_archive,\n        name = \"crates__unicode-ident-1.0.24\",\n        sha256 = \"e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75\",\n        type = \"tar.gz\",\n        urls = [\"https://static.crates.io/crates/unicode-ident/1.0.24/download\"],\n        strip_prefix = \"unicode-ident-1.0.24\",\n        build_file = Label(\"@crates//crates:BUILD.unicode-ident-1.0.24.bazel\"),\n    )\n\n    return [\n       struct(repo=\"crates__googletest-0.14.3\", is_dev_dep = False),\n       struct(repo=\"crates__linkme-0.3.37\", is_dev_dep = False),\n       struct(repo=\"crates__paste-1.0.15\", is_dev_dep = False),\n       struct(repo=\"crates__quote-1.0.47\", is_dev_dep = False),\n       struct(repo=\"crates__syn-2.0.119\", is_dev_dep = False),\n    ]\n"
+              }
             }
           },
-          "com_github_grpc_grpc_swift": {
+          "crates__aho-corasick-1.1.5": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/grpc/grpc-swift/archive/1.16.0.tar.gz"
+                "https://static.crates.io/crates/aho-corasick/1.1.5/download"
               ],
-              "sha256": "58b60431d0064969f9679411264b82e40a217ae6bd34e17096d92cc4e47556a5",
-              "strip_prefix": "grpc-swift-1.16.0/",
-              "build_file": "@@rules_swift+//third_party:com_github_grpc_grpc_swift/BUILD.overlay"
+              "strip_prefix": "aho-corasick-1.1.5",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"aho_corasick\",\n    deps = [\n        \"@crates__memchr-2.8.3//:memchr\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"perf-literal\",\n        \"std\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=aho-corasick\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.1.5\",\n)\n"
             }
           },
-          "com_github_apple_swift_docc_symbolkit": {
+          "crates__autocfg-1.5.1": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-docc-symbolkit/archive/refs/tags/swift-5.10-RELEASE.tar.gz"
+                "https://static.crates.io/crates/autocfg/1.5.1/download"
               ],
-              "sha256": "de1d4b6940468ddb53b89df7aa1a81323b9712775b0e33e8254fa0f6f7469a97",
-              "strip_prefix": "swift-docc-symbolkit-swift-5.10-RELEASE",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_docc_symbolkit/BUILD.overlay"
+              "strip_prefix": "autocfg-1.5.1",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"autocfg\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2015\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=autocfg\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.5.1\",\n)\n"
             }
           },
-          "com_github_apple_swift_nio": {
+          "crates__googletest-0.14.3": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-nio/archive/2.42.0.tar.gz"
+                "https://static.crates.io/crates/googletest/0.14.3/download"
               ],
-              "sha256": "e3304bc3fb53aea74a3e54bd005ede11f6dc357117d9b1db642d03aea87194a0",
-              "strip_prefix": "swift-nio-2.42.0/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio/BUILD.overlay"
+              "strip_prefix": "googletest-0.14.3",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"googletest\",\n    deps = [\n        \"@crates__num-traits-0.2.19//:num_traits\",\n        \"@crates__regex-1.13.1//:regex\",\n    ],\n    proc_macro_deps = [\n        \"@crates__googletest_macro-0.14.3//:googletest_macro\",\n        \"@crates__rustversion-1.0.23//:rustversion\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=googletest\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.14.3\",\n)\n"
             }
           },
-          "com_github_apple_swift_nio_http2": {
+          "crates__googletest_macro-0.14.3": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-nio-http2/archive/1.26.0.tar.gz"
+                "https://static.crates.io/crates/googletest_macro/0.14.3/download"
               ],
-              "sha256": "f0edfc9d6a7be1d587e5b403f2d04264bdfae59aac1d74f7d974a9022c6d2b25",
-              "strip_prefix": "swift-nio-http2-1.26.0/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_http2/BUILD.overlay"
+              "strip_prefix": "googletest_macro-0.14.3",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_proc_macro\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_proc_macro(\n    name = \"googletest_macro\",\n    deps = [\n        \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n        \"@crates__quote-1.0.47//:quote\",\n        \"@crates__syn-2.0.119//:syn\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=googletest_macro\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.14.3\",\n)\n"
             }
           },
-          "com_github_apple_swift_nio_transport_services": {
+          "crates__linkme-0.3.37": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-nio-transport-services/archive/1.15.0.tar.gz"
+                "https://static.crates.io/crates/linkme/0.3.37/download"
               ],
-              "sha256": "f3498dafa633751a52b9b7f741f7ac30c42bcbeb3b9edca6d447e0da8e693262",
-              "strip_prefix": "swift-nio-transport-services-1.15.0/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_transport_services/BUILD.overlay"
+              "strip_prefix": "linkme-0.3.37",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n    \"@rules_rust//cargo:defs.bzl\",\n    \"cargo_build_script\",\n    \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"linkme\",\n    deps = [\n        \"@crates__linkme-0.3.37//:build_script_build\",\n    ],\n    proc_macro_deps = [\n        \"@crates__linkme-impl-0.3.37//:linkme_impl\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=linkme\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.3.37\",\n)\n\ncargo_build_script(\n    name = \"_bs\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \"**/*.rs\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_name = \"build_script_build\",\n    crate_root = \"build.rs\",\n    data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    edition = \"2021\",\n    pkg_name = \"linkme\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=linkme\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    version = \"0.3.37\",\n    visibility = [\"//visibility:private\"],\n)\n\nalias(\n    name = \"build_script_build\",\n    actual = \":_bs\",\n    tags = [\"manual\"],\n)\n"
             }
           },
-          "com_github_apple_swift_nio_extras": {
+          "crates__linkme-impl-0.3.37": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-nio-extras/archive/1.4.0.tar.gz"
+                "https://static.crates.io/crates/linkme-impl/0.3.37/download"
               ],
-              "sha256": "4684b52951d9d9937bb3e8ccd6b5daedd777021ef2519ea2f18c4c922843b52b",
-              "strip_prefix": "swift-nio-extras-1.4.0/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_extras/BUILD.overlay"
+              "strip_prefix": "linkme-impl-0.3.37",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n    \"@rules_rust//cargo:defs.bzl\",\n    \"cargo_build_script\",\n    \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_proc_macro\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_proc_macro(\n    name = \"linkme_impl\",\n    deps = [\n        \"@crates__linkme-impl-0.3.37//:build_script_build\",\n        \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n        \"@crates__quote-1.0.47//:quote\",\n        \"@crates__syn-3.0.5//:syn\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=linkme-impl\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.3.37\",\n)\n\ncargo_build_script(\n    name = \"_bs\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \"**/*.rs\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_name = \"build_script_build\",\n    crate_root = \"build.rs\",\n    data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    edition = \"2021\",\n    pkg_name = \"linkme-impl\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=linkme-impl\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    version = \"0.3.37\",\n    visibility = [\"//visibility:private\"],\n)\n\nalias(\n    name = \"build_script_build\",\n    actual = \":_bs\",\n    tags = [\"manual\"],\n)\n"
             }
           },
-          "com_github_apple_swift_log": {
+          "crates__memchr-2.8.3": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-log/archive/1.4.4.tar.gz"
+                "https://static.crates.io/crates/memchr/2.8.3/download"
               ],
-              "sha256": "48fe66426c784c0c20031f15dc17faf9f4c9037c192bfac2f643f65cb2321ba0",
-              "strip_prefix": "swift-log-1.4.4/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_log/BUILD.overlay"
+              "strip_prefix": "memchr-2.8.3",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"memchr\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"alloc\",\n        \"std\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=memchr\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"2.8.3\",\n)\n"
             }
           },
-          "com_github_apple_swift_nio_ssl": {
+          "crates__num-traits-0.2.19": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-nio-ssl/archive/2.23.0.tar.gz"
+                "https://static.crates.io/crates/num-traits/0.2.19/download"
               ],
-              "sha256": "4787c63f61dd04d99e498adc3d1a628193387e41efddf8de19b8db04544d016d",
-              "strip_prefix": "swift-nio-ssl-2.23.0/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_ssl/BUILD.overlay"
+              "strip_prefix": "num-traits-0.2.19",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n    \"@rules_rust//cargo:defs.bzl\",\n    \"cargo_build_script\",\n    \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"num_traits\",\n    deps = [\n        \"@crates__num-traits-0.2.19//:build_script_build\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"default\",\n        \"std\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=num-traits\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.2.19\",\n)\n\ncargo_build_script(\n    name = \"_bs\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \"**/*.rs\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"default\",\n        \"std\",\n    ],\n    crate_name = \"build_script_build\",\n    crate_root = \"build.rs\",\n    data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    deps = [\n        \"@crates__autocfg-1.5.1//:autocfg\",\n    ],\n    edition = \"2021\",\n    pkg_name = \"num-traits\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=num-traits\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    version = \"0.2.19\",\n    visibility = [\"//visibility:private\"],\n)\n\nalias(\n    name = \"build_script_build\",\n    actual = \":_bs\",\n    tags = [\"manual\"],\n)\n"
             }
           },
-          "com_github_apple_swift_collections": {
+          "crates__paste-1.0.15": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-collections/archive/1.0.4.tar.gz"
+                "https://static.crates.io/crates/paste/1.0.15/download"
               ],
-              "sha256": "d9e4c8a91c60fb9c92a04caccbb10ded42f4cb47b26a212bc6b39cc390a4b096",
-              "strip_prefix": "swift-collections-1.0.4/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_collections/BUILD.overlay"
+              "strip_prefix": "paste-1.0.15",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n    \"@rules_rust//cargo:defs.bzl\",\n    \"cargo_build_script\",\n    \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_proc_macro\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_proc_macro(\n    name = \"paste\",\n    deps = [\n        \"@crates__paste-1.0.15//:build_script_build\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2018\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=paste\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.0.15\",\n)\n\ncargo_build_script(\n    name = \"_bs\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \"**/*.rs\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_name = \"build_script_build\",\n    crate_root = \"build.rs\",\n    data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    edition = \"2018\",\n    pkg_name = \"paste\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=paste\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    version = \"1.0.15\",\n    visibility = [\"//visibility:private\"],\n)\n\nalias(\n    name = \"build_script_build\",\n    actual = \":_bs\",\n    tags = [\"manual\"],\n)\n"
             }
           },
-          "com_github_apple_swift_atomics": {
+          "crates__proc-macro2-1.0.107": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/apple/swift-atomics/archive/1.1.0.tar.gz"
+                "https://static.crates.io/crates/proc-macro2/1.0.107/download"
               ],
-              "sha256": "1bee7f469f7e8dc49f11cfa4da07182fbc79eab000ec2c17bfdce468c5d276fb",
-              "strip_prefix": "swift-atomics-1.1.0/",
-              "build_file": "@@rules_swift+//third_party:com_github_apple_swift_atomics/BUILD.overlay"
+              "strip_prefix": "proc-macro2-1.0.107",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n    \"@rules_rust//cargo:defs.bzl\",\n    \"cargo_build_script\",\n    \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"proc_macro2\",\n    deps = [\n        \"@crates__proc-macro2-1.0.107//:build_script_build\",\n        \"@crates__unicode-ident-1.0.24//:unicode_ident\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"proc-macro\",\n    ] + select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [\n            \"default\",  # aarch64-apple-darwin\n        ],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [\n            \"default\",  # aarch64-unknown-linux-gnu\n        ],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [\n            \"default\",  # x86_64-pc-windows-msvc\n        ],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [\n            \"default\",  # x86_64-unknown-linux-gnu\n        ],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [\n            \"default\",  # x86_64-unknown-nixos-gnu\n        ],\n        \"//conditions:default\": [],\n    }),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=proc-macro2\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.0.107\",\n)\n\ncargo_build_script(\n    name = \"_bs\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \"**/*.rs\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"proc-macro\",\n    ] + select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [\n            \"default\",  # aarch64-apple-darwin\n        ],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [\n            \"default\",  # aarch64-unknown-linux-gnu\n        ],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [\n            \"default\",  # x86_64-pc-windows-msvc\n        ],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [\n            \"default\",  # x86_64-unknown-linux-gnu\n        ],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [\n            \"default\",  # x86_64-unknown-nixos-gnu\n        ],\n        \"//conditions:default\": [],\n    }),\n    crate_name = \"build_script_build\",\n    crate_root = \"build.rs\",\n    data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    edition = \"2021\",\n    pkg_name = \"proc-macro2\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=proc-macro2\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    version = \"1.0.107\",\n    visibility = [\"//visibility:private\"],\n)\n\nalias(\n    name = \"build_script_build\",\n    actual = \":_bs\",\n    tags = [\"manual\"],\n)\n"
             }
           },
-          "build_bazel_rules_swift_index_import": {
+          "crates__quote-1.0.47": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
             "attributes": {
-              "build_file": "@@rules_swift+//third_party:build_bazel_rules_swift_index_import/BUILD.overlay",
-              "canonical_id": "index-import-5.8",
+              "remote_patch_strip": 1,
+              "sha256": "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001",
+              "type": "tar.gz",
               "urls": [
-                "https://github.com/MobileNativeFoundation/index-import/releases/download/5.8.0.1/index-import.tar.gz"
+                "https://static.crates.io/crates/quote/1.0.47/download"
               ],
-              "sha256": "28c1ffa39d99e74ed70623899b207b41f79214c498c603915aef55972a851a15"
+              "strip_prefix": "quote-1.0.47",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n    \"@rules_rust//cargo:defs.bzl\",\n    \"cargo_build_script\",\n    \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"quote\",\n    deps = [\n        \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n        \"@crates__quote-1.0.47//:build_script_build\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"default\",\n        \"proc-macro\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=quote\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.0.47\",\n)\n\ncargo_build_script(\n    name = \"_bs\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \"**/*.rs\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"default\",\n        \"proc-macro\",\n    ],\n    crate_name = \"build_script_build\",\n    crate_root = \"build.rs\",\n    data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    edition = \"2021\",\n    pkg_name = \"quote\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=quote\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    version = \"1.0.47\",\n    visibility = [\"//visibility:private\"],\n)\n\nalias(\n    name = \"build_script_build\",\n    actual = \":_bs\",\n    tags = [\"manual\"],\n)\n"
             }
           },
-          "build_bazel_rules_swift_local_config": {
-            "repoRuleId": "@@rules_swift+//swift/internal:swift_autoconfiguration.bzl%swift_autoconfiguration",
-            "attributes": {}
+          "crates__regex-1.13.1": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d",
+              "type": "tar.gz",
+              "urls": [
+                "https://static.crates.io/crates/regex/1.13.1/download"
+              ],
+              "strip_prefix": "regex-1.13.1",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"regex\",\n    deps = [\n        \"@crates__aho-corasick-1.1.5//:aho_corasick\",\n        \"@crates__memchr-2.8.3//:memchr\",\n        \"@crates__regex-automata-0.4.18//:regex_automata\",\n        \"@crates__regex-syntax-0.8.11//:regex_syntax\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"default\",\n        \"perf\",\n        \"perf-backtrack\",\n        \"perf-cache\",\n        \"perf-dfa\",\n        \"perf-inline\",\n        \"perf-literal\",\n        \"perf-onepass\",\n        \"std\",\n        \"unicode\",\n        \"unicode-age\",\n        \"unicode-bool\",\n        \"unicode-case\",\n        \"unicode-gencat\",\n        \"unicode-perl\",\n        \"unicode-script\",\n        \"unicode-segment\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=regex\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.13.1\",\n)\n"
+            }
+          },
+          "crates__regex-automata-0.4.18": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2",
+              "type": "tar.gz",
+              "urls": [
+                "https://static.crates.io/crates/regex-automata/0.4.18/download"
+              ],
+              "strip_prefix": "regex-automata-0.4.18",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"regex_automata\",\n    deps = [\n        \"@crates__aho-corasick-1.1.5//:aho_corasick\",\n        \"@crates__memchr-2.8.3//:memchr\",\n        \"@crates__regex-syntax-0.8.11//:regex_syntax\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"alloc\",\n        \"dfa-onepass\",\n        \"hybrid\",\n        \"meta\",\n        \"nfa-backtrack\",\n        \"nfa-pikevm\",\n        \"nfa-thompson\",\n        \"perf-inline\",\n        \"perf-literal\",\n        \"perf-literal-multisubstring\",\n        \"perf-literal-substring\",\n        \"std\",\n        \"syntax\",\n        \"unicode\",\n        \"unicode-age\",\n        \"unicode-bool\",\n        \"unicode-case\",\n        \"unicode-gencat\",\n        \"unicode-perl\",\n        \"unicode-script\",\n        \"unicode-segment\",\n        \"unicode-word-boundary\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=regex-automata\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.4.18\",\n)\n"
+            }
+          },
+          "crates__regex-syntax-0.8.11": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4",
+              "type": "tar.gz",
+              "urls": [
+                "https://static.crates.io/crates/regex-syntax/0.8.11/download"
+              ],
+              "strip_prefix": "regex-syntax-0.8.11",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"regex_syntax\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"default\",\n        \"std\",\n        \"unicode\",\n        \"unicode-age\",\n        \"unicode-bool\",\n        \"unicode-case\",\n        \"unicode-gencat\",\n        \"unicode-perl\",\n        \"unicode-script\",\n        \"unicode-segment\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=regex-syntax\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"0.8.11\",\n)\n"
+            }
+          },
+          "crates__rustversion-1.0.23": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f",
+              "type": "tar.gz",
+              "urls": [
+                "https://static.crates.io/crates/rustversion/1.0.23/download"
+              ],
+              "strip_prefix": "rustversion-1.0.23",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\n    \"@rules_rust//cargo:defs.bzl\",\n    \"cargo_build_script\",\n    \"cargo_toml_env_vars\",\n)\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_proc_macro\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_proc_macro(\n    name = \"rustversion\",\n    deps = [\n        \"@crates__rustversion-1.0.23//:build_script_build\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2018\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=rustversion\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.0.23\",\n)\n\ncargo_build_script(\n    name = \"_bs\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \"**/*.rs\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_name = \"build_script_build\",\n    crate_root = \"build/build.rs\",\n    data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    edition = \"2018\",\n    pkg_name = \"rustversion\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=rustversion\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    version = \"1.0.23\",\n    visibility = [\"//visibility:private\"],\n)\n\nalias(\n    name = \"build_script_build\",\n    actual = \":_bs\",\n    tags = [\"manual\"],\n)\n"
+            }
+          },
+          "crates__syn-2.0.119": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297",
+              "type": "tar.gz",
+              "urls": [
+                "https://static.crates.io/crates/syn/2.0.119/download"
+              ],
+              "strip_prefix": "syn-2.0.119",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"syn\",\n    deps = [\n        \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n        \"@crates__quote-1.0.47//:quote\",\n        \"@crates__unicode-ident-1.0.24//:unicode_ident\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"clone-impls\",\n        \"default\",\n        \"derive\",\n        \"parsing\",\n        \"printing\",\n        \"proc-macro\",\n    ] + select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [\n            \"extra-traits\",  # aarch64-apple-darwin\n            \"full\",  # aarch64-apple-darwin\n        ],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [\n            \"extra-traits\",  # aarch64-unknown-linux-gnu\n            \"full\",  # aarch64-unknown-linux-gnu\n        ],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [\n            \"extra-traits\",  # x86_64-pc-windows-msvc\n            \"full\",  # x86_64-pc-windows-msvc\n        ],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [\n            \"extra-traits\",  # x86_64-unknown-linux-gnu\n            \"full\",  # x86_64-unknown-linux-gnu\n        ],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [\n            \"extra-traits\",  # x86_64-unknown-nixos-gnu\n            \"full\",  # x86_64-unknown-nixos-gnu\n        ],\n        \"//conditions:default\": [],\n    }),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=syn\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"2.0.119\",\n)\n"
+            }
+          },
+          "crates__syn-3.0.5": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9",
+              "type": "tar.gz",
+              "urls": [
+                "https://static.crates.io/crates/syn/3.0.5/download"
+              ],
+              "strip_prefix": "syn-3.0.5",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"syn\",\n    deps = [\n        \"@crates__proc-macro2-1.0.107//:proc_macro2\",\n        \"@crates__quote-1.0.47//:quote\",\n        \"@crates__unicode-ident-1.0.24//:unicode_ident\",\n    ],\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_features = [\n        \"clone-impls\",\n        \"default\",\n        \"derive\",\n        \"parsing\",\n        \"printing\",\n        \"proc-macro\",\n    ],\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=syn\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"3.0.5\",\n)\n"
+            }
+          },
+          "crates__unicode-ident-1.0.24": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "remote_patch_strip": 1,
+              "sha256": "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75",
+              "type": "tar.gz",
+              "urls": [
+                "https://static.crates.io/crates/unicode-ident/1.0.24/download"
+              ],
+              "strip_prefix": "unicode-ident-1.0.24",
+              "build_file_content": "###############################################################################\n# @generated\n# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To \n# regenerate this file, run the following:\n#\n#     bazel mod show_repo 'protobuf'\n###############################################################################\n\nload(\"@rules_rust//cargo:defs.bzl\", \"cargo_toml_env_vars\")\n\nload(\"@rules_rust//rust:defs.bzl\", \"rust_library\")\n\n# buildifier: disable=bzl-visibility\nload(\"@rules_rust//crate_universe/private:selects.bzl\", \"selects\")\n\npackage(default_visibility = [\"//visibility:public\"])\n\ncargo_toml_env_vars(\n    name = \"cargo_toml_env_vars\",\n    src = \"Cargo.toml\",\n)\n\nrust_library(\n    name = \"unicode_ident\",\n    compile_data = glob(\n        allow_empty = True,\n        include = [\"**\"],\n        exclude = [\n            \"**/* *\",\n            \".tmp_git_root/**/*\",\n            \"BUILD\",\n            \"BUILD.bazel\",\n            \"WORKSPACE\",\n            \"WORKSPACE.bazel\",\n        ],\n    ),\n    crate_root = \"src/lib.rs\",\n    edition = \"2021\",\n    rustc_env_files = [\n        \":cargo_toml_env_vars\",\n    ],\n    rustc_flags = [\n        \"--cap-lints=allow\",\n    ],\n    srcs = glob(\n        allow_empty = True,\n        include = [\"**/*.rs\"],\n    ),\n    tags = [\n        \"cargo-bazel\",\n        \"crate-name=unicode-ident\",\n        \"manual\",\n        \"noclippy\",\n        \"norustfmt\",\n    ],\n    target_compatible_with = select({\n        \"@rules_rust//rust/platform:aarch64-apple-darwin\": [],\n        \"@rules_rust//rust/platform:aarch64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:wasm32-unknown-unknown\": [],\n        \"@rules_rust//rust/platform:wasm32-wasip1\": [],\n        \"@rules_rust//rust/platform:x86_64-pc-windows-msvc\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-linux-gnu\": [],\n        \"@rules_rust//rust/platform:x86_64-unknown-nixos-gnu\": [],\n        \"//conditions:default\": [\"@platforms//:incompatible\"],\n    }),\n    version = \"1.0.24\",\n)\n"
+            }
+          }
+        }
+      }
+    },
+    "@@rules_rust+//crate_universe/private:internal_extensions.bzl%cu_nr": {
+      "general": {
+        "bzlTransitiveDigest": "hDN4kJRBylKi4ybZCxjOpeQvi+fhjfVKeXXOEj2zvpI=",
+        "usagesDigest": "tG3p3Nb5XxC7vWY/bcKdb//g0HoAxpxxH3F5/jBVlk4=",
+        "recordedInputs": [
+          "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals",
+          "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version",
+          "REPO_MAPPING:rules_cc+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:rules_cc+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_cc+,cc_compatibility_proxy rules_cc++compatibility_proxy+cc_compatibility_proxy",
+          "REPO_MAPPING:rules_cc+,platforms platforms",
+          "REPO_MAPPING:rules_cc+,rules_cc rules_cc+",
+          "REPO_MAPPING:rules_cc++compatibility_proxy+cc_compatibility_proxy,rules_cc rules_cc+",
+          "REPO_MAPPING:rules_rust+,bazel_features bazel_features+",
+          "REPO_MAPPING:rules_rust+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:rules_rust+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_rust+,cui rules_rust++cu+cui",
+          "REPO_MAPPING:rules_rust+,rrc rules_rust++i2+rrc",
+          "REPO_MAPPING:rules_rust+,rules_cc rules_cc+",
+          "REPO_MAPPING:rules_rust+,rules_rust rules_rust+"
+        ],
+        "generatedRepoSpecs": {
+          "cargo_bazel_bootstrap": {
+            "repoRuleId": "@@rules_rust+//cargo/private:cargo_bootstrap.bzl%cargo_bootstrap_repository",
+            "attributes": {
+              "srcs": [
+                "@@rules_rust+//crate_universe:src/api.rs",
+                "@@rules_rust+//crate_universe:src/api/lockfile.rs",
+                "@@rules_rust+//crate_universe:src/cli.rs",
+                "@@rules_rust+//crate_universe:src/cli/generate.rs",
+                "@@rules_rust+//crate_universe:src/cli/query.rs",
+                "@@rules_rust+//crate_universe:src/cli/render.rs",
+                "@@rules_rust+//crate_universe:src/cli/splice.rs",
+                "@@rules_rust+//crate_universe:src/cli/vendor.rs",
+                "@@rules_rust+//crate_universe:src/config.rs",
+                "@@rules_rust+//crate_universe:src/context.rs",
+                "@@rules_rust+//crate_universe:src/context/crate_context.rs",
+                "@@rules_rust+//crate_universe:src/context/platforms.rs",
+                "@@rules_rust+//crate_universe:src/lib.rs",
+                "@@rules_rust+//crate_universe:src/lockfile.rs",
+                "@@rules_rust+//crate_universe:src/main.rs",
+                "@@rules_rust+//crate_universe:src/metadata.rs",
+                "@@rules_rust+//crate_universe:src/metadata/cargo_bin.rs",
+                "@@rules_rust+//crate_universe:src/metadata/cargo_tree_resolver.rs",
+                "@@rules_rust+//crate_universe:src/metadata/cargo_tree_rustc_wrapper.bat",
+                "@@rules_rust+//crate_universe:src/metadata/cargo_tree_rustc_wrapper.sh",
+                "@@rules_rust+//crate_universe:src/metadata/dependency.rs",
+                "@@rules_rust+//crate_universe:src/metadata/metadata_annotation.rs",
+                "@@rules_rust+//crate_universe:src/rendering.rs",
+                "@@rules_rust+//crate_universe:src/rendering/template_engine.rs",
+                "@@rules_rust+//crate_universe:src/rendering/templates/module_bzl.j2",
+                "@@rules_rust+//crate_universe:src/rendering/templates/partials/header.j2",
+                "@@rules_rust+//crate_universe:src/rendering/templates/partials/module/aliases_map.j2",
+                "@@rules_rust+//crate_universe:src/rendering/templates/partials/module/deps_map.j2",
+                "@@rules_rust+//crate_universe:src/rendering/templates/partials/module/repo_git.j2",
+                "@@rules_rust+//crate_universe:src/rendering/templates/partials/module/repo_http.j2",
+                "@@rules_rust+//crate_universe:src/rendering/templates/vendor_module.j2",
+                "@@rules_rust+//crate_universe:src/rendering/verbatim/alias_rules.bzl",
+                "@@rules_rust+//crate_universe:src/select.rs",
+                "@@rules_rust+//crate_universe:src/splicing.rs",
+                "@@rules_rust+//crate_universe:src/splicing/cargo_config.rs",
+                "@@rules_rust+//crate_universe:src/splicing/crate_index_lookup.rs",
+                "@@rules_rust+//crate_universe:src/splicing/splicer.rs",
+                "@@rules_rust+//crate_universe:src/test.rs",
+                "@@rules_rust+//crate_universe:src/utils.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/glob.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/label.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/select.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/select_dict.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/select_list.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/select_scalar.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/select_set.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/serialize.rs",
+                "@@rules_rust+//crate_universe:src/utils/starlark/target_compatible_with.rs",
+                "@@rules_rust+//crate_universe:src/utils/symlink.rs",
+                "@@rules_rust+//crate_universe:src/utils/target_triple.rs"
+              ],
+              "binary": "cargo-bazel",
+              "cargo_lockfile": "@@rules_rust+//crate_universe:Cargo.lock",
+              "cargo_toml": "@@rules_rust+//crate_universe:Cargo.toml",
+              "version": "1.93.1",
+              "timeout": 900,
+              "rust_toolchain_cargo_template": "@rust_host_tools//:bin/{tool}",
+              "rust_toolchain_rustc_template": "@rust_host_tools//:bin/{tool}",
+              "compressed_windows_toolchain_names": false
+            }
           }
         },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_swift+",
-            "bazel_tools",
-            "bazel_tools"
+        "moduleExtensionMetadata": {
+          "explicitRootModuleDirectDeps": [
+            "cargo_bazel_bootstrap"
           ],
-          [
-            "rules_swift+",
-            "build_bazel_rules_swift",
-            "rules_swift+"
-          ]
-        ]
+          "explicitRootModuleDirectDevDeps": [],
+          "useAllRepos": "NO",
+          "reproducible": false
+        }
+      }
+    },
+    "@@yq.bzl+//yq:extensions.bzl%yq": {
+      "general": {
+        "bzlTransitiveDigest": "tDqk+ntWTdxNAWPDjRY1uITgHbti2jcXR5ZdinltBs0=",
+        "usagesDigest": "XGLRpNcVs4WD/zog6U0sXbBo2OlR5O4mc58OU1L3EVc=",
+        "recordedInputs": [],
+        "generatedRepoSpecs": {
+          "yq_darwin_amd64": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "darwin_amd64",
+              "version": "4.45.2"
+            }
+          },
+          "yq_darwin_arm64": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "darwin_arm64",
+              "version": "4.45.2"
+            }
+          },
+          "yq_linux_amd64": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "linux_amd64",
+              "version": "4.45.2"
+            }
+          },
+          "yq_linux_arm64": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "linux_arm64",
+              "version": "4.45.2"
+            }
+          },
+          "yq_linux_s390x": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "linux_s390x",
+              "version": "4.45.2"
+            }
+          },
+          "yq_linux_riscv64": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "linux_riscv64",
+              "version": "4.45.2"
+            }
+          },
+          "yq_linux_ppc64le": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "linux_ppc64le",
+              "version": "4.45.2"
+            }
+          },
+          "yq_windows_amd64": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "windows_amd64",
+              "version": "4.45.2"
+            }
+          },
+          "yq_windows_arm64": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+            "attributes": {
+              "platform": "windows_arm64",
+              "version": "4.45.2"
+            }
+          },
+          "yq_toolchains": {
+            "repoRuleId": "@@yq.bzl+//yq/toolchain:toolchain.bzl%yq_toolchains_repo",
+            "attributes": {
+              "user_repository_name": "yq"
+            }
+          }
+        }
       }
     }
   },
-  "facts": {}
+  "facts": {
+    "@@aspect_tools_telemetry+//:extension.bzl%telemetry": {
+      "notice_version": "1"
+    },
+    "@@rules_go+//go:extensions.bzl%go_sdk": {
+      "1.22.4": {
+        "aix_ppc64": [
+          "go1.22.4.aix-ppc64.tar.gz",
+          "b9647fa9fc83a0cc5d4f092a19eaeaecf45f063a5aa7d4962fde65aeb7ae6ce1"
+        ],
+        "darwin_amd64": [
+          "go1.22.4.darwin-amd64.tar.gz",
+          "c95967f50aa4ace34af0c236cbdb49a9a3e80ee2ad09d85775cb4462a5c19ed3"
+        ],
+        "darwin_arm64": [
+          "go1.22.4.darwin-arm64.tar.gz",
+          "242b78dc4c8f3d5435d28a0d2cec9b4c1aa999b601fb8aa59fb4e5a1364bf827"
+        ],
+        "dragonfly_amd64": [
+          "go1.22.4.dragonfly-amd64.tar.gz",
+          "f2fbb51af4719d3616efb482d6ed2b96579b474156f85a7ddc6f126764feec4b"
+        ],
+        "freebsd_386": [
+          "go1.22.4.freebsd-386.tar.gz",
+          "7c54884bb9f274884651d41e61d1bc12738863ad1497e97ea19ad0e9aa6bf7b5"
+        ],
+        "freebsd_amd64": [
+          "go1.22.4.freebsd-amd64.tar.gz",
+          "88d44500e1701dd35797619774d6dd51bf60f45a8338b0a82ddc018e4e63fb78"
+        ],
+        "freebsd_arm64": [
+          "go1.22.4.freebsd-arm64.tar.gz",
+          "726dc093cf020277be45debf03c3b02b43c2efb3e2a5d4fba8f52579d65327dc"
+        ],
+        "freebsd_armv6l": [
+          "go1.22.4.freebsd-arm.tar.gz",
+          "3d9efe47db142a22679aba46b1772e3900b0d87ae13bd2b3bc80dbf2ac0b2cd6"
+        ],
+        "freebsd_riscv64": [
+          "go1.22.4.freebsd-riscv64.tar.gz",
+          "5f6b67e5e32f1d6ccb2d4dcb44934a5e2e870a877ba7443d86ec43cfc28afa71"
+        ],
+        "illumos_amd64": [
+          "go1.22.4.illumos-amd64.tar.gz",
+          "d56ecc2f85b6418a21ef83879594d0c42ab4f65391a676bb12254870e6690d63"
+        ],
+        "linux_386": [
+          "go1.22.4.linux-386.tar.gz",
+          "47a2a8d249a91eb8605c33bceec63aedda0441a43eac47b4721e3975ff916cec"
+        ],
+        "linux_amd64": [
+          "go1.22.4.linux-amd64.tar.gz",
+          "ba79d4526102575196273416239cca418a651e049c2b099f3159db85e7bade7d"
+        ],
+        "linux_arm64": [
+          "go1.22.4.linux-arm64.tar.gz",
+          "a8e177c354d2e4a1b61020aca3562e27ea3e8f8247eca3170e3fa1e0c2f9e771"
+        ],
+        "linux_armv6l": [
+          "go1.22.4.linux-armv6l.tar.gz",
+          "e2b143fbacbc9cbd448e9ef41ac3981f0488ce849af1cf37e2341d09670661de"
+        ],
+        "linux_loong64": [
+          "go1.22.4.linux-loong64.tar.gz",
+          "e2ff9436e4b34bf6926b06d97916e26d67a909a2effec17967245900f0816f1d"
+        ],
+        "linux_mips": [
+          "go1.22.4.linux-mips.tar.gz",
+          "73f0dcc60458c4770593b05a7bc01cc0d31fc98f948c0c2334812c7a1f2fc3f1"
+        ],
+        "linux_mips64": [
+          "go1.22.4.linux-mips64.tar.gz",
+          "417af97fc2630a647052375768be4c38adcc5af946352ea5b28613ea81ca5d45"
+        ],
+        "linux_mips64le": [
+          "go1.22.4.linux-mips64le.tar.gz",
+          "7486e2d7dd8c98eb44df815ace35a7fe7f30b7c02326e3741bd934077508139b"
+        ],
+        "linux_mipsle": [
+          "go1.22.4.linux-mipsle.tar.gz",
+          "69479c8aad301e459a8365b40cad1074a0dbba5defb9291669f94809c4c4be6e"
+        ],
+        "linux_ppc64": [
+          "go1.22.4.linux-ppc64.tar.gz",
+          "dd238847e65bc3e2745caca475a5db6522a2fcf85cf6c38fc36a06642b19efd7"
+        ],
+        "linux_ppc64le": [
+          "go1.22.4.linux-ppc64le.tar.gz",
+          "a3e5834657ef92523f570f798fed42f1f87bc18222a16815ec76b84169649ec4"
+        ],
+        "linux_riscv64": [
+          "go1.22.4.linux-riscv64.tar.gz",
+          "56a827ff7dc6245bcd7a1e9288dffaa1d8b0fd7468562264c1523daf3b4f1b4a"
+        ],
+        "linux_s390x": [
+          "go1.22.4.linux-s390x.tar.gz",
+          "7590c3e278e2dc6040aae0a39da3ca1eb2e3921673a7304cc34d588c45889eec"
+        ],
+        "netbsd_386": [
+          "go1.22.4.netbsd-386.tar.gz",
+          "ddd2eebe34471a2502de6c5dad04ab27c9fc80cbde7a9ad5b3c66ecec4504e1d"
+        ],
+        "netbsd_amd64": [
+          "go1.22.4.netbsd-amd64.tar.gz",
+          "33af79f6f935f6fbacc5d23876450b3567b79348fc065beef8e64081127dd234"
+        ],
+        "netbsd_arm64": [
+          "go1.22.4.netbsd-arm64.tar.gz",
+          "c9a2971dec9f6d320c6f2b049b2353c6d0a2d35e87b8a4b2d78a2f0d62545f8e"
+        ],
+        "netbsd_armv6l": [
+          "go1.22.4.netbsd-arm.tar.gz",
+          "fa3550ebd5375a70b3bcd342b5a71f4bd271dcbbfaf4eabefa2144ab5d8924b6"
+        ],
+        "openbsd_386": [
+          "go1.22.4.openbsd-386.tar.gz",
+          "d21af022331bfdc2b5b161d616c3a1a4573d33cf7a30416ee509a8f3641deb47"
+        ],
+        "openbsd_amd64": [
+          "go1.22.4.openbsd-amd64.tar.gz",
+          "72c0094c43f7e5722ec49c2a3e9dfa7a1123ac43a5f3a63eecf3e3795d3ff0ae"
+        ],
+        "openbsd_arm64": [
+          "go1.22.4.openbsd-arm64.tar.gz",
+          "a7ab8d4e0b02bf06ed144ba42c61c0e93ee00f2b433415dfd4ad4b6e79f31650"
+        ],
+        "openbsd_armv6l": [
+          "go1.22.4.openbsd-arm.tar.gz",
+          "1096831ea3c5ea3ca57d14251d9eda3786889531eb40d7d6775dcaa324d4b065"
+        ],
+        "openbsd_ppc64": [
+          "go1.22.4.openbsd-ppc64.tar.gz",
+          "9716327c8a628358798898dc5148c49dbbeb5196bf2cbf088e550721a6e4f60b"
+        ],
+        "plan9_386": [
+          "go1.22.4.plan9-386.tar.gz",
+          "a8dd4503c95c32a502a616ab78870a19889c9325fe9bd31eb16dd69346e4bfa8"
+        ],
+        "plan9_amd64": [
+          "go1.22.4.plan9-amd64.tar.gz",
+          "5423a25808d76fe5aca8607a2e5ac5673abf45446b168cb5e9d8519ee9fe39a1"
+        ],
+        "plan9_armv6l": [
+          "go1.22.4.plan9-arm.tar.gz",
+          "6af939ad583f5c85c09c53728ab7d38c3cc2b39167562d6c18a07c5c6608b370"
+        ],
+        "solaris_amd64": [
+          "go1.22.4.solaris-amd64.tar.gz",
+          "e8cabe69c03085725afdb32a6f9998191a3e55a747b270d835fd05000d56abba"
+        ],
+        "windows_386": [
+          "go1.22.4.windows-386.zip",
+          "aca4e2c37278a10f1c70dd0df142f7d66b50334fcee48978d409202d308d6d25"
+        ],
+        "windows_amd64": [
+          "go1.22.4.windows-amd64.zip",
+          "26321c4d945a0035d8a5bc4a1965b0df401ff8ceac66ce2daadabf9030419a98"
+        ],
+        "windows_arm64": [
+          "go1.22.4.windows-arm64.zip",
+          "8a2daa9ea28cbdafddc6171aefed384f4e5b6e714fb52116fe9ed25a132f37ed"
+        ],
+        "windows_armv6l": [
+          "go1.22.4.windows-arm.zip",
+          "5fcd0671a49cecf39b41021621ee1b6e7aa1370f37122b72e80d4fd4185833b6"
+        ]
+      },
+      "1.25.0": {
+        "aix_ppc64": [
+          "go1.25.0.aix-ppc64.tar.gz",
+          "e5234a7dac67bc86c528fe9752fc9d63557918627707a733ab4cac1a6faed2d4"
+        ],
+        "darwin_amd64": [
+          "go1.25.0.darwin-amd64.tar.gz",
+          "5bd60e823037062c2307c71e8111809865116714d6f6b410597cf5075dfd80ef"
+        ],
+        "darwin_arm64": [
+          "go1.25.0.darwin-arm64.tar.gz",
+          "544932844156d8172f7a28f77f2ac9c15a23046698b6243f633b0a0b00c0749c"
+        ],
+        "dragonfly_amd64": [
+          "go1.25.0.dragonfly-amd64.tar.gz",
+          "5ed3cf9a810a1483822538674f1336c06b51aa1b94d6d545a1a0319a48177120"
+        ],
+        "freebsd_386": [
+          "go1.25.0.freebsd-386.tar.gz",
+          "abea5d5c6697e6b5c224731f2158fe87c602996a2a233ac0c4730cd57bf8374e"
+        ],
+        "freebsd_amd64": [
+          "go1.25.0.freebsd-amd64.tar.gz",
+          "86e6fe0a29698d7601c4442052dac48bd58d532c51cccb8f1917df648138730b"
+        ],
+        "freebsd_arm": [
+          "go1.25.0.freebsd-arm.tar.gz",
+          "d90b78e41921f72f30e8bbc81d9dec2cff7ff384a33d8d8debb24053e4336bfe"
+        ],
+        "freebsd_arm64": [
+          "go1.25.0.freebsd-arm64.tar.gz",
+          "451d0da1affd886bfb291b7c63a6018527b269505db21ce6e14724f22ab0662e"
+        ],
+        "freebsd_riscv64": [
+          "go1.25.0.freebsd-riscv64.tar.gz",
+          "7b565f76bd8bda46549eeaaefe0e53b251e644c230577290c0f66b1ecdb3cdbe"
+        ],
+        "illumos_amd64": [
+          "go1.25.0.illumos-amd64.tar.gz",
+          "b1e1fdaab1ad25aa1c08d7a36c97d45d74b98b89c3f78c6d2145f77face54a2c"
+        ],
+        "linux_386": [
+          "go1.25.0.linux-386.tar.gz",
+          "8c602dd9d99bc9453b3995d20ce4baf382cc50855900a0ece5de9929df4a993a"
+        ],
+        "linux_amd64": [
+          "go1.25.0.linux-amd64.tar.gz",
+          "2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613"
+        ],
+        "linux_arm64": [
+          "go1.25.0.linux-arm64.tar.gz",
+          "05de75d6994a2783699815ee553bd5a9327d8b79991de36e38b66862782f54ae"
+        ],
+        "linux_armv6l": [
+          "go1.25.0.linux-armv6l.tar.gz",
+          "a5a8f8198fcf00e1e485b8ecef9ee020778bf32a408a4e8873371bfce458cd09"
+        ],
+        "linux_loong64": [
+          "go1.25.0.linux-loong64.tar.gz",
+          "cab86b1cf761b1cb3bac86a8877cfc92e7b036fc0d3084123d77013d61432afc"
+        ],
+        "linux_mips": [
+          "go1.25.0.linux-mips.tar.gz",
+          "d66b6fb74c3d91b9829dc95ec10ca1f047ef5e89332152f92e136cf0e2da5be1"
+        ],
+        "linux_mips64": [
+          "go1.25.0.linux-mips64.tar.gz",
+          "4082e4381a8661bc2a839ff94ba3daf4f6cde20f8fb771b5b3d4762dc84198a2"
+        ],
+        "linux_mips64le": [
+          "go1.25.0.linux-mips64le.tar.gz",
+          "70002c299ec7f7175ac2ef673b1b347eecfa54ae11f34416a6053c17f855afcc"
+        ],
+        "linux_mipsle": [
+          "go1.25.0.linux-mipsle.tar.gz",
+          "b00a3a39eff099f6df9f1c7355bf28e4589d0586f42d7d4a394efb763d145a73"
+        ],
+        "linux_ppc64": [
+          "go1.25.0.linux-ppc64.tar.gz",
+          "df166f33bd98160662560a72ff0b4ba731f969a80f088922bddcf566a88c1ec1"
+        ],
+        "linux_ppc64le": [
+          "go1.25.0.linux-ppc64le.tar.gz",
+          "0f18a89e7576cf2c5fa0b487a1635d9bcbf843df5f110e9982c64df52a983ad0"
+        ],
+        "linux_riscv64": [
+          "go1.25.0.linux-riscv64.tar.gz",
+          "c018ff74a2c48d55c8ca9b07c8e24163558ffec8bea08b326d6336905d956b67"
+        ],
+        "linux_s390x": [
+          "go1.25.0.linux-s390x.tar.gz",
+          "34e5a2e19f2292fbaf8783e3a241e6e49689276aef6510a8060ea5ef54eee408"
+        ],
+        "netbsd_386": [
+          "go1.25.0.netbsd-386.tar.gz",
+          "f8586cdb7aa855657609a5c5f6dbf523efa00c2bbd7c76d3936bec80aa6c0aba"
+        ],
+        "netbsd_amd64": [
+          "go1.25.0.netbsd-amd64.tar.gz",
+          "ae8dc1469385b86a157a423bb56304ba45730de8a897615874f57dd096db2c2a"
+        ],
+        "netbsd_arm": [
+          "go1.25.0.netbsd-arm.tar.gz",
+          "1ff7e4cc764425fc9dd6825eaee79d02b3c7cafffbb3691687c8d672ade76cb7"
+        ],
+        "netbsd_arm64": [
+          "go1.25.0.netbsd-arm64.tar.gz",
+          "e1b310739f26724216aa6d7d7208c4031f9ff54c9b5b9a796ddc8bebcb4a5f16"
+        ],
+        "openbsd_386": [
+          "go1.25.0.openbsd-386.tar.gz",
+          "4802a9b20e533da91adb84aab42e94aa56cfe3e5475d0550bed3385b182e69d8"
+        ],
+        "openbsd_amd64": [
+          "go1.25.0.openbsd-amd64.tar.gz",
+          "c016cd984bebe317b19a4f297c4f50def120dc9788490540c89f28e42f1dabe1"
+        ],
+        "openbsd_arm": [
+          "go1.25.0.openbsd-arm.tar.gz",
+          "a1e31d0bf22172ddde42edf5ec811ef81be43433df0948ece52fecb247ccfd8d"
+        ],
+        "openbsd_arm64": [
+          "go1.25.0.openbsd-arm64.tar.gz",
+          "343ea8edd8c218196e15a859c6072d0dd3246fbbb168481ab665eb4c4140458d"
+        ],
+        "openbsd_ppc64": [
+          "go1.25.0.openbsd-ppc64.tar.gz",
+          "694c14da1bcaeb5e3332d49bdc2b6d155067648f8fe1540c5de8f3cf8e157154"
+        ],
+        "openbsd_riscv64": [
+          "go1.25.0.openbsd-riscv64.tar.gz",
+          "aa510ad25cf54c06cd9c70b6d80ded69cb20188ac6e1735655eef29ff7e7885f"
+        ],
+        "plan9_386": [
+          "go1.25.0.plan9-386.tar.gz",
+          "46f8cef02086cf04bf186c5912776b56535178d4cb319cd19c9fdbdd29231986"
+        ],
+        "plan9_amd64": [
+          "go1.25.0.plan9-amd64.tar.gz",
+          "29b34391d84095e44608a228f63f2f88113a37b74a79781353ec043dfbcb427b"
+        ],
+        "plan9_arm": [
+          "go1.25.0.plan9-arm.tar.gz",
+          "0a047107d13ebe7943aaa6d54b1d7bbd2e45e68ce449b52915a818da715799c2"
+        ],
+        "solaris_amd64": [
+          "go1.25.0.solaris-amd64.tar.gz",
+          "9977f9e4351984364a3b2b78f8b88bfd1d339812356d5237678514594b7d3611"
+        ],
+        "windows_386": [
+          "go1.25.0.windows-386.zip",
+          "df9f39db82a803af0db639e3613a36681ab7a42866b1384b3f3a1045663961a7"
+        ],
+        "windows_amd64": [
+          "go1.25.0.windows-amd64.zip",
+          "89efb4f9b30812eee083cc1770fdd2913c14d301064f6454851428f9707d190b"
+        ],
+        "windows_arm64": [
+          "go1.25.0.windows-arm64.zip",
+          "27bab004c72b3d7bd05a69b6ec0fc54a309b4b78cc569dd963d8b3ec28bfdb8c"
+        ]
+      }
+    },
+    "@@rules_python+//python/extensions:pip.bzl%pip": {
+      "dist_hashes": {
+        "https://pypi.org/simple": {
+          "backports-tarfile": {
+            "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz": "d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991",
+            "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl": "77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"
+          },
+          "certifi": {
+            "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz": "47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43",
+            "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl": "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"
+          },
+          "cffi": {
+            "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
+            "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl": "3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
+            "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl": "5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
+            "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl": "b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
+            "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
+            "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
+            "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl": "cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
+            "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl": "7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
+            "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl": "737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
+            "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl": "c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
+            "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl": "89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
+            "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl": "7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
+            "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl": "cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
+            "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl": "b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
+            "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl": "6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
+            "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl": "19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
+            "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl": "81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
+            "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl": "de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
+            "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl": "9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
+            "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl": "087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
+            "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl": "a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
+            "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
+            "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl": "8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
+            "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl": "45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
+            "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl": "00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
+            "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
+            "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl": "2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
+            "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
+            "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl": "d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
+            "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl": "b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
+            "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl": "c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
+            "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl": "e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
+            "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
+            "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl": "da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
+            "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
+            "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl": "fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
+            "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl": "0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
+            "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl": "4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
+            "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl": "c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
+            "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
+            "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
+            "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
+            "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl": "f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
+            "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl": "dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
+            "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl": "9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
+            "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl": "1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
+            "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
+            "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
+            "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl": "2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
+            "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl": "94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
+            "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl": "0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
+            "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl": "66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
+            "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
+            "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
+            "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
+            "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl": "2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
+            "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
+            "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl": "203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
+            "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl": "d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
+            "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
+            "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl": "d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
+            "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl": "fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf",
+            "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
+            "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl": "38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
+            "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl": "256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
+            "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl": "dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
+            "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
+            "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
+            "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
+            "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
+            "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl": "b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
+            "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
+            "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl": "8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
+            "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
+            "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl": "6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
+            "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl": "1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
+            "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl": "0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
+            "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl": "6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
+            "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz": "44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
+            "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl": "74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
+            "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl": "f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
+            "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl": "1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
+            "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl": "da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
+            "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl": "21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"
+          },
+          "charset-normalizer": {
+            "https://files.pythonhosted.org/packages/00/bd/ef9c88464b126fa176f4ef4a317ad9b6f4d30b2cffbc43386062367c3e2c/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9",
+            "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl": "ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049",
+            "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7",
+            "https://files.pythonhosted.org/packages/05/35/bb59b1cd012d7196fc81c2f5879113971efc226a63812c9cf7f89fe97c40/charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl": "5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40",
+            "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl": "42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491",
+            "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl": "30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92",
+            "https://files.pythonhosted.org/packages/0c/52/8b0c6c3e53f7e546a5e49b9edb876f379725914e1130297f3b423c7b71c5/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b",
+            "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f",
+            "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d",
+            "https://files.pythonhosted.org/packages/1a/79/ae516e678d6e32df2e7e740a7be51dc80b700e2697cb70054a0f1ac2c955/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b",
+            "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl": "2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312",
+            "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe",
+            "https://files.pythonhosted.org/packages/22/82/63a45bfc36f73efe46731a3a71cb84e2112f7e0b049507025ce477f0f052/charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl": "0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c",
+            "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl": "d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f",
+            "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl": "1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae",
+            "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl": "cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93",
+            "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl": "78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a",
+            "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601",
+            "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e",
+            "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl": "86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc",
+            "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91",
+            "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl": "4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0",
+            "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl": "88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34",
+            "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl": "939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14",
+            "https://files.pythonhosted.org/packages/50/10/c117806094d2c956ba88958dab680574019abc0c02bcf57b32287afca544/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl": "a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b",
+            "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl": "fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37",
+            "https://files.pythonhosted.org/packages/59/c0/a74f3bd167d311365e7973990243f32c35e7a94e45103125275b9e6c479f/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4",
+            "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9",
+            "https://files.pythonhosted.org/packages/61/c5/dc3ba772489c453621ffc27e8978a98fe7e41a93e787e5e5bde797f1dddb/charset_normalizer-3.4.3-cp38-cp38-win32.whl": "ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557",
+            "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl": "96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30",
+            "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl": "16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432",
+            "https://files.pythonhosted.org/packages/64/d1/f9d141c893ef5d4243bc75c130e95af8fd4bc355beff06e9b1e941daad6e/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl": "5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a",
+            "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl": "6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849",
+            "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2",
+            "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl": "14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe",
+            "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl": "18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc",
+            "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8",
+            "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl": "d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16",
+            "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl": "fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9",
+            "https://files.pythonhosted.org/packages/7a/03/cbb6fac9d3e57f7e07ce062712ee80d80a5ab46614684078461917426279/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl": "d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb",
+            "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884",
+            "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl": "53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0",
+            "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f",
+            "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl": "b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b",
+            "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392",
+            "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz": "6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14",
+            "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c",
+            "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31",
+            "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07",
+            "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl": "ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a",
+            "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl": "3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15",
+            "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl": "fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c",
+            "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl": "cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef",
+            "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa",
+            "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl": "6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce",
+            "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl": "02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0",
+            "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl": "027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154",
+            "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl": "257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7",
+            "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl": "c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce",
+            "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl": "320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f",
+            "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl": "6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927",
+            "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl": "70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05",
+            "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl": "1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19",
+            "https://files.pythonhosted.org/packages/c5/35/9c99739250742375167bc1b1319cd1cec2bf67438a70d84b2e1ec4c9daa3/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl": "b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942",
+            "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64",
+            "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl": "d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca",
+            "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl": "b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db",
+            "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl": "fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72",
+            "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl": "511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc",
+            "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl": "c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669",
+            "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f",
+            "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99",
+            "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl": "e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1",
+            "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl": "73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c",
+            "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl": "c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0",
+            "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl": "8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096",
+            "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl": "31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c",
+            "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl": "bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5",
+            "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl": "c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"
+          },
+          "cryptography": {
+            "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902",
+            "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl": "ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4",
+            "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl": "cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1",
+            "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl": "5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e",
+            "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl": "cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0",
+            "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl": "d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457",
+            "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl": "258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4",
+            "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl": "fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e",
+            "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl": "397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b",
+            "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl": "420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef",
+            "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl": "e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb",
+            "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl": "8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83",
+            "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl": "d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15",
+            "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl": "60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f",
+            "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl": "b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d",
+            "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl": "c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1",
+            "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl": "a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e",
+            "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz": "e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5",
+            "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308",
+            "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl": "f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246",
+            "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl": "fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022",
+            "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl": "65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee",
+            "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325",
+            "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl": "fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f",
+            "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl": "9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb",
+            "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl": "d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455",
+            "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c",
+            "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl": "d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842",
+            "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl": "935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006",
+            "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl": "128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832",
+            "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl": "73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77",
+            "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl": "abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba",
+            "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl": "fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298",
+            "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl": "5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163",
+            "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl": "d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2",
+            "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl": "84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7",
+            "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl": "7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85",
+            "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl": "462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4",
+            "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl": "80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99",
+            "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl": "cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2",
+            "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl": "35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0",
+            "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl": "ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce",
+            "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl": "1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067",
+            "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl": "42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b",
+            "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl": "04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65",
+            "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl": "91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85",
+            "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl": "506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3",
+            "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl": "3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968",
+            "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl": "24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"
+          },
+          "docutils": {
+            "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz": "9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d",
+            "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl": "b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8"
+          },
+          "idna": {
+            "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3",
+            "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz": "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"
+          },
+          "importlib-metadata": {
+            "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl": "e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd",
+            "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz": "d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"
+          },
+          "jaraco-classes": {
+            "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz": "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd",
+            "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl": "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"
+          },
+          "jaraco-context": {
+            "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz": "9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3",
+            "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl": "f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"
+          },
+          "jaraco-functools": {
+            "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl": "227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8",
+            "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz": "cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294"
+          },
+          "jeepney": {
+            "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz": "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732",
+            "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl": "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"
+          },
+          "keyring": {
+            "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz": "0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66",
+            "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl": "552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd"
+          },
+          "markdown-it-py": {
+            "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz": "cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3",
+            "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl": "87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"
+          },
+          "mdurl": {
+            "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
+            "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz": "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
+          },
+          "more-itertools": {
+            "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl": "52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b",
+            "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz": "f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"
+          },
+          "nh3": {
+            "https://files.pythonhosted.org/packages/0c/e0/cf1543e798ba86d838952e8be4cb8d18e22999be2a24b112a671f1c04fd6/nh3-0.3.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a",
+            "https://files.pythonhosted.org/packages/10/71/2fb1834c10fab6d9291d62c95192ea2f4c7518bd32ad6c46aab5d095cb87/nh3-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl": "0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5",
+            "https://files.pythonhosted.org/packages/23/1e/80a8c517655dd40bb13363fc4d9e66b2f13245763faab1a20f1df67165a7/nh3-0.3.0-cp313-cp313t-win_amd64.whl": "423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e",
+            "https://files.pythonhosted.org/packages/2f/d6/f1c6e091cbe8700401c736c2bc3980c46dca770a2cf6a3b48a175114058e/nh3-0.3.0-cp313-cp313t-win32.whl": "7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5",
+            "https://files.pythonhosted.org/packages/33/c1/8f8ccc2492a000b6156dce68a43253fcff8b4ce70ab4216d08f90a2ac998/nh3-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl": "1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9",
+            "https://files.pythonhosted.org/packages/39/2c/6394301428b2017a9d5644af25f487fa557d06bc8a491769accec7524d9a/nh3-0.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1",
+            "https://files.pythonhosted.org/packages/4c/3c/cba7b26ccc0ef150c81646478aa32f9c9535234f54845603c838a1dc955c/nh3-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl": "80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d",
+            "https://files.pythonhosted.org/packages/4e/9a/344b9f9c4bd1c2413a397f38ee6a3d5db30f1a507d4976e046226f12b297/nh3-0.3.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl": "37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9",
+            "https://files.pythonhosted.org/packages/5b/76/3165e84e5266d146d967a6cc784ff2fbf6ddd00985a55ec006b72bc39d5d/nh3-0.3.0-cp38-abi3-win_arm64.whl": "d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2",
+            "https://files.pythonhosted.org/packages/5c/86/a96b1453c107b815f9ab8fac5412407c33cc5c7580a4daf57aabeb41b774/nh3-0.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl": "ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1",
+            "https://files.pythonhosted.org/packages/63/da/c5fd472b700ba37d2df630a9e0d8cc156033551ceb8b4c49cc8a5f606b68/nh3-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl": "ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95",
+            "https://files.pythonhosted.org/packages/66/3f/cd37f76c8ca277b02a84aa20d7bd60fbac85b4e2cbdae77cb759b22de58b/nh3-0.3.0-cp38-abi3-musllinux_1_2_aarch64.whl": "634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62",
+            "https://files.pythonhosted.org/packages/6a/1b/b15bd1ce201a1a610aeb44afd478d55ac018b4475920a3118ffd806e2483/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl": "e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a",
+            "https://files.pythonhosted.org/packages/8c/ae/324b165d904dc1672eee5f5661c0a68d4bab5b59fbb07afb6d8d19a30b45/nh3-0.3.0-cp38-abi3-win_amd64.whl": "bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95",
+            "https://files.pythonhosted.org/packages/8f/14/079670fb2e848c4ba2476c5a7a2d1319826053f4f0368f61fca9bb4227ae/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl": "7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49",
+            "https://files.pythonhosted.org/packages/97/03/03f79f7e5178eb1ad5083af84faff471e866801beb980cc72943a4397368/nh3-0.3.0-cp38-abi3-musllinux_1_2_i686.whl": "c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450",
+            "https://files.pythonhosted.org/packages/97/33/11e7273b663839626f714cb68f6eb49899da5a0d9b6bc47b41fe870259c2/nh3-0.3.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl": "389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392",
+            "https://files.pythonhosted.org/packages/9a/e0/af86d2a974c87a4ba7f19bc3b44a8eaa3da480de264138fec82fe17b340b/nh3-0.3.0-cp313-cp313t-win_arm64.whl": "16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f",
+            "https://files.pythonhosted.org/packages/a3/e5/ac7fc565f5d8bce7f979d1afd68e8cb415020d62fa6507133281c7d49f91/nh3-0.3.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl": "af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb",
+            "https://files.pythonhosted.org/packages/ad/7f/7c6b8358cf1222921747844ab0eef81129e9970b952fcb814df417159fb9/nh3-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl": "7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2",
+            "https://files.pythonhosted.org/packages/b4/11/340b7a551916a4b2b68c54799d710f86cf3838a4abaad8e74d35360343bb/nh3-0.3.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl": "a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb",
+            "https://files.pythonhosted.org/packages/c3/a4/96cff0977357f60f06ec4368c4c7a7a26cccfe7c9fcd54f5378bf0428fd3/nh3-0.3.0.tar.gz": "d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f",
+            "https://files.pythonhosted.org/packages/c9/50/76936ec021fe1f3270c03278b8af5f2079038116b5d0bfe8538ffe699d69/nh3-0.3.0-cp38-abi3-win32.whl": "6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d",
+            "https://files.pythonhosted.org/packages/ce/55/1974bcc16884a397ee699cebd3914e1f59be64ab305533347ca2d983756f/nh3-0.3.0-cp38-abi3-musllinux_1_2_x86_64.whl": "3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518",
+            "https://files.pythonhosted.org/packages/ee/db/7aa11b44bae4e7474feb1201d8dee04fabe5651c7cb51409ebda94a4ed67/nh3-0.3.0-cp38-abi3-musllinux_1_2_armv7l.whl": "b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23",
+            "https://files.pythonhosted.org/packages/f3/ba/59e204d90727c25b253856e456ea61265ca810cda8ee802c35f3fadaab00/nh3-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl": "e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35"
+          },
+          "pkginfo": {
+            "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz": "5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b",
+            "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl": "c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"
+          },
+          "pycparser": {
+            "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl": "e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934",
+            "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz": "78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"
+          },
+          "pygments": {
+            "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz": "636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
+            "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl": "86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
+          },
+          "pywin32-ctypes": {
+            "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz": "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755",
+            "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl": "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"
+          },
+          "readme-renderer": {
+            "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz": "8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1",
+            "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl": "2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151"
+          },
+          "requests": {
+            "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz": "c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652",
+            "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl": "3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"
+          },
+          "requests-toolbelt": {
+            "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl": "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06",
+            "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz": "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"
+          },
+          "rfc3986": {
+            "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz": "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c",
+            "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl": "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"
+          },
+          "rich": {
+            "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl": "536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f",
+            "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz": "e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"
+          },
+          "secretstorage": {
+            "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz": "2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77",
+            "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl": "f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"
+          },
+          "twine": {
+            "https://files.pythonhosted.org/packages/5d/ec/00f9d5fd040ae29867355e559a94e9a8429225a0284a3f5f091a3878bfc0/twine-5.1.1-py3-none-any.whl": "215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997",
+            "https://files.pythonhosted.org/packages/77/68/bd982e5e949ef8334e6f7dcf76ae40922a8750aa2e347291ae1477a4782b/twine-5.1.1.tar.gz": "9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db"
+          },
+          "urllib3": {
+            "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl": "bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4",
+            "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz": "1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"
+          },
+          "zipp": {
+            "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl": "071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e",
+            "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz": "a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"
+          }
+        }
+      },
+      "fact_version": "v1"
+    }
+  }
 }
diff --git a/README.md b/README.md
index c8f0b70..d772238 100644
--- a/README.md
+++ b/README.md
@@ -24,23 +24,35 @@
 ## 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/GerritCodeReview/gerrit).
+There is a mirror of the repository on [GitHub](https://github.com/GerritCodeReview/gerrit).
 
 ## Reporting bugs
 
-Please report bugs on the [issue tracker](https://bugs.chromium.org/p/gerrit/issues/list).
+Please report bugs on the
+[issue tracker](https://issues.gerritcodereview.com/issues?q=status:open%20componentid:1370072).
+
+Due to spam abuse, membership in the
+[repo-discuss](http://groups.google.com/group/repo-discuss) Google Group is
+required in order to create issues. See the
+[announcement](https://www.gerritcodereview.com/2025-06-05-community-managers-minutes.html#reducing-spam-on-both-issue-tracker-and-gerritgooglesource)
+for more info.
 
 ## Contribute
 
 Gerrit is the work of hundreds of contributors. We appreciate your help!
 
-Please read the [contribution guidelines](https://gerrit.googlesource.com/gerrit/+/master/SUBMITTING_PATCHES).
+Please read the [contribution guidelines](SUBMITTING_PATCHES).
 
-Note that we do not accept Pull Requests via the Github mirror.
+Due to spam abuse, membership in the
+[repo-discuss](http://groups.google.com/group/repo-discuss) Google Group is
+required in order to create Gerrit changes on the
+[gerrit-review](https://gerrit-review.googlesource.com) Gerrit server.
+
+Note that we do not accept Pull Requests via the GitHub mirror.
 
 ## Getting in contact
 
-The Developer Mailing list is [repo-discuss on Google Groups](https://groups.google.com/forum/#!forum/repo-discuss).
+Find a full list of contact options on the [website](https://www.gerritcodereview.com/contact.html).
 
 ## License
 
diff --git a/SUBMITTING_PATCHES b/SUBMITTING_PATCHES
index 8a5b785..6e22345 100644
--- a/SUBMITTING_PATCHES
+++ b/SUBMITTING_PATCHES
@@ -8,6 +8,8 @@
 
    git push https://gerrit.googlesource.com/gerrit HEAD:refs/for/master
 
+See https://gerrit-review.googlesource.com/Documentation/dev-contributing.html
+for full details.
 
 Long Version:
 
@@ -62,6 +64,11 @@
 
 (3) Sending your patches.
 
+Due to spam abuse, membership in the http://groups.google.com/group/repo-discuss
+Google Group is required in order to create Gerrit changes on the
+https://gerrit-review.googlesource.com Gerrit server. Ensure you've done that
+before attempting git push.
+
 Do not email your patches to anyone.
 
 Instead, login to the Gerrit Code Review tool at:
diff --git a/WORKSPACE.bzlmod b/WORKSPACE.bzlmod
deleted file mode 100644
index 004dafb..0000000
--- a/WORKSPACE.bzlmod
+++ /dev/null
@@ -1,151 +0,0 @@
-# npm packages are split into different node_modules directories based on their
-# usage.
-# 1. @npm (node_modules) - contains packages to run tests, check code, etc...
-#    It is expected that @npm is used ONLY to run tools. No packages from @npm
-#    are used by other code in gerrit.
-# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder
-#    contains self-written tools which are run for building and/or testing. The
-#    @tools_npm directory contains all the packages needed to run this tools.
-# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which
-#    are necessary to run polygerrit and to bundle it. Only code from these
-#    packages can be included in the final bundle for polygerrit. @ui_npm folder
-#    must not have devDependencies. All devDependencies must be placed in
-#    @ui_dev_npm.
-# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit.
-#    The packages from these folder can be used for testing, but must not be
-#    included in the final bundle.
-# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit
-#    plugins. The packages here are expected to be used in plugins.
-# Note: separation between @ui_npm and @ui_dev_npm is necessary because with
-#    rules_nodejs we can't generate two external repositories from the same
-#    package.json. At the same time we want to avoid accidental usages of code
-#    from devDependencies in polygerrit bundle.
-workspace(
-    name = "gerrit",
-)
-
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
-load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
-
-# Gerrit-specific patched rules_nodejs 5.8.5 release artifact.
-# Windows-specific paths are disabled since Gerrit only supports Linux
-# and macOS builders.
-http_archive(
-    name = "build_bazel_rules_nodejs",
-    sha256 = "cd4f06efb688067a2a891c9b5647184b12708dcff36bebabeddce666f5422e7f",
-    urls = [
-        "https://github.com/davido/rules_nodejs/releases/download/v5.8.5-unixonly/rules_nodejs-5.8.5-gerrit.tar.gz",
-    ],
-)
-
-load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
-
-build_bazel_rules_nodejs_dependencies()
-
-# This is required just because we have a dependency on @bazel/concatjs.
-# We don't actually use any of this web_testing stuff.
-# TODO: Remove this dependency.
-http_archive(
-    name = "io_bazel_rules_webtesting",
-    sha256 = "e9abb7658b6a129740c0b3ef6f5a2370864e102a5ba5ffca2cea565829ed825a",
-    urls = [
-        "https://github.com/bazelbuild/rules_webtesting/releases/download/0.3.5/rules_webtesting.tar.gz",
-    ],
-)
-
-# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
-load("@io_bazel_rules_webtesting//web:repositories.bzl", "web_test_repositories")
-
-# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
-web_test_repositories()
-
-# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
-load("@io_bazel_rules_webtesting//web/versioned:browsers-0.3.3.bzl", "browser_repositories")
-
-# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
-browser_repositories(
-    chromium = True,
-    firefox = True,
-)
-
-load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
-
-NODE_20_REPO = {
-    "20.19.5-darwin_arm64": ("node-v20.19.5-darwin-arm64.tar.gz", "node-v20.19.5-darwin-arm64", "cfed7503d8d99fbcf2f52e408ec52f616058eb0867b34dbc3437259993ef5cba"),
-    "20.19.5-darwin_amd64": ("node-v20.19.5-darwin-x64.tar.gz", "node-v20.19.5-darwin-x64", "f9cff058f2766d4d0631dc69b5f7f27664b3a42ff186e25ac7e1ac269af7e696"),
-    "20.19.5-linux_arm64": ("node-v20.19.5-linux-arm64.tar.xz", "node-v20.19.5-linux-arm64", "d462267863ae8ee556039ebdf559055a8ec562c633889ef1403f3adb449ba1dd"),
-    "20.19.5-linux_ppc64le": ("node-v20.19.5-linux-ppc64le.tar.xz", "node-v20.19.5-linux-ppc64le", "ef98025e71d6d498476a95f144e353be074b24431b22eaa81bc64f921ea7d57f"),
-    "20.19.5-linux_s390x": ("node-v20.19.5-linux-s390x.tar.xz", "node-v20.19.5-linux-s390x", "a2e56c4b7fbffd0e6eef3a89e1c5945962fe85b4e2acfa59edc77a9238cc7901"),
-    "20.19.5-linux_amd64": ("node-v20.19.5-linux-x64.tar.xz", "node-v20.19.5-linux-x64", "315046739a513a70e03a4a55a8afda8cf979f30852e576075c340084e3f8ac0f"),
-    "20.19.5-windows_amd64": ("node-v20.19.5-win-x64.zip", "node-v20.19.5-win-x64", "c48159529572a5a947eef2d55d6485dfdc4ce8e67216402e2f6de52ad5d95695"),
-}
-
-node_repositories(
-    node_repositories = NODE_20_REPO,
-    node_version = "20.19.5",
-    yarn_version = "1.22.19",
-)
-
-yarn_install(
-    name = "npm",
-    exports_directories_only = False,
-    frozen_lockfile = False,
-    package_json = "//:package.json",
-    package_path = "",
-    symlink_node_modules = True,
-    yarn_lock = "//:yarn.lock",
-)
-
-yarn_install(
-    name = "ui_npm",
-    args = [
-        "--prod",
-        # By default, yarn install all optional dependencies.
-        # In some cases, it installs a lot of additional dependencies which
-        # are not required (for example, "resemblejs" has one optional
-        # dependencies "canvas" that leads to tens of additional dependencies).
-        # Each additional dependency requires a license even if it is not used
-        # in our code.  We want to ensure that all optional dependencies are
-        # explicitly added to package.json.
-        "--ignore-optional",
-    ],
-    exports_directories_only = False,
-    frozen_lockfile = False,
-    package_json = "//:polygerrit-ui/app/package.json",
-    package_path = "polygerrit-ui/app",
-    symlink_node_modules = True,
-    yarn_lock = "//:polygerrit-ui/app/yarn.lock",
-)
-
-yarn_install(
-    name = "ui_dev_npm",
-    exports_directories_only = False,
-    frozen_lockfile = False,
-    package_json = "//:polygerrit-ui/package.json",
-    package_path = "polygerrit-ui",
-    symlink_node_modules = True,
-    yarn_lock = "//:polygerrit-ui/yarn.lock",
-)
-
-yarn_install(
-    name = "tools_npm",
-    exports_directories_only = False,
-    frozen_lockfile = False,
-    package_json = "//:tools/node_tools/package.json",
-    package_path = "tools/node_tools",
-    symlink_node_modules = True,
-    yarn_lock = "//:tools/node_tools/yarn.lock",
-)
-
-yarn_install(
-    name = "plugins_npm",
-    args = ["--prod"],
-    exports_directories_only = False,
-    frozen_lockfile = False,
-    package_json = "//:plugins/package.json",
-    package_path = "plugins",
-    symlink_node_modules = True,
-    yarn_lock = "//:plugins/yarn.lock",
-)
-
-external_plugin_deps()
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 23641e3..3236d31 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -1,5 +1,5 @@
 load("@rules_java//java:defs.bzl", "java_library")
-load("//tools/bzl:genrule2.bzl", "genrule2")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
 
 genrule2(
     name = "query",
diff --git a/configs/agent_configs.textproto b/configs/agent_configs.textproto
new file mode 100644
index 0000000..c16bf16b
--- /dev/null
+++ b/configs/agent_configs.textproto
@@ -0,0 +1,64 @@
+# Frontend Engineering Agent
+configs: {
+  id: "gerrit-frontend-engineering"
+  display_name: "Gerrit Frontend Engineering"
+  description: "Analyzes Polygerrit UI, frontend TypeScript/JavaScript files, and UI elements for architecture and style correctness."
+  skills: "gerrit_frontend_engineering"
+  include_filters: {
+    project: "gerrit"
+    path_regex: "polygerrit-ui/.*"
+  }
+  automatic: true
+}
+
+# System Logic Agent
+configs: {
+  id: "gerrit-system-logic"
+  display_name: "Gerrit System Logic & Correctness"
+  description: "Analyzes Gerrit backend logic, Java sources, database transactions, and correctness rules."
+  skills: "gerrit_system_logic"
+  include_filters: {
+    project: "gerrit"
+    path_regex: ".*\\.java"
+  }
+  automatic: true
+}
+
+# Hygiene & Operations Agent
+configs: {
+  id: "gerrit-hygiene-operations"
+  display_name: "Gerrit Hygiene & Operations"
+  description: "Analyzes code hygiene, formatting, release preparation, and downstream dependencies."
+  skills: "gerrit_hygiene_operations"
+  include_filters: {
+    project: "gerrit"
+  }
+  automatic: true
+}
+
+# Commit Message Reviewer Agent
+configs: {
+  id: "gerrit-commit-message-review"
+  display_name: "Gerrit Commit Message Reviewer"
+  description: "Reviews the CL commit message for correctness, grammar, formatting compliance, and clarity."
+  skills: "gerrit_commit_message_review"
+  include_filters: {
+    project: "gerrit"
+    path_regex: "^/?COMMIT_MSG$"
+  }
+}
+
+# TypeScript Style Review Agent
+configs: {
+  id: "gerrit-typescript-style-review"
+  display_name: "TypeScript Style Review"
+  description: "Checks TypeScript files against style guide, focusing on property visibility and bypasses."
+  skills: "typescript_style_review"
+  include_filters: {
+    project: "gerrit"
+    project: "TestRepo"
+    path_regex: ".*\\.ts$"
+  }
+  automatic: true
+}
+
diff --git a/configs/skills/gerrit_commit_message_review/SKILL.md b/configs/skills/gerrit_commit_message_review/SKILL.md
new file mode 100644
index 0000000..1d50842
--- /dev/null
+++ b/configs/skills/gerrit_commit_message_review/SKILL.md
@@ -0,0 +1,474 @@
+---
+name: gerrit-commit-message-review
+description: Proofreads and suggests structural improvements for Git commit messages to ensure style guide compliance, completeness, and accuracy.
+---
+
+# Git Commit Message & Metadata Standards
+
+## Executive Summary
+
+Welcome to the definitive engineering reference for formatting, structuring, and
+preserving metadata inside Git commit messages. This document encapsulates the
+core conventions for commit hygiene, which are essential to ensuring commit log
+histories remain clean, readable, and highly traceable across development
+lifecycles.
+
+Adhering to these standards ensures metadata traceability, and provides clear,
+long-term context to future developers.
+
+## Summary
+
+| Chapter Theme / Title | Scope & Objective |
+| :--- | :--- |
+| **Commit Title Conventions** | Defines stylistic requirements for the first line of the commit message to optimize history navigation. |
+| **Commit Body Structure & Formatting** | Outlines instructions to clearly explain the "what" and "why" of the patchset with pragmatic conciseness. |
+| **Metadata Footers & Preservations** | Enforces the strict preservation of system-critical integration footers (such as Change-Id and issue tracking IDs). |
+| **Review Feedback & Suggested Commit Message** | Instructs the reviewer to provide a complete, fully-compliant, copy-pasteable revised commit message. |
+
+
+--------------------------------------------------------------------------------
+
+## Chapter: Commit Title Conventions
+
+**Context:** The title line of a Git commit message is the first line of visual feedback for engineers navigating repository logs. To ensure clarity and readability, title structures should be direct and action-oriented.
+
+### Summary
+
+| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
+| :--- | :--- | :--- | :--- |
+| **T1-01** | Concise & Imperative Commit Titles | High | Utilizing past-tense/progressive verbs (e.g. "Fixed...", "Fixing...") or trailing punctuation. |
+
+### Rules
+
+#### T1-01: Concise & Imperative Commit Titles
+
+> **Rule:** Commit titles must start with an imperative verb (e.g., "Add", "Fix", "Update", "Remove") and use sentence case without trailing punctuation. Do not enforce character count or line length limits on the commit title.
+>
+> **What:** The commit title line must be a concise, imperative sentence summary.
+>
+> **Applies To:** Git commit message first line.
+>
+> **Why:** Using consistent imperative verbs and sentence case without trailing punctuation ensures clean, readable display in repository logs and CLI tools. Line length is already checked deterministically by Gerrit commit validators.
+
+**Trap 1: Writing passive or non-imperative titles using progressive or past tense.**
+
+**Don't:**
+```text
+Fixing the loading spinner bug in gr-reply-dialog.ts and adding tests.
+```
+
+**Do:**
+```text
+Fix loading spinner and add test coverage
+```
+
+--------------------------------------------------------------------------------
+
+## Chapter: Commit Body Structure & Formatting
+
+**Context:** The body of a commit is a vital repository asset storing the architectural intent behind a change. It must explain engineering decisions with pragmatic conciseness and provide targeted context.
+
+### Summary
+
+| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
+| :--- | :--- | :--- | :--- |
+| **T2-01** | Explaining the Context: What and Why | High | Omitting commit bodies entirely, repeating the title, describing "how" instead of "why", or leaving critical design/bug links without context. |
+| **T2-02** | Pragmatic Tone, Conciseness, and Anti-Filler | High | Writing verbose, flowery prose, introducing generic engineering philosophy/boilerplate, or using redundant Q&A layouts on simple changes. |
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T2-01: Explaining the Context: What and Why
+
+> **Rule:** The commit message body must clearly explain *what* changes were made and *why* they were necessary, focusing on context and architectural intent, while maintaining a concise, non-redundant, and pragmatic tone. The body must jump directly into the technical context or problem and must never repeat, rephrase, or start with a high-level introductory summary of the title. For complex, security-sensitive, or high-risk changes, the explanation should explicitly ground the "why" by referencing the relevant issue, bug tracking ID, or design/RFC document. Do not include empty, low-value, or vacuous bug references (e.g. 'To address b/XXXXX, ...') in the body text unless accompanied by descriptive context explaining what the bug or issue represents. If the specific bug context is not known or cannot be verified, the bug reference must be omitted from the body paragraphs entirely, relying solely on the metadata footer for tracking.
+>
+> **What:** Explanations in the body must detail the problem and the rationale for the solution, leaving the mechanical "how" to be read from the code diff, and omitting generic value propositions of development practices, non-technical fluff, or introductory meta-sentences. The opening paragraph of the body must begin directly with the context or problem being solved and not restate or paraphrase the commit title. If the commit relates to a complex problem or implements an approved design specification, the body should draw from and cite these linked resources to clarify the reasoning in a direct, straightforward manner. If citing a bug tracker ID in the body paragraphs, ensure it adds concrete value by describing what problem or feedback is tracked there; otherwise, leave the bug reference out of the narrative body and let the footer handle the link.
+>
+> **Applies To:** Commit message lines following the spacer blank line (line 3 and onward).
+>
+> **Why:** Obvious code listings are redundant. Context is key: for complex or sensitive engineering changes, subsequent maintainers must understand the origin of a requirement or design constraint (e.g., a specific bug, CVE, or design specification) without having to guess, establishing clear auditability.
+
+**Trap 1: Repeating or rephrasing the title, starting the body with a high-level introductory summary sentence, or omitting concrete context.**
+
+The commit title is already the high-level summary of the change. Starting the body of the commit message with a rephrasing, restatement, or introductory "thesis statement" (e.g. "Add the agent and define its corresponding guidelines") is highly redundant and wastes reader time. Do not write introductory meta-sentences; begin the body paragraphs by jumping directly into the technical context or the problem being solved.
+
+**Don't:**
+```text
+Fix loading spinner and add test coverage
+
+This change fixes the loading spinner and adds test coverage to gr-reply-dialog.ts.
+```
+*(Problem: Repeats the title almost verbatim in the body opening.)*
+
+**Do:**
+```text
+Fix loading spinner and add test coverage
+
+The loading spinner in gr-reply-dialog was experiencing visual jitter
+on rapid page transitions due to a race condition in the reactive
+lifecycle hook.
+
+This change moves property assignments out of firstUpdated to avoid
+unnecessary second-pass rendering, stabilizing the visual state.
+```
+*(Rationale: Jumps immediately into the concrete problem.)*
+
+**Trap 2: Omitting context from linked bugs or design documents in complex, critical, or security-sensitive changes.**
+
+While simple or minor bug fixes do not need to explicitly reference their issue IDs inside the body, major, high-risk, or architectural modifications that reference external specs, RFCs, or bug tracker tickets should integrate that context into the "why" explanation. Failing to do so makes the commit message look disconnected from its metadata, making review and auditability difficult.
+
+**Don't:**
+```text
+Enhance project deletion permission validation
+
+Only users with administrative privileges are allowed to delete a
+project, but the server was previously checking for owner status only.
+This change corrects the check to require system administrator scope.
+
+Bug: gerrit:40012901
+```
+*(Problem: A security-sensitive permission model change is being made under a bug, but the body only explains the mechanical change. It completely misses the security context—like the permission bypass mentioned in the bug report—making the reasoning for this risk-heavy change unclear without looking up the bug.)*
+
+**Do:**
+```text
+Enhance project deletion permission validation
+
+To resolve the permission bypass reported in gerrit:40012901, where
+project owners could bypass global security policies to delete resource
+containers, we must restrict deletion calls to administrators.
+
+As defined in the project deletion security spec (https://example.com/gerrit-delete-spec),
+only system-level administrators should have the capability to
+destroy project repositories in production environments.
+
+Bug: gerrit:40012901
+```
+
+**Trap 3: Adding vacuous or low-value bug/issue references to the commit body.**
+
+Do not introduce generic, filler sentences or phrases (like "To address b/12345...", "In reference to b/12345...", or "As requested in b/12345...") into the narrative body of a commit message unless you are actually adding descriptive, valuable context from that bug or design document. If the bug context is not known, or if you cannot visit/verify the bug contents, **omit** the reference from the body text entirely. The metadata footer at the bottom (e.g., `Bug: b/12345` or `Google-Bug-ID: b/12345`) is the correct place to handle automated tracking; adding a vacuous mention in the body adds zero explanatory value and only increases noise.
+
+**Don't (Vacuous reference in body):**
+```text
+Suggest full commit message and avoid pedantic reviews
+
+To address b/505405738, this change updates the review guidelines to
+require that commit message reviews provide a complete, copy-pasteable
+revised message when formatting issues are found. It also instructs the
+reviewer to avoid pedantic feedback on minor casing or phrasing if the
+original message is already clear and compliant.
+
+Google-Bug-ID: b/505405738
+Release-Notes: skip
+Change-Id: I75ef56099ea36b8838a65746abb3a4771fcefd23
+```
+*(Problem: The body paragraph starts with "To address b/505405738, ...", but this phrase does not explain what the bug represents or what context it provides. It is a low-value, mechanical filler phrase.)*
+
+**Do (When bug context is unknown - omit the reference from the body):**
+```text
+Suggest full commit message and avoid pedantic reviews
+
+Automated commit message reviews can sometimes generate fragmented,
+pedantic feedback on minor phrasing nits, creating friction rather than
+saving developer time.
+
+To improve usability, SKILL.md now requires reviewers to generate a
+complete, copy-pasteable revised commit message when formatting issues
+are found, and to suppress feedback on minor nits if the original
+commit is fundamentally sound.
+
+Google-Bug-ID: b/505405738
+Release-Notes: skip
+Change-Id: I75ef56099ea36b8838a65746abb3a4771fcefd23
+```
+*(Rationale: Since the bug context is not explicitly visited or verified, the vacuous reference is completely removed from the body text, and the body jumps directly into the problem context first rather than starting with a generic "This change..." statement.)*
+
+**Do (When bug context is visited/known - integrate descriptive context):**
+```text
+Suggest full commit message and avoid pedantic reviews
+
+In b/505405738, developers reported being slowed down by fragmented,
+pedantic automated review feedback and requested ready-to-apply
+suggestions to reduce manual editing friction.
+
+To address this, this change updates the guidelines to require a
+complete, copy-pasteable revised message when formatting issues are
+found, and instructs reviewers to tolerate minor casing or phrasing
+variations when the original message is already compliant.
+
+Google-Bug-ID: b/505405738
+Release-Notes: skip
+Change-Id: I75ef56099ea36b8838a65746abb3a4771fcefd23
+```
+*(Rationale: The bug reference in the body now adds actual value by explaining exactly what issue/feedback was reported in b/505405738, providing important context to future maintainers, while explaining the problem/need before the solution.)*
+```
+*(Rationale: Since this is a high-risk change (a security bypass), the body explicitly connects to the bug report gerrit:40012901 and links the authoritative security spec (e.g., https://example.com/gerrit-delete-spec). This establishes bulletproof reasoning and traceability for a critical modification.)*
+
+--------------------------------------------------------------------------------
+
+#### T2-02: Pragmatic Tone, Conciseness, and Anti-Filler
+
+> **Rule:** Commit messages must be concise, direct, and free of conversational filler, obvious generalities, marketing/PR speak, or excessive boilerplate structure. Every sentence must serve to communicate technical context.
+>
+> **What:** Avoid long-winded introductions (e.g. "To ensure standard high-quality..."), platitudes (e.g. "Commit messages are vital repositories of engineering intent..."), and rigid Q&A-style templates (like "What is changing / Why this is necessary" headers) unless the change is highly complex and structurally demands them. Keep explanations simple, direct, and focused on the technical problem and its solution.
+>
+> **Applies To:** Git commit message bodies.
+>
+> **Why:** Verbose, flowery, or highly templated commit messages clutter repository logs and increase cognitive load for engineers searching history. A staff engineer's time is valuable; the commit message must deliver maximum signal-to-noise ratio.
+
+**Trap 1: Including generic software engineering justifications or explaining why code review, testing, or good practices are important in general.**
+Do not write essays on general design philosophy in the commit message. Stick strictly to the specific change's technical facts.
+
+**Don't:**
+```text
+Register gerrit-commit-message-review agent and skill
+
+To enforce rigorous commit log hygiene across this repository, this
+change registers the new 'gerrit-commit-message-review' AI reviewer
+agent and establishes its associated quality guidelines.
+
+Commit messages are permanent repositories of engineering intent, but
+manual review is prone to human oversight. Under the feature request
+in Issue 505405738, this system automates audit checks to provide
+instant, high-fidelity feedback. By deploying a specialized agent that
+evaluates formatting style, structural completeness, and footer
+integrity, developers receive automated proofreading findings directly
+in the Gerrit Checks UI (with ready-to-apply autofixes where
+applicable).
+```
+*(Problem: Highly verbose, generic text explaining general AI reviewer value propositions and repository hygiene, filled with fluff sentences like "Commit messages are permanent repositories...")*
+
+**Do:**
+```text
+Register gerrit-commit-message-review agent and skill
+
+To automate commit log hygiene audits across this repository, this
+change registers the new agent to trigger automatically on COMMIT_MSG
+changes. The accompanying skill definition outlines rules for
+subject-line format, context and intent explanation, and strict
+preservation of integration footers.
+
+Bug: Issue 505405738
+```
+*(Rationale: High signal-to-noise ratio. Completely avoids vacuous issue references in the body and jumps directly into the technical mechanism, leaving the issue linkage to the metadata footer.)*
+
+**Trap 2: Forcing complex multi-section layouts (like bullet points or Q&A headers) for straightforward, medium-sized, or simple changes.**
+Use simple, direct paragraphs instead of lists and structural headings whenever possible. Only use numbered lists when listing a sequence of highly distinct architectural changes.
+
+**Don't:**
+```text
+Fix loading spinner and add test coverage
+
+What is changing:
+1. The loading spinner in gr-reply-dialog.ts is fixed.
+2. Property assignments are moved out of firstUpdated to avoid dual rendering.
+3. Tests are added.
+
+Why this is necessary:
+The loading spinner was experiencing visual jitter on rapid page transitions
+due to a race condition in the reactive lifecycle hook, which is bad for UX.
+```
+*(Problem: Trivial bug fix is forced into a multi-headed structure with redundant wording.)*
+
+**Do:**
+```text
+Fix loading spinner and add test coverage
+
+The loading spinner in gr-reply-dialog was experiencing visual jitter
+on rapid page transitions due to a race condition in the reactive
+lifecycle hook.
+
+This change moves property assignments out of firstUpdated to avoid
+unnecessary second-pass rendering, stabilizing the visual state.
+```
+*(Rationale: Short, elegant, two paragraphs of narrative flow. Fully explains the problem and high-level solution without rigid, repetitive structural overhead.)*
+
+**Trap 3: Over-explaining straightforward changes, using sequential narrations ("First", "Second"), or explaining obvious developer-experience benefits.**
+Avoid writing multiple paragraphs or sequential lists to explain simple, singular enhancements. Never dedicate sentences to explaining *why* a change is beneficial in general (e.g. explaining that "this saves developer effort" or "improves developer experience"). Ground the explanation purely in the technical delta and keep simple changes within a single, dense paragraph of 2 to 3 sentences max.
+
+**Don't (Too Verbose):**
+```text
+Suggest full commit message and avoid pedantic reviews
+
+To address b/505405738 and improve the developer experience when
+addressing commit message feedback, this change updates the review
+guidelines in SKILL.md with a new chapter outlining response standards.
+
+First, if any formatting or hygiene issues are found, the reviewer must
+provide a complete, fully-compliant, and copy-pasteable revised commit
+message. This saves developer effort and streamlines the edit workflow.
+
+Second, to prevent automated review noise, the reviewer must adopt a
+pragmatic approach and tolerate minor casing or subjective phrasing
+differences if the message is already clear and compliant.
+```
+*(Problem: Highly verbose, uses sequential enumeration ("First", "Second"), and explains obvious general benefits like "This saves developer effort and streamlines the edit workflow".)*
+
+**Do (Ultra-Concise & High Density):**
+```text
+Suggest full commit message and avoid pedantic reviews
+
+Automated commit message reviews can generate fragmented, pedantic
+feedback on minor phrasing nits, creating friction rather than saving
+developer time. This change updates SKILL.md to require reviewers to
+provide a complete, copy-pasteable revised commit message when
+violations are found, and to tolerate minor variations if the original
+message is fundamentally compliant.
+
+Google-Bug-ID: b/505405738
+Release-Notes: skip
+```
+*(Rationale: Highly concise and high signal-to-noise ratio. Explains the problem/why context first, and avoids any vacuous issue reference prefixes or "This change..." introductory meta-sentences, while utilizing the metadata footer for bug linkage.)*
+
+
+--------------------------------------------------------------------------------
+
+## Chapter: Metadata Footers & Preservations
+
+**Context:** Dynamic metadata footers serve as vital integration links connecting code changes to issue tracking systems, code review platforms, and automated release auditing pipelines.
+
+### Summary
+
+| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
+| :--- | :--- | :--- | :--- |
+| **T3-01** | Mandatory Integration Footer Preservation | Critical | Modifying, corrupting, or dropping structured footers (Change-Id, Bug, or issue tracking keys) during edits. |
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T3-01: Mandatory Integration Footer Preservation
+
+> **Rule:** Always preserve all structured git metadata footers at the bottom of the commit message, matching the appropriate tracker style based on environment context.
+>
+> **What:** Do not modify, corrupt, or drop system-critical footers such as Change-Id, Bug, or tracker reference keys during edits.
+>
+> **Applies To:** Commit message footer block at the bottom.
+>
+> **Why:** Code review platforms (such as Gerrit) track revisions strictly using the `Change-Id` footer. Deleting it detaches revision history or breaks integration webhooks. Similarly, issue tracking systems rely on matching keys (e.g., `Bug: <ID>`, `Closes #<ID>`) to link code commits with project tickets.
+
+**Trap 1: Amending or rewriting the commit message and dropping the original metadata footers.**
+
+**Don't:**
+```text
+Update system cache configs
+
+Refactored memory size and cache duration parameters.
+```
+
+**Do (Standard issue tracker format):**
+```text
+Update system cache configs
+
+Refactored memory size and cache duration parameters.
+
+Bug: Issue 12345
+Release-Notes: skip
+Change-Id: Iab12cd34ef560078009000120034005600780090
+```
+
+**Do (GitHub/GitLab tracker format):**
+```text
+Update system cache configs
+
+Refactored memory size and cache duration parameters.
+
+Closes #1234567
+Release-Notes: skip
+Change-Id: Iab12cd34ef560078009000120034005600780090
+```
+
+--------------------------------------------------------------------------------
+
+## Chapter: Review Feedback & Suggested Commit Message
+
+**Context:** To provide maximum value and minimize friction, the reviewer must not only point out formatting/hygiene violations but also provide a complete, corrected, and fully-compliant commit message that the developer can copy and paste directly into Gerrit's commit message editor.
+
+### Summary
+
+| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
+| :--- | :--- | :--- | :--- |
+| **T4-01** | Provide a Complete Suggested Commit Message | High | Providing only high-level feedback or listing line-by-line suggestions without providing a single, copy-pasteable revised commit message. |
+| **T4-02** | Pragmatic Tolerance & Anti-Noise | High | Suggesting rewrites for minor stylistic differences or trivial casing when the original commit message is already highly informative and readable. |
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T4-01: Provide a Complete Suggested Commit Message
+
+> **Rule:** Whenever any formatting, structure, or hygiene issues are identified in the commit message, the review feedback must include a dedicated section containing the complete, fully-compliant, and improved version of the commit message wrapped inside a markdown code block (e.g., using `text` or `git` syntax highlighting). If the original commit message is already satisfactory, fully compliant, and informative, no revised version or suggestions should be provided.
+>
+> **What:** The review feedback must provide the complete revised commit message in a single code block as a drop-in replacement ONLY when violations or potential improvements are found. This suggested message must meticulously apply all the guidelines defined in this skill (e.g., imperative verbs, clear context and intent explanation, pragmatic and concise tone). It **must** preserve all existing metadata footers (like `Change-Id`, `Bug`, `Closes`, and `Release-Notes`) exactly as they appeared in the original message. If the commit message is already fully compliant, the reviewer should state that no improvements are necessary and omit the suggestion block.
+>
+> **Applies To:** Review feedback reports and summary comments on COMMIT_MSG.
+>
+> **Why:** Developers want to resolve formatting issues as quickly as possible. Providing a complete, copy-pasteable improved message eliminates the need for the developer to manually rewrite sentences, significantly improving the developer experience. However, forcing a rewrite when the message is already of high quality causes unnecessary noise and friction.
+
+**Trap 1: Providing feedback comments on individual lines but omitting a single, unified revised commit message.**
+
+**Don't:**
+```text
+Line 1: The title uses progressive tense ("Fixing...") instead of an imperative verb.
+Line 3: The opening sentence repeats the commit title instead of explaining why the change is needed.
+```
+
+**Do:**
+````text
+### Commit Message Review
+
+I found a few issues with your commit message:
+1. The title uses progressive tense ("Fixing...") instead of an imperative verb.
+2. The opening sentence repeats the commit title instead of explaining the underlying problem.
+
+Here is an improved, fully-compliant version of your commit message that you can copy and paste directly into the Gerrit edit dialog:
+
+```text
+Fix loading spinner and add test coverage
+
+The loading spinner in gr-reply-dialog was experiencing visual jitter
+on rapid page transitions due to a race condition in the reactive
+lifecycle hook.
+
+This change moves property assignments out of firstUpdated to avoid
+unnecessary second-pass rendering, stabilizing the visual state.
+
+Bug: Issue 12345
+Release-Notes: skip
+Change-Id: Iab12cd34ef560078009000120034005600780090
+```
+````
+*(Rationale: Provides a ready-to-use solution that saves developer effort.)*
+
+--------------------------------------------------------------------------------
+
+#### T4-02: Pragmatic Tolerance & Anti-Noise
+
+> **Rule:** The reviewer must adopt a pragmatic, non-pedantic approach to evaluating commit messages. Do NOT suggest rewrites for minor stylistic differences, subjective phrasing preferences, or trivial casing choices if the original message is already clear and informative. Do NOT count characters or flag subject or body line lengths, as line length limits are already enforced deterministically by Gerrit's commit validators.
+>
+> **What:** Apply a high threshold of value before flagging a commit message or suggesting an alternative. Trivial stylistic points (such as starting a component prefix colon with a lowercase verb, e.g., `GrepServlet: add ...` vs `GrepServlet: Add ...`, or slightly differing sentence structures that express the same context) are considered acceptable. The reviewer must **never** post comments or generate a suggestion block for these minor variations or for line-length/wrapping counts. Suggest revisions **only** when there are clear, substantive violations (e.g., missing essential context, repeating the title verbatim as the sole body, or corrupted/missing metadata footers).
+>
+> **Applies To:** All review feedback reports and comments.
+>
+> **Why:** Superfluous or nitpicky reviews (often referred to as "pedantic noise") irritate authors, waste review cycles, and erode trust in automated tooling. AI reviews must focus strictly on high-value correctness, safety, and critical readability standards.
+
+**Trap 1: Flagging a well-written, informative commit message over minor sentence formatting, prefix casing, or line lengths.**
+
+**Don't (Pedantic Noise):**
+```text
+### Commit Message Review
+
+The title uses a lowercase verb after the prefix ("add" instead of "Add"). Also, we can improve the body phrasing to be slightly more descriptive.
+
+Suggested Commit Message:
+GrepServlet: Add JSON content search endpoint
+
+To support content search in Gitiles (Issue 376381593)...
+```
+*(Problem: The original commit message was already outstanding. Suggesting a rewrite for trivial casing and wording differences adds no structural value.)*
+
+**Do:**
+State that the commit message is fully compliant and satisfactory, and do not post any individual comments or suggested rewrite.
+
+
diff --git a/configs/skills/gerrit_frontend_engineering/SKILL.md b/configs/skills/gerrit_frontend_engineering/SKILL.md
new file mode 100644
index 0000000..d89e403
--- /dev/null
+++ b/configs/skills/gerrit_frontend_engineering/SKILL.md
@@ -0,0 +1,2938 @@
+---
+name: gerrit-frontend-engineering
+description: Provides guidance and best practices on Polygerrit UI development, frontend architecture, and TypeScript/JS coding standards in Gerrit.
+---
+
+# Frontend Engineering & UI Development Engineering Guide
+
+## Executive Summary
+
+Welcome to the Frontend Engineering & UI Development guide. This repository
+serves as the authoritative source of tribal knowledge for our UI architecture,
+born from historical refactoring efforts, critical performance optimizations,
+and the ongoing necessity to prevent recurrent regression of known failure
+modes. By codifying these engineering standards, we ensure that incoming
+engineers can confidently navigate the complexities of our frontend ecosystem
+without falling into legacy traps, introducing unverified UI states, or
+triggering silent runtime failures.
+
+This guide enforces strict architectural boundaries across the entire UI
+development lifecycle. It mandates rigorous state encapsulation within the Lit
+framework, uncompromising TypeScript type safety, and highly resilient
+client-server integrations. It further standardizes hermetic UI testing
+methodologies, unifies our CSS design systems, and outlines strict strategies
+for client-side performance profiling.
+
+Adherence to these principles guarantees structural consistency and system
+reliability. Whether you are building dynamic web components, integrating
+complex REST API endpoints, or optimizing data payloads for AI contexts and
+telemetry, this documentation establishes the foundational constraints required
+to ship a performant, predictable, and scalable user interface.
+
+## Summary
+
+| Chapter Theme / Title                | Scope & Objective                     |
+| :----------------------------------- | :------------------------------------ |
+| **Lit Framework Idioms & State       | Enforce strict Lit framework idioms   |
+: Encapsulation**                      : by leveraging declarative rendering,  :
+:                                      : reactive property encapsulation, and  :
+:                                      : native lifecycle hooks. Avoid         :
+:                                      : imperative DOM manipulation and       :
+:                                      : properly isolate transient UI status  :
+:                                      : from the core data models.            :
+| **TypeScript Strictness & Type       | This domain governs the strict        |
+: Safety**                             : enforcement of TypeScript type safety :
+:                                      : by explicitly forbidding unsafe       :
+:                                      : casting and blanket compiler          :
+:                                      : suppressions. It mandates precise     :
+:                                      : component modeling, strict interface  :
+:                                      : adherence, and proper access          :
+:                                      : modifiers to eliminate silent runtime :
+:                                      : failures and unverified states.       :
+| **Hermetic Testing & Visual          | This chapter mandates the strict      |
+: Regression**                         : isolation of unit tests,              :
+:                                      : comprehensive visual validation using :
+:                                      : full shadow DOM snapshots, and the    :
+:                                      : centralization of test data           :
+:                                      : generation to guarantee hermetic      :
+:                                      : execution and prevent false-positive  :
+:                                      : assertions.                           :
+| **Client-Side Performance &          | This theme governs the optimization   |
+: Telemetry**                          : of client-side performance by         :
+:                                      : enforcing strict telemetry on         :
+:                                      : synchronous CPU-bound operations and  :
+:                                      : minimizing network latency. It        :
+:                                      : mandates the caching of asynchronous  :
+:                                      : API promises, the derivation of state :
+:                                      : from existing payloads, and the rigid :
+:                                      : bounding of background data requests. :
+| **CSS Architecture & Design System   | This domain governs the consistent    |
+: Consistency**                        : application of styles across the UI,  :
+:                                      : mandating the use of content-driven   :
+:                                      : layout techniques, externalized       :
+:                                      : custom property configurations for    :
+:                                      : visual assets, and declarative Lit    :
+:                                      : directives over imperative inline     :
+:                                      : styling.                              :
+| **API Integration & Error Handling** | This chapter governs the resilient    |
+:                                      : integration of frontend logic with    :
+:                                      : REST APIs. It establishes strict      :
+:                                      : constraints for maintaining backend   :
+:                                      : payload parity, explicitly modeling   :
+:                                      : structural variances, and enforcing   :
+:                                      : centralized error handling over       :
+:                                      : localized `try...catch` blocks.       :
+| **AI Context & Telemetry Payload     | This domain governs the client-side   |
+: Optimization**                       : lifecycle and optimization of data    :
+:                                      : structures sent to AI agents and      :
+:                                      : telemetry pipelines. It strictly      :
+:                                      : dictates payload deduplication        :
+:                                      : strategies, transparent AI token      :
+:                                      : constraint surfacing, and rigorous    :
+:                                      : parsing validation to prevent silent  :
+:                                      : context loss.                         :
+
+--------------------------------------------------------------------------------
+--------------------------------------------------------------------------------
+
+## Chapter: Lit Framework Idioms & State Encapsulation
+
+**Context:** Enforce strict Lit framework idioms by leveraging declarative
+rendering, reactive property encapsulation, and native lifecycle hooks. Avoid
+imperative DOM manipulation and properly isolate transient UI status from the
+core data models.
+
+### Summary
+
+| Rule ID   | Principle /        | Priority | Primary Symptom / Trap           |
+:           : Constraint         :          :                                  :
+| :-------- | :----------------- | :------- | :------------------------------- |
+| **T1-01** | Separation of Data | High     | Reusing a data property to hold  |
+:           : and UI State       :          : UI loading or error text, which  :
+:           : Variables          :          : corrupts the data being passed   :
+:           :                    :          : to child components or the       :
+:           :                    :          : clipboard.                       :
+| **T1-02** | Explicit Boolean   | High     | Inspecting the DOM via           |
+:           : Properties over    :          : `this.children` in Lit element   :
+:           : Dynamic Slot       :          : methods to determine if a named  :
+:           : Detection          :          : slot element was provided by the :
+:           :                    :          : parent.                          :
+| **T1-03** | Declarative Event  | Medium   | Attaching DOM event listeners    |
+:           : Listeners in Lit   :          : imperatively within the          :
+:           : Templates          :          : component constructor.           :
+| **T1-04** | Declarative        | Medium   | Chaining ternary operators       |
+:           : Conditional CSS    :          : inside a template string to      :
+:           : via classMap       :          : build a class list.              :
+| **T1-05** | Centralized        | Medium   | Defining a custom manager object |
+:           : Periodic           :          : or singleton strictly tied to    :
+:           : LitElement Updates :          : one specific UI component type   :
+:           : via Generic        :          : for periodic updates.            :
+:           : Utility            :          :                                  :
+| **T1-06** | Declarative        | Medium   | Manually invoking setAttribute   |
+:           : Attribute          :          : or removeAttribute within a Lit  :
+:           : Reflection in Lit  :          : lifecycle method to sync DOM     :
+:           : Components         :          : properties.                      :
+| **T1-07** | Idempotent         | Medium   | Firing a tracking event inside   |
+:           : Telemetry          :          : `updated` purely based on        :
+:           : Reporting in Lit   :          : conditional presence, without a  :
+:           : Component          :          : state flag acknowledging it      :
+:           : Lifecycle Hooks    :          : fired.                           :
+| **T1-08** | Strict Truthiness  | High     | Relying on optional chaining     |
+:           : Checks for         :          : length checks to represent empty :
+:           : Asynchronous Array :          : or missing data.                 :
+:           : Data               :          :                                  :
+| **T1-09** | Pre-computing      | Medium   | A helper method called in        |
+:           : Derived State in   :          : `render()` that parses raw       :
+:           : Lit Lifecycle      :          : strings into arrays on every     :
+:           : Methods            :          : invocation.                      :
+| **T1-10** | Returning          | Medium   | Using an empty template literal  |
+:           : `nothing` for      :          : or omitting a return when a      :
+:           : Empty Templates in :          : condition fails in the template. :
+:           : Lit                :          :                                  :
+| **T1-11** | Single-Pass        | Medium   | Assigning values to `@state()`   |
+:           : Initialization of  :          : or `@property()` fields inside   :
+:           : Reactive           :          : the `firstUpdated` hook.         :
+:           : Properties         :          :                                  :
+| **T1-12** | Guarding           | High     | Executing timeout handlers or    |
+:           : Asynchronous State :          : asynchronous DOM updates without :
+:           : Updates            :          : confirming the component remains :
+:           : Post-Disconnection :          : in the DOM structure.            :
+| **T1-13** | Use Lit classMap   | Medium   | Concatenating ternary operators  |
+:           : Directive for      :          : inside the `class` attribute     :
+:           : Conditional CSS    :          : string of a Lit HTML template.   :
+:           : Classes            :          :                                  :
+| **T1-14** | Strict Lit State   | High     | Using the `@property` decorator  |
+:           : Encapsulation      :          : for variables that represent     :
+:           :                    :          : internal component state (like   :
+:           :                    :          : data lists or loading spinners). :
+| **T1-15** | Declarative        | Medium   | Controlling element visibility   |
+:           : Component          :          : using CSS class conditionals.    :
+:           : Visibility         :          :                                  :
+:           : Toggling           :          :                                  :
+| **T1-16** | Immutable Lit Form | High     | Mutating form state fields       |
+:           : State Resets       :          : directly without triggering a    :
+:           :                    :          : reference change for Lit to      :
+:           :                    :          : detect.                          :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T1-01: Separation of Data and UI State Variables
+
+> **Rule:** Never overload core data variables with transient UI status strings.
+> You must use dedicated status properties to manage UI state separately from
+> the underlying data model.
+>
+> **What:** Do not overload core data variables with transient UI status strings
+> (e.g., 'Loading...', 'Error'). Use dedicated status properties to manage UI
+> state separately from the underlying data model.
+>
+> **Applies To:** Lit components managing asynchronous operations and displaying
+> resulting data alongside loading or error states.
+>
+> **Why:** A `generatedPassword` property was overloaded to temporarily hold
+> 'Generating...' or 'Failed to generate'. This caused the associated 'Copy to
+> clipboard' component to copy the error or status text instead of a valid
+> password. Failing to adhere to this typically results in **Invalid Data
+> Copied**.
+
+**Trap 1: Reusing a data property to hold UI loading or error text, which
+corrupts the data being passed to child components or the clipboard.**
+
+**Don't:**
+
+```typescript
+this._generatedPassword = 'Generating...';
+this.restApiService.generatePassword().then(newPassword => {
+  this._generatedPassword = newPassword ?? 'Failed to generate';
+});
+```
+
+**Do:**
+
+```typescript
+this.status = 'Generating...';
+this.restApiService.generatePassword().then(newPassword => {
+  if (newPassword) {
+    this.generatedPassword = newPassword;
+    this.status = undefined;
+  } else {
+    this.status = 'Failed to generate';
+  }
+});
+```
+
+#### T1-02: Explicit Boolean Properties over Dynamic Slot Detection
+
+> **Rule:** Always control conditional layout wrappers using explicit boolean
+> properties mapped to the component API. Never dynamically query the DOM for
+> the presence of assigned slots.
+>
+> **What:** Control the rendering of conditional layout wrappers using explicit
+> boolean properties mapped to the component's API, rather than dynamically
+> querying the DOM for the presence of assigned slots.
+>
+> **Applies To:** Lit components featuring conditional layout containers that
+> wrap `<slot>` elements (e.g., search bars with optional leading icons).
+>
+> **Why:** Dynamically checking `hasNamedSlot` by iterating over `this.children`
+> was computationally brittle and caused rendering bugs where structural
+> elements (like search icons) were accidentally removed or created unintended
+> 'ghost spacing'. Failing to adhere to this typically results in **Layout
+> Regression / Missing Elements**.
+
+**Trap 1: Inspecting the DOM via `this.children` in Lit element methods to
+determine if a named slot element was provided by the parent.**
+
+**Don't:**
+
+```typescript
+private hasNamedSlot(name: string): boolean {
+  return Array.from(this.children).some(
+    el => el.getAttribute('slot') === name
+  );
+}
+
+render() {
+  return this.hasNamedSlot('leading-icon') ? html`<div><slot name="leading-icon"></slot></div>` : nothing;
+}
+```
+
+**Do:**
+
+```typescript
+@property({type: Boolean})
+showLeadingIcon = false;
+
+render() {
+  return this.showLeadingIcon ? html`<div><slot name="leading-icon"></slot></div>` : nothing;
+}
+```
+
+#### T1-03: Declarative Event Listeners in Lit Templates
+
+> **Rule:** Always bind event listeners declaratively directly within the
+> component's `render()` template. Never imperatively attach listeners using
+> `this.addEventListener` in the constructor.
+>
+> **What:** Bind event listeners declaratively directly within the component's
+> `render()` template using `@event` syntax, rather than imperatively attaching
+> them using `this.addEventListener` in the constructor.
+>
+> **Applies To:** LitElement initialization and user interaction event handling.
+>
+> **Why:** Imperatively adding listeners via the constructor disconnects the
+> logic from the declarative template, risks memory leaks if not cleaned up,
+> leaves inline documentation orphaned, and triggers automated code health
+> warnings. Failing to adhere to this typically results in **Structural
+> Anti-Pattern / Orphaned Context**.
+
+**Trap 1: Attaching DOM event listeners imperatively within the component
+constructor.**
+
+**Don't:**
+
+```typescript
+constructor() {
+  super();
+  // BAD: Imperative binding
+  this.addEventListener('mousedown', e => this.handleMouseDown(e));
+}
+```
+
+**Do:**
+
+```html
+override render() {
+  // GOOD: Declarative binding directly in the Lit template
+  return html`
+    <div class="menu" @mousedown=${this.handleMenuMouseDown}>
+      <div class="menu-item">${this.hoverCardText}</div>
+    </div>
+  `;
+}
+```
+
+#### T1-04: Declarative Conditional CSS via classMap
+
+> **Rule:** Must use the Lit `classMap` directive for applying conditional CSS
+> classes in templates. Avoid manual string interpolation with ternary
+> operators.
+>
+> **What:** Use the Lit `classMap` directive for applying conditional CSS
+> classes in templates instead of manual string interpolation with ternary
+> operators.
+>
+> **Applies To:** Lit Framework templates (`render()` methods).
+>
+> **Why:** Manual string interpolation for classes is difficult to read and
+> prone to whitespace concatenation errors, which leads to incorrectly applied
+> or missed CSS selectors during dynamic state changes. Failing to adhere to
+> this typically results in **Malformed Class Strings / UI Bugs**.
+
+**Trap 1: Chaining ternary operators inside a template string to build a class
+list.**
+
+**Don't:**
+
+```typescript
+<div class="diffContainer ${this.shownSidebar ? 'sidebarOpen' : ''} ${this.file?.diffs_too_expensive_to_compute ? 'hidden' : ''}">
+```
+
+**Do:**
+
+```typescript
+<div class=${classMap({
+  diffContainer: true,
+  sidebarOpen: this.shownSidebar,
+  hidden: !!this.file?.diffs_too_expensive_to_compute
+})}>
+```
+
+#### T1-05: Centralized Periodic LitElement Updates via Generic Utility
+
+> **Rule:** Always register components requiring timer-based periodic
+> re-rendering with a centralized update manager. Never implement individual
+> `setInterval` loops inside isolated components.
+>
+> **What:** Components requiring timer-based periodic re-rendering must register
+> with a centralized `PeriodicUpdateManager` rather than implementing individual
+> `setInterval` and lifecycle cleanup logic inside the component.
+>
+> **Applies To:** LitElements displaying time-sensitive data (e.g., relative
+> dates, "time ago" formatters).
+>
+> **Why:** Individual date formatter components initially implemented their own
+> object literal manager or interval timers. This violated the separation of
+> concerns and led to memory leaks if components failed to clean up their
+> specific timers upon disconnection. Failing to adhere to this typically
+> results in **Memory Leaks / Duplicated Timer Logic**.
+
+**Trap 1: Defining a custom manager object or singleton strictly tied to one
+specific UI component type for periodic updates.**
+
+**Don't:**
+
+```typescript
+export const dateFormatterManager = {
+  formatters: new Set<GrDateFormatter>(),
+  register(formatter) { /* set interval to call requestUpdate */ }
+}
+```
+
+**Do:**
+
+```typescript
+// In periodic-update-util.ts
+export class PeriodicUpdateManager<T extends LitElement> {
+  constructor(private readonly refreshIntervalMs: number) {}
+  register(component: T) { /* generic interval logic */ }
+}
+
+// In component
+override connectedCallback() {
+  super.connectedCallback();
+  dateFormatterManager.register(this);
+}
+```
+
+#### T1-06: Declarative Attribute Reflection in Lit Components
+
+> **Rule:** Must use Lit's `@property({reflect: true})` decorator to synchronize
+> a component's property state with DOM attributes. Never manually manipulate
+> attributes via `setAttribute` within lifecycle methods.
+>
+> **What:** Use Lit's @property({reflect: true}) decorator to automatically
+> synchronize a component's property state with its corresponding DOM
+> attributes, replacing manual DOM manipulation calls.
+>
+> **Applies To:** Lit web components, styling hooks, and state management.
+>
+> **Why:** A custom icon wrapper was manually setting and removing an attribute
+> inside the `willUpdate` lifecycle method to apply CSS selectors, violating
+> declarative state management principles. Failing to adhere to this typically
+> results in **Imperative DOM Manipulation**.
+
+**Trap 1: Manually invoking setAttribute or removeAttribute within a Lit
+lifecycle method to sync DOM properties.**
+
+**Don't:**
+
+```typescript
+override willUpdate() {
+  if (this.icon) {
+    this.setAttribute('custom', '');
+  } else {
+    this.removeAttribute('custom');
+  }
+}
+```
+
+**Do:**
+
+```typescript
+@property({type: Boolean, reflect: true})
+custom = false;
+
+override willUpdate() {
+  this.custom = this.icon ? true : false;
+}
+```
+
+#### T1-07: Idempotent Telemetry Reporting in Lit Component Lifecycle Hooks
+
+> **Rule:** Must guard telemetry interactions fired during the `updated()`
+> lifecycle hook with a dedicated state flag. Never allow subsequent, unrelated
+> property changes to trigger duplicate reporting.
+>
+> **What:** Telemetry interactions fired during the `updated()` lifecycle hook
+> must be guarded by a dedicated state flag to prevent duplicate reporting
+> triggered by subsequent, unrelated property changes.
+>
+> **Applies To:** Lit components executing side effects (like analytics or
+> impression tracking) within the `updated()` loop.
+>
+> **Why:** When streaming AI responses, the component's state rapidly updated.
+> Without an idempotent guard flag, the system generated multiple telemetry
+> events for the exact same interaction every time the state re-rendered.
+> Failing to adhere to this typically results in **Duplicate Impression
+> Reporting**.
+
+**Trap 1: Firing a tracking event inside `updated` purely based on conditional
+presence, without a state flag acknowledging it fired.**
+
+**Don't:**
+
+```typescript
+override updated(changedProperties: PropertyValues) {
+  if (this.message()?.responseComplete) {
+    this.reportSuggestionsShown();
+  }
+}
+```
+
+**Do:**
+
+```typescript
+private reportedSuggestionsShown = false;
+
+override updated(changedProperties: PropertyValues) {
+  if (!this.reportedSuggestionsShown && this.message()?.responseComplete) {
+    this.reportSuggestionsShown();
+    this.reportedSuggestionsShown = true;
+  }
+}
+```
+
+#### T1-08: Strict Truthiness Checks for Asynchronous Array Data
+
+> **Rule:** Always explicitly check for null or undefined before evaluating the
+> `.length` property of asynchronous array data. Never rely solely on optional
+> chaining length checks for truthiness.
+>
+> **What:** When determining the fallback UI state based on asynchronous array
+> data (like API responses), explicitly check for null/undefined before checking
+> the `.length` property.
+>
+> **Applies To:** Lit components conditionally rendering UI elements based on
+> the loaded state of arrays.
+>
+> **Why:** Checking `array?.length === 0` fails when the array is still
+> `undefined`, because `undefined === 0` resolves to `false`, causing the
+> application to skip rendering the fallback UI during the loading or
+> uninitialized state. Failing to adhere to this typically results in **Missing
+> Fallback UI**.
+
+**Trap 1: Relying on optional chaining length checks to represent empty or
+missing data.**
+
+**Don't:**
+
+```typescript
+if (this.repoLabels?.length === 0) {
+  return this.renderDefaultParameterInputField();
+}
+```
+
+**Do:**
+
+```typescript
+if (!this.repoLabels || this.repoLabels.length === 0) {
+  return this.renderDefaultParameterInputField();
+}
+```
+
+#### T1-09: Pre-computing Derived State in Lit Lifecycle Methods
+
+> **Rule:** Always pre-compute derived state within the `willUpdate` lifecycle
+> method or a dedicated observer. Never process strings or execute heavy array
+> computations directly inside the `render()` loop.
+>
+> **What:** Avoid processing strings or doing heavy array computations directly
+> inside the `render()` method or helper template methods. Instead, reactively
+> compute derived state (e.g., parsing a string into an array) within the
+> `willUpdate` lifecycle method or a dedicated observer.
+>
+> **Applies To:** Lit components dealing with data transformation before
+> rendering.
+>
+> **Why:** Dynamic computation inside render cycles degrades performance and
+> muddles template readability. Helper methods that executed `.split()` and
+> regex operations on strings were running repeatedly on every re-render.
+> Failing to adhere to this typically results in **Redundant Computations**.
+
+**Trap 1: A helper method called in `render()` that parses raw strings into
+arrays on every invocation.**
+
+**Don't:**
+
+```typescript
+private getParameters(): string[] {
+  if (this.parameters) return this.parameters;
+  if (this.parameterStr?.trim()) {
+    return this.parameterStr.trim().split(/\s+/);
+  }
+  return [];
+}
+
+render() {
+  const params = this.getParameters();
+  // ...
+}
+```
+
+**Do:**
+
+```typescript
+// Handle the calculation inside `willUpdate` reacting to changes in `this.parameterStr`
+willUpdate(changedProperties: PropertyValues) {
+  if (changedProperties.has('parameterStr')) {
+    this.parameters = this.parameterStr?.trim() ? this.parameterStr.trim().split(/\s+/) : [];
+  }
+}
+```
+
+#### T1-10: Returning `nothing` for Empty Templates in Lit
+
+> **Rule:** Must explicitly return the `nothing` sentinel value instead of an
+> empty template literal when rendering conditionally empty elements.
+>
+> **What:** In Lit templates, explicitly return the `nothing` sentinel value
+> instead of an empty template literal when rendering conditionally empty
+> elements.
+>
+> **Applies To:** Lit components, specifically conditional rendering blocks.
+>
+> **Why:** Historically, empty template instances (html``) were returned for
+> false conditions, which created unnecessary markers in the DOM, slightly
+> degrading rendering efficiency and DOM cleanliness. Failing to adhere to this
+> typically results in **DOM Clutter**.
+
+**Trap 1: Using an empty template literal or omitting a return when a condition
+fails in the template.**
+
+**Don't:**
+
+```typescript
+// BAD: Returning empty template
+render() {
+  if (!this.show) return html``;
+  return html`<div>Content</div>`;
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Returning nothing
+import {nothing} from 'lit';
+
+render() {
+  if (!this.show) return nothing;
+  return html`<div>Content</div>`;
+}
+```
+
+#### T1-11: Single-Pass Initialization of Reactive Properties
+
+> **Rule:** Always initialize base reactive properties during construction or
+> via bound property updates. Never use the `firstUpdated` lifecycle hook for
+> initial assignment.
+>
+> **What:** Base reactive properties must be initialized during construction or
+> via bound property updates rather than using the `firstUpdated` lifecycle
+> hook, which triggers a redundant second render cycle.
+>
+> **Applies To:** Lit components, specifically lifecycle hooks (`constructor`,
+> `willUpdate`, `firstUpdated`).
+>
+> **Why:** Assigning derived URL states inside `firstUpdated` triggered
+> immediate, unnecessary re-renders, hurting initial paint performance. Failing
+> to adhere to this typically results in **Redundant Re-rendering**.
+
+**Trap 1: Assigning values to `@state()` or `@property()` fields inside the
+`firstUpdated` hook.**
+
+**Don't:**
+
+```typescript
+// BAD: Triggers a second render cycle immediately after the first
+override firstUpdated() {
+  this.hostUrl = window.location.origin;
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Reactively bound to updates before render
+override willUpdate(changedProperties: PropertyValues) {
+  if (!this.hostUrl) {
+    this.hostUrl = window.location.origin;
+  }
+}
+```
+
+**Exceptions:** Properties that strictly depend on measuring DOM dimensions or
+child element readiness after layout.
+
+#### T1-12: Guarding Asynchronous State Updates Post-Disconnection
+
+> **Rule:** Always explicitly verify `this.isConnected` before executing
+> asynchronous tasks or debounced updates. Never execute callbacks that mutate
+> state during DOM teardown.
+>
+> **What:** Components that schedule asynchronous tasks or debounced updates
+> must explicitly verify `this.isConnected` before executing work to prevent
+> mutations during DOM teardown.
+>
+> **Applies To:** Event handlers, async callbacks, and debounced routines in Lit
+> web components.
+>
+> **Why:** Property updates triggered `willUpdate` hooks even after
+> `disconnectedCallback` had run, scheduling new background tasks for components
+> that were no longer attached to the document. Failing to adhere to this
+> typically results in **Memory Leaks**.
+
+**Trap 1: Executing timeout handlers or asynchronous DOM updates without
+confirming the component remains in the DOM structure.**
+
+**Don't:**
+
+```typescript
+// BAD: Running debounced task without verifying connection
+updateSuggestions() {
+  this.scheduleDebounceTask();
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Short-circuiting if disconnected
+updateSuggestions() {
+  if (!this.isConnected) return;
+  this.scheduleDebounceTask();
+}
+```
+
+#### T1-13: Use Lit classMap Directive for Conditional CSS Classes
+
+> **Rule:** Must use Lit's `classMap` directive for managing multiple
+> conditional CSS classes. Never use manual string concatenation or ternary
+> chaining within class attributes.
+>
+> **What:** Use Lit's `classMap` directive for managing multiple conditional CSS
+> classes rather than manual string interpolation.
+>
+> **Applies To:** Lit component templates rendering conditional classes based on
+> component state.
+>
+> **Why:** Historically, manual string concatenation for multiple conditional
+> CSS classes led to messy, error-prone template structures that were difficult
+> to read and maintain. Failing to adhere to this typically results in
+> **Unreadable/Error-Prone Templates**.
+
+**Trap 1: Concatenating ternary operators inside the `class` attribute string of
+a Lit HTML template.**
+
+**Don't:**
+
+```html
+class="context-chip ${this.isSuggestion ? 'suggested-chip' : ''} ${this.isCustomAction ? 'custom-action-chip' : ''}"
+```
+
+**Do:**
+
+```html
+class=${classMap({'context-chip': true, 'suggested-chip': this.isSuggestion, 'custom-action-chip': this.isCustomAction})}
+```
+
+#### T1-14: Strict Lit State Encapsulation
+
+> **Rule:** Always isolate internal component variables that drive UI re-renders
+> using the `@state` decorator. Never use `@property` for internal state that is
+> not intended to be configured via HTML attributes.
+>
+> **What:** Internal component variables that drive UI re-renders but are not
+> intended to be configured via HTML attributes must use the `@state` decorator
+> instead of `@property`.
+>
+> **Applies To:** All Lit-based Web Components.
+>
+> **Why:** Component logic was exposing internal data retrieval statuses (like
+> loading state or fetched lists) as public `@property` attributes. This
+> polluted the component's public API surface and allowed external DOM
+> manipulation to improperly overwrite internal component state. Failing to
+> adhere to this typically results in **State Leakage**.
+
+**Trap 1: Using the `@property` decorator for variables that represent internal
+component state (like data lists or loading spinners).**
+
+**Don't:**
+
+```typescript
+// BAD: Internal state exposed as an attribute
+@property({type: Boolean})
+_loading = true;
+
+@property({type: Array})
+submitRequirements?: SubmitRequirementInfo[];
+```
+
+**Do:**
+
+```typescript
+// GOOD: Internal state isolated using @state
+@state()
+loading = true;
+
+@state()
+submitRequirements?: SubmitRequirementInfo[];
+```
+
+#### T1-15: Declarative Component Visibility Toggling
+
+> **Rule:** Always handle conditional rendering of DOM elements using Lit's
+> declarative `when` or `nothing` directives. Never dynamically apply CSS
+> classes that set `display: none` to toggle visibility.
+>
+> **What:** Conditional rendering of DOM elements must be handled using Lit's
+> declarative `when` or `nothing` directives within the HTML template, rather
+> than dynamically applying CSS classes that set `display: none`.
+>
+> **Applies To:** All Lit templates, particularly for loading states and
+> conditional sections.
+>
+> **Why:** Loading states were previously managed by dynamically assigning a
+> `.loading` CSS class to an element, which relied on external stylesheet rules
+> to hide/show the node. This made the UI state harder to reason about and
+> bypassed Lit's native DOM reconciliation. Failing to adhere to this typically
+> results in **Layout Shifts / DOM Bloat**.
+
+**Trap 1: Controlling element visibility using CSS class conditionals.**
+
+**Don't:**
+
+```typescript
+// BAD: CSS-driven visibility
+render() {
+  return html`
+    <table class="${this.loading ? 'loading' : ''}">
+      <!-- rows -->
+    </table>
+  `;
+}
+// Relying on: .loading #target { display: none; }
+```
+
+**Do:**
+
+```typescript
+// GOOD: Declarative Lit rendering directives
+render() {
+  return html`
+    <tbody>
+      ${when(
+        this.loading,
+        () => html`<tr><td>Loading...</td></tr>`,
+        () => html`<!-- Render data rows -->`
+      )}
+    </tbody>
+  `;
+}
+```
+
+#### T1-16: Immutable Lit Form State Resets
+
+> **Rule:** Always assign a newly constructed object reference to the state
+> property when resetting form state or clearing dialog inputs. Never mutate the
+> existing object's properties in-place.
+>
+> **What:** When resetting form state or clearing dialog inputs in Lit, assign a
+> newly constructed object reference to the state property rather than mutating
+> the existing object's properties in-place.
+>
+> **Applies To:** Lit form components and dialogs with complex object state
+> (`@state`).
+>
+> **Why:** Form dialogs failed to clear properly after creating a new item
+> because the state object was mutated in place or partially reset, resulting in
+> stale UI data where fields appeared populated but submitted empty values.
+> Failing to adhere to this typically results in **Stale UI Data**.
+
+**Trap 1: Mutating form state fields directly without triggering a reference
+change for Lit to detect.**
+
+**Don't:**
+
+```typescript
+// BAD: In-place mutation does not consistently trigger a full re-render
+handleCreateCancel() {
+  this.newRequirement.name = '';
+  this.newRequirement.description = '';
+  this.dialog.close();
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Provide a new object reference to trigger full reactive updates
+private getEmptyRequirement() {
+  return { name: '', description: '' };
+}
+
+handleCreateCancel() {
+  this.newRequirement = this.getEmptyRequirement();
+  this.dialog.close();
+}
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T6 | API Integration & Error Handling - *Fetching asynchronous
+    data structures via REST clients dictates Lit component reactive loading
+    states and rendering fallbacks.*
+*   **Downstream:** T5 | CSS Architecture & Design System Consistency - *Lit
+    template directives like `classMap` dynamically consume centralized
+    structural CSS classes and shared UI styles.*
+*   **Downstream:** T7 | AI Context & Telemetry Payload Optimization - *Lit
+    lifecycle methods like `updated()` natively trigger telemetry interaction
+    payloads that require idempotent guards to prevent data bloat.*
+
+## Chapter: TypeScript Strictness & Type Safety
+
+**Context:** This domain governs the strict enforcement of TypeScript type
+safety by explicitly forbidding unsafe casting and blanket compiler
+suppressions. It mandates precise component modeling, strict interface
+adherence, and proper access modifiers to eliminate silent runtime failures and
+unverified states.
+
+### Summary
+
+| Rule ID   | Principle / Constraint           | Priority | Primary Symptom /  |
+:           :                                  :          : Trap               :
+| :-------- | :------------------------------- | :------- | :----------------- |
+| **T2-01** | Targeted Type Casting over Broad | High     | Suppressing all    |
+:           : Error Suppression                :          : TypeScript         :
+:           :                                  :          : compiler errors on :
+:           :                                  :          : a line just to     :
+:           :                                  :          : bypass a private   :
+:           :                                  :          : visibility check   :
+:           :                                  :          : in a unit test.    :
+| **T2-02** | Removal of Underscore Prefixes   | Medium   | Prefixing reactive |
+:           : for Reactive Properties          :          : Lit properties     :
+:           :                                  :          : with an underscore :
+:           :                                  :          : to denote private  :
+:           :                                  :          : state.             :
+| **T2-03** | Elimination of TypeScript        | High     | Suppressing the    |
+:           : Compiler Suppressions in Unit    :          : compiler error to  :
+:           : Tests                            :          : mutate a private   :
+:           :                                  :          : property in the    :
+:           :                                  :          : test setup.        :
+| **T2-04** | Utilizing TypeScript Utility     | High     | Constructing an    |
+:           : Types over Unsafe Casting        :          : object with        :
+:           :                                  :          : missing properties :
+:           :                                  :          : and masking the    :
+:           :                                  :          : error by casting   :
+:           :                                  :          : it as the full     :
+:           :                                  :          : interface type.    :
+| **T2-05** | Prototype Methods over Arrow     | Medium   | Using an arrow     |
+:           : Function Properties              :          : function assigned  :
+:           :                                  :          : to a variable      :
+:           :                                  :          : inside the class   :
+:           :                                  :          : body.              :
+| **T2-06** | Elimination of Unsafe `any` Type | High     | Suppressing        |
+:           : Casts                            :          : TypeScript errors  :
+:           :                                  :          : or forcibly        :
+:           :                                  :          : casting objects to :
+:           :                                  :          : `any` when dynamic :
+:           :                                  :          : types don't        :
+:           :                                  :          : strictly align.    :
+| **T2-07** | Testing Private State            | Medium   | Forcibly accessing |
+:           : Encapsulation Rules              :          : class internals    :
+:           :                                  :          : using string-keyed :
+:           :                                  :          : bracket notation   :
+:           :                                  :          : in test files.     :
+| **T2-08** | Eliminate Unsafe 'any' Type      | Medium   | Casting function   |
+:           : Casting in Test Stubs            :          : arguments to `any` :
+:           :                                  :          : within a mock's    :
+:           :                                  :          : custom callback    :
+:           :                                  :          : function.          :
+| **T2-09** | Suppression of Private Member    | Medium   | Casting the class  |
+:           : Access in Tests via              :          : reference or       :
+:           : @ts-expect-error                 :          : global object to   :
+:           :                                  :          : `any` to bypass    :
+:           :                                  :          : TypeScript's       :
+:           :                                  :          : visibility and     :
+:           :                                  :          : presence checks.   :
+| **T2-10** | Enforce Type Contracts Over      | Medium   | Adding             |
+:           : Redundant Runtime Checks         :          : `.filter(Boolean)` :
+:           :                                  :          : or explicit        :
+:           :                                  :          : `undefined` checks :
+:           :                                  :          : on an array that   :
+:           :                                  :          : is strictly typed  :
+:           :                                  :          : as containing only :
+:           :                                  :          : defined objects.   :
+| **T2-11** | Explicit TypeScript Access       | Medium   | Prefixing internal |
+:           : Modifiers                        :          : class methods or   :
+:           :                                  :          : properties with an :
+:           :                                  :          : underscore while   :
+:           :                                  :          : leaving them       :
+:           :                                  :          : functionally       :
+:           :                                  :          : public.            :
+| **T2-12** | Idiomatic Boolean Casting        | Medium   | Casting a          |
+:           :                                  :          : potentially        :
+:           :                                  :          : undefined boolean  :
+:           :                                  :          : to a strict        :
+:           :                                  :          : boolean using the  :
+:           :                                  :          : nullish coalescing :
+:           :                                  :          : operator.          :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T2-01: Targeted Type Casting over Broad Error Suppression
+
+> **Rule:** Always use targeted type casts (e.g., `as any`) to bypass specific
+> visibility constraints in unit tests rather than silencing the entire line
+> with `@ts-expect-error`.
+>
+> **What:** Replace blanket `@ts-expect-error` directives with targeted type
+> casts (e.g., `(element as any)`) when attempting to access private component
+> methods or properties in unit tests.
+>
+> **Applies To:** TypeScript unit tests interacting with encapsulated component
+> logic.
+>
+> **Why:** Using `// @ts-expect-error` suppresses all TypeScript errors on the
+> subsequent line. Historically, this masked actual bugs like typos in test
+> assertions (e.g., a typo in `assert.isFalse`), rendering the tests unreliable.
+> Failing to adhere to this typically results in **Masked Bugs / Silent
+> Failures**.
+
+**Trap 1: Suppressing all TypeScript compiler errors on a line just to bypass a
+private visibility check in a unit test.**
+
+**Don't:**
+
+```typescript
+// BAD: Masks all TS errors, including typos in the assert statement.
+// @ts-expect-error
+assert.isFalse(element.hasAiComments());
+```
+
+**Do:**
+
+```typescript
+// GOOD: Bypasses visibility selectively while maintaining strict type checking on the assertion.
+assert.isFalse((element as any).hasAiComments());
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-02: Removal of Underscore Prefixes for Reactive Properties
+
+> **Rule:** Never use leading underscores for Lit element properties; strictly
+> enforce private state using TypeScript access modifiers.
+>
+> **What:** Do not use leading underscores for Lit element properties. Rely on
+> TypeScript `private` access modifiers to encapsulate internal state instead of
+> naming conventions.
+>
+> **Applies To:** All LitElement `@property` and `@state` declarations.
+>
+> **Why:** Leading underscores were heavily used in the legacy Polymer
+> implementation to denote privacy, but they violate current TypeScript
+> strictness standards and style guidelines for the modernized PolyGerrit UI.
+> Failing to adhere to this typically results in **Style Guide Violation**.
+
+**Trap 1: Prefixing reactive Lit properties with an underscore to denote private
+state.**
+
+**Don't:**
+
+```typescript
+@property({type: String})
+_passwordUrl: string | null = null;
+```
+
+**Do:**
+
+```typescript
+@property({type: String})
+passwordUrl: string | null = null;
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-03: Elimination of TypeScript Compiler Suppressions in Unit Tests
+
+> **Rule:** Must never bypass compiler checks to modify test internals;
+> explicitly elevate visibility of the required property and document the
+> exception.
+>
+> **What:** Unit tests must not bypass compiler checks using @ts-expect-error to
+> test internal logic. Instead, widen the visibility of the target property or
+> method from private to public/internal, and document it with a comment.
+>
+> **Applies To:** TypeScript unit test suites and component class files (e.g.,
+> Lit components).
+>
+> **Why:** Developers were using @ts-expect-error directives to suppress
+> compiler errors when assigning or calling private component members in tests.
+> This masked actual type regressions from the TS compiler. Failing to adhere to
+> this typically results in **Type Safety Bypass**.
+
+**Trap 1: Suppressing the compiler error to mutate a private property in the
+test setup.**
+
+**Don't:**
+
+```typescript
+// In test file:
+// @ts-expect-error
+element.docsBaseUrl = 'https://docs.com/';
+// @ts-expect-error
+assert.equal(element.computeHelpUrl(), '...');
+```
+
+**Do:**
+
+```typescript
+// In component file:
+// private but used in test
+public docsBaseUrl = '';
+
+// In test file (no suppressions):
+element.docsBaseUrl = 'https://docs.com/';
+assert.equal(element.computeHelpUrl(), '...');
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-04: Utilizing TypeScript Utility Types over Unsafe Casting
+
+> **Rule:** Always construct precise subsets of interfaces using utility types
+> like `Pick<T, K>` rather than forcibly blinding the compiler via `as Type`.
+>
+> **What:** When creating objects that fulfill only a specific subset of an
+> interface's requirements, construct the correct type signature using
+> TypeScript utility types (e.g., Pick<T, K>) rather than using 'as Type' type
+> assertions to blind the compiler.
+>
+> **Applies To:** TypeScript data transformations, API response mapping, and
+> component state variables.
+>
+> **Why:** A subset of a LabelDefinitionInfo object was generated and
+> aggressively typed using `as LabelDefinitionInfo`. This misled consumers of
+> the data about which properties were actually populated, creating potential
+> runtime hazards. Failing to adhere to this typically results in **Unsafe Type
+> Assertion**.
+
+**Trap 1: Constructing an object with missing properties and masking the error
+by casting it as the full interface type.**
+
+**Don't:**
+
+```typescript
+const partial = {
+  name: 'LabelName',
+  values: { '+1': '' }
+} as LabelDefinitionInfo;
+```
+
+**Do:**
+
+```typescript
+const partial: Pick<LabelDefinitionInfo, 'name'> & Pick<LabelDefinitionInfo, 'values'> = {
+  name: 'LabelName',
+  values: { '+1': '' }
+};
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-05: Prototype Methods over Arrow Function Properties
+
+> **Rule:** Always define class behaviors as standard prototype methods to avoid
+> the excess instantiation overhead caused by arrow function properties.
+>
+> **What:** Define class behaviors using standard class methods rather than
+> assigning arrow functions as class properties to optimize memory usage.
+>
+> **Applies To:** TypeScript class definitions across the application
+> (Providers, Services, Models).
+>
+> **Why:** Assigning arrow functions directly as class properties caused a new
+> instance of the function to be created in memory for every instantiation of
+> the class, unnecessarily increasing memory overhead. Failing to adhere to this
+> typically results in **Excessive Memory Overhead**.
+
+**Trap 1: Using an arrow function assigned to a variable inside the class
+body.**
+
+**Don't:**
+
+```typescript
+export class LabelSuggestionsProvider {
+  getSuggestions = (
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> => {
+    // logic
+  };
+}
+```
+
+**Do:**
+
+```typescript
+export class LabelSuggestionsProvider {
+  getSuggestions(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    // logic
+  }
+}
+```
+
+**Exceptions:** Arrow functions are acceptable if the method is passed around as
+a callback and strictly requires preserving the `this` lexical binding without
+manual `.bind(this)`.
+
+--------------------------------------------------------------------------------
+
+#### T2-06: Elimination of Unsafe `any` Type Casts
+
+> **Rule:** Never use the `any` type; enforce strict bounds using concrete
+> interfaces or rely on `unknown` for safe downcasting.
+>
+> **What:** The `any` type must be strictly avoided. Use explicit interfaces,
+> strict types, or `unknown` for downcasting, eliminating `@ts-expect-error` and
+> `@typescript-eslint/no-explicit-any`.
+>
+> **Applies To:** Global TypeScript codebase, particularly component state
+> assignments, plugin configurations, and test setups.
+>
+> **Why:** Developer convenience led to pervasive use of `any` type casting,
+> bypassing the compiler and masking structural mismatches that caused uncaught
+> runtime exceptions when interfaces evolved. Failing to adhere to this
+> typically results in **Type Erasure**.
+
+**Trap 1: Suppressing TypeScript errors or forcibly casting objects to `any`
+when dynamic types don't strictly align.**
+
+**Don't:**
+
+```typescript
+// BAD: Bypassing type safety
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+.then((element: any) => {
+  assert.strictEqual(element, module);
+})
+```
+
+**Do:**
+
+```typescript
+// GOOD: Using unknown or explicit interfaces
+.then((element: unknown) => {
+  assert.strictEqual(element, module);
+})
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-07: Testing Private State Encapsulation Rules
+
+> **Rule:** Must never pierce class boundaries using bracket notation
+> (`['property']`) in tests; natively expose and document properties required
+> for testing.
+>
+> **What:** Do not bypass private property access via bracket notation
+> (`element['privateProp']`) in tests. If a property must be exposed for
+> testing, remove the `private` modifier and document it with a standard
+> comment.
+>
+> **Applies To:** Component state definitions and their respective test suites.
+>
+> **Why:** Engineers were circumventing class lexical scope constraints by using
+> bracket notation to set private component states in tests. This violated the
+> TypeScript style guide and evaded static analysis tools. Failing to adhere to
+> this typically results in **Encapsulation Violation**.
+
+**Trap 1: Forcibly accessing class internals using string-keyed bracket notation
+in test files.**
+
+**Don't:**
+
+```typescript
+// BAD: Bypassing private scope
+element['stages'] = [{ condition: 'status:open' }];
+```
+
+**Do:**
+
+```typescript
+// GOOD: Remove private, add explicit documentation, and use dot notation
+// In component:
+@state() // private but used in tests
+stages: Stage[] = [];
+
+// In test:
+element.stages = [{ condition: 'status:open' }];
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-08: Eliminate Unsafe 'any' Type Casting in Test Stubs
+
+> **Rule:** Never substitute actual types with `any` in mock definitions;
+> enforce accurate mock function signatures or use `unknown`.
+>
+> **What:** Avoid using the `any` type when defining function arguments in stub
+> callbacks (e.g., Sinon `callsFake`). Use the actual mocked type or `unknown`.
+>
+> **Applies To:** Sinon stubs and mock definitions within unit tests.
+>
+> **Why:** Using the 'any' type bypassed the TypeScript compiler's type
+> checking, allowing signature mismatches between the mock and the actual
+> implementation to silently pass. Failing to adhere to this typically results
+> in **Silent Type Mismatches**.
+
+**Trap 1: Casting function arguments to `any` within a mock's custom callback
+function.**
+
+**Don't:**
+
+```typescript
+sinon.stub(Obj, 'method').callsFake(function (this: Obj, account: any) { ... })
+```
+
+**Do:**
+
+```typescript
+sinon.stub(Obj, 'method').callsFake(function (this: Obj, account: unknown) { ... })
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-09: Suppression of Private Member Access in Tests via @ts-expect-error
+
+> **Rule:** Always apply `@ts-expect-error` to suppress targeted visibility
+> issues in mocks rather than annihilating all type validation by casting to
+> `any`.
+>
+> **What:** When accessing private or static members in unit tests for mocking
+> purposes, use the `@ts-expect-error` directive instead of casting the entire
+> class or object to `any`.
+>
+> **Applies To:** Unit test setup scripts needing to stub private, protected, or
+> unexported properties on classes or global objects.
+>
+> **Why:** Casting objects to 'any' completely stripped all type checking for
+> subsequent operations. Utilizing `@ts-expect-error` maintains the type
+> context, ensuring the test fails at compile-time if the underlying property's
+> visibility or type changes in the future. Failing to adhere to this typically
+> results in **Complete Type Loss**.
+
+**Trap 1: Casting the class reference or global object to `any` to bypass
+TypeScript's visibility and presence checks.**
+
+**Don't:**
+
+```typescript
+// BAD: Overrides all type checking
+const libLoader = (GrImageViewer as any).libLoader;
+(window as any).resemble = sinon.stub();
+```
+
+**Do:**
+
+```typescript
+// GOOD: Suppresses visibility error but retains underlying type
+// @ts-expect-error
+const libLoader = GrImageViewer.libLoader;
+// @ts-expect-error
+window.resemble = sinon.stub();
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-10: Enforce Type Contracts Over Redundant Runtime Checks
+
+> **Rule:** Must never litter code with redundant falsy checks for
+> arrays/objects explicitly typed as defined; fix non-compliant test data
+> upstream.
+>
+> **What:** Do not add redundant runtime filtering or null-checks for conditions
+> that the TypeScript definitions explicitly forbid. Fix the upstream source or
+> test data instead.
+>
+> **Applies To:** Business logic and data processing on strictly typed models.
+>
+> **Why:** Adding defensive runtime checks for strictly-typed data degraded
+> readability and masked underlying flaws in test setups that were passing
+> invalid, poorly-mocked data into functions. Failing to adhere to this
+> typically results in **Masked Upstream Bugs / Cluttered Logic**.
+
+**Trap 1: Adding `.filter(Boolean)` or explicit `undefined` checks on an array
+that is strictly typed as containing only defined objects.**
+
+**Don't:**
+
+```typescript
+// Method strictly accepts: changes: ChangeInfo[]
+async sync(changes: ChangeInfo[]) {
+  // BAD: Redundant runtime check
+  const validChanges = changes.filter(c => c && isChangeInfo(c));
+  const basicChanges = new Map(validChanges.map(c => [c.id, c]));
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Trust the contract and fix failing tests upstream
+async sync(changes: ChangeInfo[]) {
+  const basicChanges = new Map(changes.map(c => [c.id, c]));
+}
+```
+
+**Exceptions:** External payloads lacking robust typing across integration
+boundaries.
+
+--------------------------------------------------------------------------------
+
+#### T2-11: Explicit TypeScript Access Modifiers
+
+> **Rule:** Always codify class visibility through strict `private`,
+> `protected`, or `public` modifiers instead of falling back on leading
+> underscore conventions.
+>
+> **What:** Class members and methods intended for internal use must be enforced
+> using the TypeScript `private` access modifier rather than relying on the
+> legacy `_` (underscore) naming convention.
+>
+> **Applies To:** TypeScript classes and Web Component definitions.
+>
+> **Why:** The codebase contained legacy properties like `_loading` and methods
+> without explicit access modifiers, which failed to utilize the TypeScript
+> compiler to prevent external dependencies from coupling to internal
+> implementation details. Failing to adhere to this typically results in
+> **Broken Encapsulation**.
+
+**Trap 1: Prefixing internal class methods or properties with an underscore
+while leaving them functionally public.**
+
+**Don't:**
+
+```typescript
+// BAD: Relying on naming conventions for privacy
+@state()
+_loading = true;
+
+renderBoolean() { ... }
+```
+
+**Do:**
+
+```typescript
+// GOOD: Enforcing privacy with TypeScript modifiers
+@state()
+private loading = true;
+
+private renderCheckmark() { ... }
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-12: Idiomatic Boolean Casting
+
+> **Rule:** Always cast optional or truthy/falsy values using the idiomatic
+> double-negation operator (`!!`) rather than the nullish coalescing operator
+> (`?? false`).
+>
+> **What:** To cast an optionally undefined or truthy/falsy value to a strict
+> boolean, use the double-negation operator (`!!`) instead of the nullish
+> coalescing operator (`?? false`).
+>
+> **Applies To:** TypeScript logic handling optional object properties or API
+> responses.
+>
+> **Why:** During permission evaluation, `access?.is_owner ?? false` was flagged
+> during review as unidiomatic, and the codebase was aligned to use standard
+> double-negation for boolean casts. Failing to adhere to this typically results
+> in **Unidiomatic Code**.
+
+**Trap 1: Casting a potentially undefined boolean to a strict boolean using the
+nullish coalescing operator.**
+
+**Don't:**
+
+```typescript
+// BAD: Verbose and unidiomatic casting
+this.isProjectOwner = access?.is_owner ?? false;
+```
+
+**Do:**
+
+```typescript
+// GOOD: Concise, standard boolean cast
+this.isProjectOwner = !!access?.is_owner;
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T1 | Lit Framework Idioms & State Encapsulation - *TypeScript
+    strictness principles govern the visibility and structural integrity of Lit
+    component state variables and properties.*
+*   **Downstream:** T3 | Hermetic Testing & Visual Regression - *Strict typing
+    directly dictates the syntax and permitted mocking strategies for test stubs
+    and fixtures.*
+
+## Chapter: Hermetic Testing & Visual Regression
+
+**Context:** This chapter mandates the strict isolation of unit tests,
+comprehensive visual validation using full shadow DOM snapshots, and the
+centralization of test data generation to guarantee hermetic execution and
+prevent false-positive assertions.
+
+### Summary
+
+| Rule ID   | Principle /        | Priority | Primary Symptom / Trap           |
+:           : Constraint         :          :                                  :
+| :-------- | :----------------- | :------- | :------------------------------- |
+| **T3-01** | Strict DOM Query   | High     | Querying for an element and      |
+:           : Assertions in      :          : manually asserting its existence :
+:           : Testing            :          : using an assertion library.      :
+| **T3-02** | Shadow DOM Event   | Medium   | Relying exclusively on the       |
+:           : Property Fallbacks :          : element property which may       :
+:           : for Tests          :          : remain empty in test fixtures.   :
+| **T3-03** | Comprehensive      | High     | Checking the `length` of         |
+:           : Shadow DOM         :          : elements matching a query        :
+:           : Snapshot           :          : selector to verify UI rendering. :
+:           : Assertions         :          :                                  :
+| **T3-04** | Centralized Test   | Medium   | Manually creating partial data   |
+:           : Data Generation    :          : structures in tests and using    :
+:           : via Factory        :          : type casting to satisfy the      :
+:           : Helpers            :          : compiler.                        :
+| **T3-05** | Isolation of       | Medium   | Adding a visualDiff snapshot     |
+:           : Visual Regression  :          : assertion inside standard        :
+:           : Tests              :          : logical unit tests.              :
+| **T3-06** | Awaiting           | High     | Triggering a UI update and       |
+:           : Asynchronous       :          : immediately asserting on the     :
+:           : Render Cycles      :          : resulting DOM without waiting    :
+:           : Before Assertions  :          : for the Lit rendering engine     :
+:           :                    :          : lifecycle.                       :
+| **T3-07** | DRY Centralization | Medium   | Duplicating 5+ lines of UI       |
+:           : of Shared Test     :          : interaction setup across         :
+:           : Setup Logic        :          : multiple test cases.             :
+| **T3-08** | Avoid              | Medium   | Assigning a random, dynamic      |
+:           : Monkey-Patching    :          : property to the component being  :
+:           : Test-Only          :          : tested to track mock responses   :
+:           : Properties on      :          : or internal states.              :
+:           : Component          :          :                                  :
+:           : Instances          :          :                                  :
+| **T3-09** | Explicit Mocking   | High     | Allowing the component to        |
+:           : of External        :          : natively load its third-party    :
+:           : Network Assets in  :          : script dependencies during a     :
+:           : Tests              :          : basic unit test fixture setup.   :
+| **T3-10** | Strict Limits on   | High     | Increasing the Mocha test suite  |
+:           : Test Timeouts for  :          : timeout inside a failing         :
+:           : Visual Regressions :          : screenshot test to force it to   :
+:           :                    :          : pass.                            :
+| **T3-11** | Realistic          | High     | Rendering a generic template     |
+:           : Component          :          : without required inputs and      :
+:           : Initialization in  :          : asserting the shadow DOM is      :
+:           : Tests              :          : empty.                           :
+| **T3-12** | Descriptive and    | Medium   | Naming a test based on an        |
+:           : Contextual Test    :          : abstract concept rather than the :
+:           : Case Naming        :          : behavioral condition.            :
+| **T3-13** | Production-Aligned | Medium   | Defining static mock data for    |
+:           : Visual Regression  :          : visual tests that includes       :
+:           : Mock Data          :          : properties normally filtered out :
+:           :                    :          : by the production environment.   :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T3-01: Strict DOM Query Assertions in Testing
+
+> **Rule:** Always use the `queryAndAssert` utility to locate and verify DOM
+> elements simultaneously in unit tests.
+>
+> **What:** Use the `queryAndAssert` utility to locate DOM elements in unit
+> tests instead of using `query` coupled with manual truthiness assertions or
+> optional chaining.
+>
+> **Applies To:** Frontend Web Component testing; specifically LitElement test
+> fixtures interacting with the DOM.
+>
+> **Why:** Using a standard `query` that returns `null` when an element is
+> missing causes cryptic `TypeError`s later in the test execution. Asserting
+> immediately provides clear, fail-fast test reporting. Failing to adhere to
+> this typically results in **Test TypeErrors / Cryptic Failures**.
+
+**Trap 1: Querying for an element and manually asserting its existence using an
+assertion library.**
+
+**Don't:**
+
+```typescript
+const warning = query(element, '.expensiveDiff');
+assert.isOk(warning);
+assert.include(warning.textContent, 'Diff too expensive');
+```
+
+**Do:**
+
+```typescript
+const warning = queryAndAssert(element, '.expensiveDiff');
+assert.include(warning.textContent, 'Diff too expensive');
+```
+
+**Trap 2: Using optional chaining to silently swallow null elements when
+checking class lists.**
+
+**Don't:**
+
+```typescript
+const diffContainer = query(element, '.diffContainer');
+assert.isTrue(diffContainer?.classList.contains('hidden'));
+```
+
+**Do:**
+
+```typescript
+const diffContainer = queryAndAssert(element, '.diffContainer');
+assert.isTrue(diffContainer.classList.contains('hidden'));
+```
+
+#### T3-02: Shadow DOM Event Property Fallbacks for Tests
+
+> **Rule:** Must implement explicit attribute fallbacks when reading custom
+> event properties that fail to synchronize in hermetic test environments.
+>
+> **What:** When handling custom event properties (like `value` from custom
+> `md-outlined-select` components) that fail to reliably synchronize to the
+> target's property in the testing environment, safely fallback to reading the
+> `value` attribute.
+>
+> **Applies To:** Lit element event handlers dealing with custom web components
+> and executed in hermetic testing environments.
+>
+> **Why:** Automated tests simulating user selections in `md-outlined-select`
+> were failing because the testing framework failed to read the value from the
+> event target property, requiring an explicit fallback to the attribute.
+> Failing to adhere to this typically results in **False Negative Test
+> Failures**.
+
+**Trap 1: Relying exclusively on the element property which may remain empty in
+test fixtures.**
+
+**Don't:**
+
+```typescript
+@change=${(e: Event) => {
+  this.selectedLabelForVote = (e.target as HTMLSelectElement).value;
+}}
+```
+
+**Do:**
+
+```typescript
+@change=${(e: Event) => {
+  // TODO: Remove reading from attribute once test env issue is fixed
+  this.selectedLabelForVote =
+    ((e.target as HTMLSelectElement).value ||
+    (e.target as HTMLSelectElement).getAttribute('value')) ?? '';
+}}
+```
+
+#### T3-03: Comprehensive Shadow DOM Snapshot Assertions
+
+> **Rule:** Never use shallow DOM assertions; always validate UI structures
+> using `assert.shadowDom.equal`.
+>
+> **What:** UI component tests must use `assert.shadowDom.equal` to validate the
+> entire rendered structure against an expected HTML snapshot, rather than
+> asserting shallow DOM properties.
+>
+> **Applies To:** Frontend UI unit testing of Lit web components.
+>
+> **Why:** During refactoring, comprehensive DOM checks were accidentally
+> replaced with shallow element counts, creating a blind spot where regressions
+> in structural layout, attributes, and text content could slip past CI. Failing
+> to adhere to this typically results in **Missed UI Regressions**.
+
+**Trap 1: Checking the `length` of elements matching a query selector to verify
+UI rendering.**
+
+**Don't:**
+
+```typescript
+// BAD: Shallow DOM assertion
+const flowElements = element.shadowRoot!.querySelectorAll('.flow');
+assert.equal(flowElements.length, 2);
+```
+
+**Do:**
+
+```typescript
+// GOOD: Full Shadow DOM snapshot assertion
+assert.shadowDom.equal(
+  element,
+  /* HTML */ `
+    <div class="container">
+      <div class="flow">...</div>
+      <div class="flow">...</div>
+    </div>
+  `
+);
+```
+
+#### T3-04: Centralized Test Data Generation via Factory Helpers
+
+> **Rule:** Always construct mock data structures using centralized factory
+> helpers configured with `Partial<T>` overrides.
+>
+> **What:** Avoid manually instantiating large mock data payloads. Utilize
+> centralized factory functions that accept `Partial<T>` and provide sensible
+> defaults, eliminating manual type assertions.
+>
+> **Applies To:** Frontend unit tests and mock data setup blocks.
+>
+> **Why:** Tests repeatedly hardcoded large data objects, making the test suite
+> brittle to schema changes and requiring explicit type casting (`as FlowInfo`)
+> to bypass compiler complaints about missing fields. Failing to adhere to this
+> typically results in **Brittle Tests**.
+
+**Trap 1: Manually creating partial data structures in tests and using type
+casting to satisfy the compiler.**
+
+**Don't:**
+
+```typescript
+// BAD: Manual object creation with casting
+const flow = {
+  uuid: 'flow1',
+  owner: {name: 'owner1'},
+  created: '2025-01-01T10:00:00.000Z' as Timestamp,
+} as FlowInfo;
+```
+
+**Do:**
+
+```typescript
+// GOOD: Using a test data generator with Partial overrides
+const flow = createFlow({
+  uuid: 'flow1',
+  owner: {name: 'owner1', _account_id: 1 as AccountId},
+});
+```
+
+#### T3-05: Isolation of Visual Regression Tests
+
+> **Rule:** Must isolate DOM snapshot and screenshot operations into parallel
+> `_screenshot_test.ts` files.
+>
+> **What:** Visual regression (screenshot) tests utilizing DOM snapshots must
+> reside in a separate `_screenshot_test.ts` file rather than being appended to
+> standard unit test files.
+>
+> **Applies To:** Test directory structure and files utilizing `visualDiff`.
+>
+> **Why:** Combining fast unit tests with slow visual diffing tests bloated unit
+> execution times and complicated testing step separation in the CI pipeline.
+> Failing to adhere to this typically results in **CI Bottleneck**.
+
+**Trap 1: Adding a visualDiff snapshot assertion inside standard logical unit
+tests.**
+
+**Don't:**
+
+*   Putting visual `visualDiff` test blocks into the standard
+    `gr-[component]_test.ts` file alongside business logic tests.
+
+**Do:**
+
+*   Creating a parallel `gr-[component]_screenshot_test.ts` dedicated
+    exclusively to visual assertions.
+
+#### T3-06: Awaiting Asynchronous Render Cycles Before Assertions
+
+> **Rule:** Always `await element.updateComplete` prior to evaluating DOM nodes
+> following a UI state change.
+>
+> **What:** Tests asserting UI state changes must strictly `await
+> element.updateComplete` before querying DOM elements or their properties,
+> avoiding false positive evaluations.
+>
+> **Applies To:** Lit web component unit tests.
+>
+> **Why:** Tests were erroneously passing because asynchronous state updates
+> lacked `await`, causing assertions to execute and pass before the updated
+> component render cycle actually fired. Failing to adhere to this typically
+> results in **False Positive Tests**.
+
+**Trap 1: Triggering a UI update and immediately asserting on the resulting DOM
+without waiting for the Lit rendering engine lifecycle.**
+
+**Don't:**
+
+```typescript
+// BAD: Synchronous assertion after state change
+element.renderInOrder([{path: 'p2'}]);
+assert.equal(reviewStub.callCount, 1);
+```
+
+**Do:**
+
+```typescript
+// GOOD: Awaiting update completion
+await element.renderInOrder([{path: 'p2'}]);
+await element.updateComplete;
+assert.equal(reviewStub.callCount, 1);
+```
+
+#### T3-07: DRY Centralization of Shared Test Setup Logic
+
+> **Rule:** Must extract repeated setup mechanisms and multi-step UI sequences
+> into centralized, shared asynchronous helpers.
+>
+> **What:** Repeated interaction sequences required to establish a test's
+> initial state (e.g., clicking menus, awaiting cycles, querying elements) must
+> be extracted into asynchronous helper functions.
+>
+> **Applies To:** Component test suites with repetitive, multi-step UI
+> interaction setups.
+>
+> **Why:** Tests were repeatedly copying and pasting the multi-step DOM
+> interaction needed to open a modal and fetch an input reference, inflating the
+> codebase and making structural changes painful. Failing to adhere to this
+> typically results in **High Maintenance Burden**.
+
+**Trap 1: Duplicating 5+ lines of UI interaction setup across multiple test
+cases.**
+
+**Don't:**
+
+*   Copying and pasting the same set of element lookups, simulated clicks, and
+    `await element.updateComplete;` lines into every test block.
+
+**Do:**
+
+*   Extracting the setup steps into a shared, typed async helper (e.g., `async
+    function openLinkDialogAndGetInput(): Promise<HTMLInputElement>`) and
+    calling it in each test.
+
+#### T3-08: Avoid Monkey-Patching Test-Only Properties on Component Instances
+
+> **Rule:** Never mutate component instances with custom, untyped tracking
+> properties; track test states using locally scoped variables.
+>
+> **What:** Avoid mutating component instances with test-only custom properties
+> to track state during tests. Utilize locally scoped variables inside the test
+> setup or perform proper stub verification instead.
+>
+> **Applies To:** Unit testing, specifically when stubbing instance methods or
+> external calls.
+>
+> **Why:** Attaching custom test-only properties (like tracking URLs directly on
+> the element) polluted the component object model, bypassed encapsulation, and
+> risked state leakage between unit tests. Failing to adhere to this typically
+> results in **Test State Leakage**.
+
+**Trap 1: Assigning a random, dynamic property to the component being tested to
+track mock responses or internal states.**
+
+**Don't:**
+
+```typescript
+.callsFake(function (this: MyComponent, arg: any) {
+  // BAD: Modifying the instance for testing purposes
+  (this as any).__test_url = arg;
+  return 'data:image...';
+});
+// ... later in the test ...
+assert.equal((element as any).__test_url, expectedUrl);
+```
+
+**Do:**
+
+```typescript
+let generatedUrl: string;
+.callsFake(function (this: MyComponent, arg: unknown) {
+  // GOOD: Using a scoped variable for tracking
+  generatedUrl = arg;
+  return 'data:image...';
+});
+// ... later in the test ...
+assert.equal(generatedUrl, expectedUrl);
+```
+
+#### T3-09: Explicit Mocking of External Network Assets in Tests
+
+> **Rule:** Must completely stub remote library calls or image-fetching
+> mechanisms to execute entirely in isolation.
+>
+> **What:** External libraries that trigger network requests (e.g., fetching
+> scripts or image diffing workers) must be fully mocked in local test setups to
+> prevent 404 network errors and ensure hermetic execution.
+>
+> **Applies To:** Unit testing for components integrating with third-party,
+> dynamically loaded libraries.
+>
+> **Why:** Failure to mock external dependencies caused the test runner to
+> attempt fetching remote or non-existent local assets, resulting in 404 console
+> errors, flaky tests, and significantly slower execution times. Failing to
+> adhere to this typically results in **404 Network Errors**.
+
+**Trap 1: Allowing the component to natively load its third-party script
+dependencies during a basic unit test fixture setup.**
+
+**Don't:**
+
+```typescript
+setup(async () => {
+  // BAD: Component attempts to download external libraries over the network
+  element = await fixture(`<my-viewer></my-viewer>`);
+});
+```
+
+**Do:**
+
+```typescript
+setup(async () => {
+  // GOOD: Mock external library loaders to resolve instantly
+  // @ts-expect-error
+  const libLoader = MyViewer.libLoader;
+  sinon.stub(libLoader, 'getLibrary').resolves();
+
+  // @ts-expect-error
+  window.externalLib = sinon.stub().returns({ ... });
+
+  element = await fixture(`<my-viewer></my-viewer>`);
+});
+```
+
+#### T3-10: Strict Limits on Test Timeouts for Visual Regressions
+
+> **Rule:** Never augment native test timeouts (`this.timeout()`) as a
+> workaround for slow rendering components or unreliable baselines.
+>
+> **What:** Do not artificially inflate test timeouts (e.g., Mocha
+> `this.timeout()`) to mask slow component rendering or flakiness in visual
+> regression tests. Maintain default timeouts.
+>
+> **Applies To:** Screenshot testing and visual regression test suites.
+>
+> **Why:** Artificially extending timeouts allowed significant performance
+> regressions in UI rendering to go unnoticed, as tests would wait excessively
+> long for slow components to stabilize rather than failing fast. Failing to
+> adhere to this typically results in **Masked Performance Regressions**.
+
+**Trap 1: Increasing the Mocha test suite timeout inside a failing screenshot
+test to force it to pass.**
+
+**Don't:**
+
+```typescript
+suite('component screenshot tests', function () {
+  this.timeout(4000);
+  // ... test body ...
+});
+```
+
+**Do:**
+
+```typescript
+suite('component screenshot tests', () => {
+  // Default timeout ensures fast failure if rendering degrades
+  // Update screenshots with CLI commands if baseline legitimately changed
+  // ... test body ...
+});
+```
+
+#### T3-11: Realistic Component Initialization in Tests
+
+> **Rule:** Must initialize required component properties to functional values
+> prior to asserting against the shadow DOM.
+>
+> **What:** Unit tests that assert against a component's shadow DOM must
+> initialize the component with realistic property states, ensuring the DOM is
+> not trivially empty or unrendered.
+>
+> **Applies To:** Lit component tests doing Shadow DOM assertions.
+>
+> **Why:** Asserting against an empty or completely uninitialized DOM resulted
+> in false-positive test passes that failed to verify any actual template or
+> structural logic. Failing to adhere to this typically results in **False
+> Positive Tests**.
+
+**Trap 1: Rendering a generic template without required inputs and asserting the
+shadow DOM is empty.**
+
+**Don't:**
+
+```typescript
+test('render', async () => {
+  await element.updateComplete;
+  assert.shadowDom.equal(element, '');
+});
+```
+
+**Do:**
+
+```typescript
+test('render', async () => {
+  element.name = 'Test';
+  await element.updateComplete;
+  assert.shadowDom.equal(
+    element,
+    `<div><h3 class="title">Test</h3></div>`
+  );
+});
+```
+
+#### T3-12: Descriptive and Contextual Test Case Naming
+
+> **Rule:** Always name test cases with strict, functional "renders [state] if
+> [condition]" descriptions.
+>
+> **What:** Test cases must use descriptive 'renders X if Y' naming structures
+> rather than relying on abstract, ambiguous, or overly technical jargon that
+> lacks functional context.
+>
+> **Applies To:** Unit test descriptions (the string argument in `test()` or
+> `it()`).
+>
+> **Why:** Ambiguous test names using mathematical or overly specific internal
+> jargon made it difficult for developers to deduce the functional intent of the
+> test upon failure without reading the implementation. Failing to adhere to
+> this typically results in **Unclear Test Intent**.
+
+**Trap 1: Naming a test based on an abstract concept rather than the behavioral
+condition.**
+
+**Don't:**
+
+*   test('render attempt ordinal', async () => { ... });
+
+**Do:**
+
+*   test('renders attempt number if not single attempt', async () => { ... });
+
+#### T3-13: Production-Aligned Visual Regression Mock Data
+
+> **Rule:** Must guarantee that mock datasets used in visual regressions bypass
+> data points filtered out by production pipeline logic.
+>
+> **What:** Mock data structures used to drive screenshot/visual regression
+> tests must precisely mirror the data shapes and filtering logic deployed in
+> the production frontend.
+>
+> **Applies To:** Frontend visual regression tests (`*_screenshot_test.ts`) and
+> mock data fixtures.
+>
+> **Why:** Visual tests previously hardcoded reference types that were
+> intentionally stripped out by frontend plugins in production. This resulted in
+> visual tests asserting on UI components that users would never actually see.
+> Failing to adhere to this typically results in **False Positive Tests**.
+
+**Trap 1: Defining static mock data for visual tests that includes properties
+normally filtered out by the production environment.**
+
+**Don't:**
+
+*   Defining test data that bypasses standard application filtering logic to
+    artificially inflate component coverage.
+
+**Do:**
+
+*   Removing filtered mock references from visual test configurations so the
+    test exactly matches the filtered production state.
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T1 | Lit Framework Idioms & State Encapsulation - *Hermetic UI
+    tests strictly rely on accurate Lit lifecycle declarations and asynchronous
+    `updateComplete` chains mapped here.*
+*   **Upstream:** T2 | TypeScript Strictness & Type Safety - *Type-safe factory
+    generators utilizing `Partial<T>` and eliminating `any` casting rely
+    completely on the strictness mandates of this domain.*
+
+## Chapter: Client-Side Performance & Telemetry
+
+**Context:** This theme governs the optimization of client-side performance by
+enforcing strict telemetry on synchronous CPU-bound operations and minimizing
+network latency. It mandates the caching of asynchronous API promises, the
+derivation of state from existing payloads, and the rigid bounding of background
+data requests.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T4-01** | Synchronous Execution     | High     | Assuming a save operation |
+:           : Telemetry Profiling       :          : is only slow due to       :
+:           :                           :          : network requests and      :
+:           :                           :          : leaving synchronous data  :
+:           :                           :          : preparation unmeasured.   :
+| **T4-02** | Elimination of Redundant  | High     | Executing an asynchronous |
+:           : API Data Fetches          :          : API call to pull data     :
+:           :                           :          : that already exists as a  :
+:           :                           :          : property of a currently   :
+:           :                           :          : loaded state object.      :
+| **T4-03** | Promise-Based API Request | High     | Calling the API directly  |
+:           : Caching                   :          : on every query invocation :
+:           :                           :          : without storing the       :
+:           :                           :          : ongoing or resolved       :
+:           :                           :          : request.                  :
+| **T4-04** | Capped Payloads for       | High     | Passing `undefined` or    |
+:           : Autocomplete Suggestion   :          : leaving limit parameters  :
+:           : Requests                  :          : empty for data            :
+:           :                           :          : aggregation endpoints     :
+:           :                           :          : used merely for           :
+:           :                           :          : suggestion dropdowns.     :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T4-01: Synchronous Execution Telemetry Profiling
+
+> **Rule:** Always instrument synchronous, CPU-bound logic with telemetry timers
+> to surface main-thread blocking operations. Never assume UI latency is solely
+> caused by asynchronous network requests.
+>
+> **What:** Instrument synchronous, CPU-bound logic (e.g., object comparison,
+> distance calculations) with telemetry timers to identify main-thread blocking
+> operations, rather than solely profiling asynchronous network calls.
+>
+> **Applies To:** Frontend Performance Profiling; particularly in high-frequency
+> or data-heavy UI actions like draft comment saving and text matching.
+>
+> **Why:** Historically, performance metrics focused heavily on network request
+> times. This hid UI freezes caused by expensive O(n^2) synchronous calculations
+> on the main thread, such as running deep equality checks against large
+> AI-generated patch suggestions. Failing to adhere to this typically results in
+> **Main Thread UI Freezes**.
+
+**Trap 1: Assuming a save operation is only slow due to network requests and
+leaving synchronous data preparation unmeasured.**
+
+**Don't:**
+
+```typescript
+// BAD: Only timing the network request
+const result = await this.restApiService.saveDraft(draft);
+```
+
+**Do:**
+
+```typescript
+// GOOD: Timing CPU-bound data preparation independently
+const fixTimer = this.reporting.getTimer('UpdateDraftComment - isFixSuggestionChanged');
+if (this.isFixSuggestionChanged()) {
+  draft.fix_suggestions = this.getFixSuggestions();
+}
+fixTimer.end();
+
+const networkTimer = this.reporting.getTimer('UpdateDraftComment - network');
+const result = await this.restApiService.saveDraft(draft);
+networkTimer.end();
+```
+
+--------------------------------------------------------------------------------
+
+#### T4-02: Elimination of Redundant API Data Fetches
+
+> **Rule:** Always derive state directly from globally hydrated context objects
+> instead of executing redundant REST API requests.
+>
+> **What:** Derive required state directly from existing context payloads (such
+> as the globally hydrated Change object) rather than making redundant network
+> requests to fetch subsets of identical data.
+>
+> **Applies To:** Frontend components querying repository metadata, permissions,
+> or labels.
+>
+> **Why:** The UI was performing a dedicated REST API call to fetch repository
+> labels, adding UI latency, even though the allowed labels were already
+> hydrated within the `change.permitted_labels` property on the global change
+> object. Failing to adhere to this typically results in **Redundant Network
+> Latency**.
+
+**Trap 1: Executing an asynchronous API call to pull data that already exists as
+a property of a currently loaded state object.**
+
+**Don't:**
+
+```typescript
+// Anti-pattern: Fetching when the data is already there
+this.repoLabels = await this.restApiService.getRepoLabels(change.project);
+```
+
+**Do:**
+
+```typescript
+// Preferred: Mapping from existing context
+const permittedLabels = change.permitted_labels ?? {};
+this.repoLabels = Object.entries(permittedLabels).map(...);
+```
+
+**Exceptions:** If the existing payload is known to be stale or intentionally
+truncated for performance reasons in that specific context.
+
+--------------------------------------------------------------------------------
+
+#### T4-03: Promise-Based API Request Caching
+
+> **Rule:** Must cache the Promise of an API request to prevent redundant
+> network calls during rapid UI events, ensuring errors resolve safely to avoid
+> cache poisoning.
+>
+> **What:** Cache the Promise of an API request to prevent redundant network
+> calls during rapid UI events (e.g., autocomplete typing), and ensure failed
+> requests resolve to a safe fallback (like undefined) so the cache is not
+> poisoned permanently.
+>
+> **Applies To:** Frontend services making API calls based on user input,
+> specifically Autocomplete and Suggestion Providers.
+>
+> **Why:** Fetching data on every keystroke without caching led to spamming the
+> backend API, especially when the data subset being searched was static (e.g.,
+> repository labels) and could be filtered purely on the client side. Failing to
+> adhere to this typically results in **Backend API Spam / High Latency**.
+
+**Trap 1: Calling the API directly on every query invocation without storing the
+ongoing or resolved request.**
+
+**Don't:**
+
+```typescript
+getSuggestions(expression: string) {
+  if (!this.repoName) return Promise.resolve([]);
+  return this.restApiService.getRepoLabels(this.repoName).then(labels => {
+    // filter logic
+  });
+}
+```
+
+**Do:**
+
+```typescript
+getSuggestions(expression: string) {
+  if (!this.repoName) return Promise.resolve([]);
+  if (!this.cachedLabelsPromise) {
+    this.cachedLabelsPromise = this.restApiService
+      .getRepoLabels(this.repoName)
+      .catch(err => {
+        reportingService.error('Provider', err);
+        return undefined; // Ensure caught errors resolve safely
+      });
+  }
+  return this.cachedLabelsPromise.then(labels => {
+    // filter logic
+  });
+}
+```
+
+**Exceptions:** Queries that rely on server-side filtering (e.g., passing the
+user's keystroke to the backend endpoint directly) cannot be cached in this
+manner.
+
+--------------------------------------------------------------------------------
+
+#### T4-04: Capped Payloads for Autocomplete Suggestion Requests
+
+> **Rule:** Never execute unbounded background API queries; always enforce hard
+> payload limits for components like autocomplete and dialogs to prevent UI
+> freezes.
+>
+> **What:** Backend API queries triggered by background UI components (like
+> autocomplete dropdowns or dialog filling) must be strictly bounded to a hard
+> limit to prevent downloading excessive payloads.
+>
+> **Applies To:** API query parameters, specifically REST API requests fetching
+> background data for UI presentation.
+>
+> **Why:** An unbounded query for open changes downloaded over 600kB of data
+> from the network, causing a severe UX lag and freezing the user interface on
+> slower internet connections. Failing to adhere to this typically results in
+> **Network Bottleneck**.
+
+**Trap 1: Passing `undefined` or leaving limit parameters empty for data
+aggregation endpoints used merely for suggestion dropdowns.**
+
+**Don't:**
+
+```typescript
+// BAD: Unbounded fetching for suggestions
+await this.restApiService.getChanges(
+  undefined, // no limit
+  'is:open -age:90d'
+);
+```
+
+**Do:**
+
+```typescript
+// GOOD: Hardcoded maximum to prevent bandwidth exhaustion
+await this.restApiService.getChanges(
+  /* changeNumber=*/ 450,
+  'is:open -age:90d'
+);
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T6 | API Integration & Error Handling - *Defines the core
+    structures of REST API operations and error fallback mechanisms necessary
+    for effective client-side caching.*
+*   **Downstream:** T7 | AI Context & Telemetry Payload Optimization - *Consumes
+    the telemetry pipelines defined here to bound and optimize specialized AI
+    interaction reporting.*
+
+## Chapter: CSS Architecture & Design System Consistency
+
+**Context:** This domain governs the consistent application of styles across the
+UI, mandating the use of content-driven layout techniques, externalized custom
+property configurations for visual assets, and declarative Lit directives over
+imperative inline styling.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T5-01** | Dynamic Sizing for Custom | Medium   | Embedding static pixel    |
+:           : SVG Icons via CSS         :          : values for width and      :
+:           :                           :          : height inside the raw SVG :
+:           :                           :          : literal.                  :
+| **T5-02** | Dynamic Width via         | Medium   | Hardcoding width in       |
+:           : Content-Driven CSS Sizing :          : pixels or percentages to  :
+:           :                           :          : achieve visual uniformity :
+:           :                           :          : despite unpredictable     :
+:           :                           :          : content strings.          :
+| **T5-03** | Declarative Conditional   | Medium   | Controlling visibility by |
+:           : Visibility using Class    :          : binding ternary operators :
+:           : Maps                      :          : to the inline `style`     :
+:           :                           :          : attribute.                :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T5-01: Dynamic Sizing for Custom SVG Icons via CSS
+
+> **Rule:** Always omit hardcoded dimensional attributes from custom SVG markup.
+> Must control icon sizing dynamically using CSS custom properties to ensure
+> cross-context scalability.
+>
+> **What:** Custom SVG icon definitions must not contain hardcoded `width` or
+> `height` attributes within the markup. Dimensions should be omitted from the
+> SVG template and controlled dynamically via CSS custom properties.
+>
+> **Applies To:** Lit icon wrapper components (`gr-icon`) and centralized custom
+> SVG asset files.
+>
+> **Why:** Hardcoded dimensions within SVG source strings forced icons into
+> rigid sizes, preventing them from scaling properly when nested inside generic
+> buttons, dense lists, or varying display contexts. Failing to adhere to this
+> typically results in **Layout Clipping / Unresponsive UI**.
+
+**Trap 1: Embedding static pixel values for width and height inside the raw SVG
+literal.**
+
+**Don't:**
+
+```typescript
+const spark = svg`<svg width="24px" height="24px" viewBox="0 0 960 960">...</svg>`;
+```
+
+**Do:**
+
+```typescript
+const spark = svg`<svg viewBox="0 0 960 960">...</svg>`;
+
+// Within gr-icon CSS:
+// svg {
+//   width: var(--gr-icon-size, 20px);
+//   height: var(--gr-icon-size, 20px);
+// }
+```
+
+--------------------------------------------------------------------------------
+
+#### T5-02: Dynamic Width via Content-Driven CSS Sizing
+
+> **Rule:** Always prefer `width: fit-content` over arbitrary fixed pixel limits
+> for dynamically generated UI cards. Never enforce visual uniformity at the
+> expense of content readability.
+>
+> **What:** Use `width: fit-content` for dynamically generated UI cards rather
+> than uniform fixed widths when content length is variable and unpredictable.
+>
+> **Applies To:** UI component styling, specifically CSS rules for lists of
+> cards or informational blocks.
+>
+> **Why:** Fixed-width cards were initially suggested for uniform alignment, but
+> variable-length rules caused clipping or excessive whitespace. `fit-content`
+> provided a more flexible baseline layout. Failing to adhere to this typically
+> results in **Visual Clipping**.
+
+**Trap 1: Hardcoding width in pixels or percentages to achieve visual uniformity
+despite unpredictable content strings.**
+
+**Don't:**
+
+```css
+/* BAD: Fixed width that might clip */
+.flow-card {
+  width: 300px;
+}
+```
+
+**Do:**
+
+```css
+/* GOOD: Width determined by content */
+.flow-card {
+  width: fit-content;
+}
+```
+
+**Exceptions:** Cases where uniform width is strictly mandated by UX mocks and
+content is truncated gracefully with ellipses.
+
+--------------------------------------------------------------------------------
+
+#### T5-03: Declarative Conditional Visibility using Class Maps
+
+> **Rule:** Must use standard CSS utility classes via Lit's `classMap` directive
+> to toggle element visibility. Never bind ternary operators to inject inline
+> style strings.
+>
+> **What:** Conditional visibility logic must rely on a standard CSS `.hidden`
+> utility class applied via Lit's `classMap` directive, rather than injecting
+> inline style strings.
+>
+> **Applies To:** Component rendering templates (`html` strings) handling
+> dynamically hidden UI states.
+>
+> **Why:** Visibility was frequently controlled via verbose ternary operators
+> injecting hardcoded CSS text into `style` attributes, making code harder to
+> read and reuse. Failing to adhere to this typically results in **Brittle
+> Inline Styling**.
+
+**Trap 1: Controlling visibility by binding ternary operators to the inline
+`style` attribute.**
+
+**Don't:**
+
+```typescript
+// BAD: Inline conditional styles
+<md-icon-button
+  style=${this.supportsHistory ? '' : 'visibility:hidden; pointer-events:none;'}
+>
+```
+
+**Do:**
+
+```typescript
+// GOOD: Using Lit classMap with a dedicated CSS rule
+<md-icon-button
+  class=${classMap({
+    'history-button': true,
+    'hidden': !this.supportsHistory
+  })}
+>
+```
+
+**Exceptions:** Truly dynamic, mathematically computed layout styles (e.g.,
+precise pixel offsets or user-defined color themes).
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T1 | Lit Framework Idioms & State Encapsulation - *Lit
+    directives like `classMap` provide the declarative mechanism required to
+    apply standard CSS utility classes effectively.*
+
+## Chapter: API Integration & Error Handling
+
+**Context:** This chapter governs the resilient integration of frontend logic
+with REST APIs. It establishes strict constraints for maintaining backend
+payload parity, explicitly modeling structural variances, and enforcing
+centralized error handling over localized `try...catch` blocks.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T6-01** | Explicit Server Error     | High     | Firing mutative API       |
+:           : Reporting in REST API     :          : requests without          :
+:           : Calls                     :          : explicitly configuring    :
+:           :                           :          : the fetch client to       :
+:           :                           :          : report server-side HTTP   :
+:           :                           :          : errors.                   :
+| **T6-02** | Strict Frontend-Backend   | Critical | Using a descriptive but   |
+:           : API Payload Parity        :          : incorrect property name   :
+:           :                           :          : in the UI that does not   :
+:           :                           :          : match the exact backend   :
+:           :                           :          : JSON key.                 :
+| **T6-03** | Delegation to Centralized | Medium   | Wrapping API calls in     |
+:           : REST API Error Handlers   :          : verbose catch blocks      :
+:           :                           :          : solely to surface generic :
+:           :                           :          : network failure messages. :
+| **T6-04** | Centralized Error         | Medium   | Wrapping an API call in a |
+:           : Callbacks for API Calls   :          : try/catch block and       :
+:           :                           :          : firing a custom error     :
+:           :                           :          : event on catch alongside  :
+:           :                           :          : null checks.              :
+| **T6-05** | Modeling Inconsistent     | Medium   | Assuming a single payload |
+:           : Backend API Payloads      :          : structure across          :
+:           :                           :          : disparate endpoints and   :
+:           :                           :          : experiencing undefined    :
+:           :                           :          : runtime property access.  :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T6-01: Explicit Server Error Reporting in REST API Calls
+
+> **Rule:** Always enable explicit server error reporting for mutating API
+> requests (PUT, POST, DELETE) to guarantee backend failures reach the UI.
+>
+> **What:** State-mutating REST API calls (PUT, POST, DELETE) must explicitly
+> enable server error reporting in their configuration to ensure backend
+> failures are caught and surfaced to the UI.
+>
+> **Applies To:** REST API service layer (`gr-rest-api-impl.ts`), specifically
+> when migrating to the new `fetch` helper structure.
+>
+> **Why:** During the migration to a modern REST API helper module, API failures
+> for mutating operations could be silently swallowed unless `reportServerError:
+> true` was explicitly passed in the request configuration. Failing to adhere to
+> this typically results in **Silent API Failures**.
+
+**Trap 1: Firing mutative API requests without explicitly configuring the fetch
+client to report server-side HTTP errors.**
+
+**Don't:**
+
+```typescript
+this._restApiHelperNew.fetch({
+  fetchOptions: getFetchOptions({
+    method: HttpMethod.PUT,
+    body: config,
+  }),
+  url,
+});
+```
+
+**Do:**
+
+```typescript
+this._restApiHelperNew.fetch({
+  fetchOptions: getFetchOptions({
+    method: HttpMethod.PUT,
+    body: config,
+  }),
+  url,
+  reportServerError: true,
+});
+```
+
+--------------------------------------------------------------------------------
+
+#### T6-02: Strict Frontend-Backend API Payload Parity
+
+> **Rule:** Must perfectly mirror the exact naming conventions of backend JSON
+> response fields within frontend TypeScript interfaces; never substitute
+> assumed names.
+>
+> **What:** Frontend TypeScript interfaces must perfectly mirror the naming
+> conventions of the backend JSON response fields; UI logic cannot substitute
+> similar or assumed names.
+>
+> **Applies To:** REST API models, data layer mappings, and UI conditionals
+> consuming backend payloads.
+>
+> **Why:** A UI feature designed to hide a component when a diff was too large
+> failed silently in production because the frontend referenced
+> `diffs_too_expensive_to_compute` while a previous implementation or
+> documentation referred to `too_expensive_to_compute`, resulting in
+> `undefined`. Failing to adhere to this typically results in **Silent UI State
+> Failures**.
+
+**Trap 1: Using a descriptive but incorrect property name in the UI that does
+not match the exact backend JSON key.**
+
+**Don't:**
+
+```typescript
+// BAD: Backend sends 'diffs_too_expensive_to_compute'
+if (this.file?.too_expensive_to_compute) {
+  renderWarning();
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Exact matching property name
+if (this.file?.diffs_too_expensive_to_compute) {
+  renderWarning();
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T6-03: Delegation to Centralized REST API Error Handlers
+
+> **Rule:** Never wrap standard REST API calls in explicit `try...catch` blocks
+> to handle generic network failures.
+>
+> **What:** Do not wrap standard REST API calls in explicit try...catch blocks
+> to handle generalized network failures, as the global framework centrally
+> intercepts these and dispatches user-visible network errors.
+>
+> **Applies To:** API service integrations and asynchronous handlers making
+> network requests.
+>
+> **Why:** A reviewer expressed concern about unhandled promise rejections if a
+> network call failed inside a finally block. The framework is designed
+> specifically not to throw standard API errors back to the caller unless
+> explicitly opted into via a custom errFn, handling generic network error
+> alerting natively. Failing to adhere to this typically results in **Redundant
+> Error Boilerplate**.
+
+**Trap 1: Wrapping API calls in verbose catch blocks solely to surface generic
+network failure messages.**
+
+**Don't:**
+
+```typescript
+try {
+  await this.restApiService.createFlow(changeNum);
+} catch (e) {
+  fireNetworkError(this, e);
+}
+```
+
+**Do:**
+
+```typescript
+// Fire and let the framework's centralized errFn handle generic failures.
+await this.restApiService.createFlow(changeNum);
+```
+
+**Exceptions:** When a specific component logic must intercept an error
+silently, or when a custom `errFn` is explicitly provided to bypass default
+handling.
+
+--------------------------------------------------------------------------------
+
+#### T6-04: Centralized Error Callbacks for API Calls
+
+> **Rule:** Always use built-in centralized error callback mechanisms (`errFn`)
+> provided by the REST API service instead of manual `try...catch` blocks.
+>
+> **What:** Use built-in centralized error callback mechanisms (`errFn`)
+> provided by the REST API service instead of wrapping calls in manual
+> `try...catch` blocks that result in redundant local error dispatching.
+>
+> **Applies To:** Asynchronous REST API calls within frontend components.
+>
+> **Why:** Manual `try...catch` blocks often resulted in duplicate error
+> notifications being fired to the user—once for the native fetch rejection by
+> the global handler, and once for the custom component fallback. Failing to
+> adhere to this typically results in **Duplicate Error Notifications**.
+
+**Trap 1: Wrapping an API call in a try/catch block and firing a custom error
+event on catch alongside null checks.**
+
+**Don't:**
+
+```typescript
+try {
+  response = await this.restApiService.getPatchContent();
+} catch (e) {
+  fireError(this, 'Failed');
+  return;
+}
+if (!response) { fireError(this, 'Failed'); return; }
+```
+
+**Do:**
+
+```typescript
+const response = await this.restApiService.getPatchContent(errFn);
+if (!response) return;
+```
+
+--------------------------------------------------------------------------------
+
+#### T6-05: Modeling Inconsistent Backend API Payloads
+
+> **Rule:** Must explicitly document and type structural variances with optional
+> fields when backend endpoints return disparate keys for the same entity.
+>
+> **What:** When backend endpoints return structurally differing payload keys
+> for the same conceptual entity, the frontend interface must explicitly type
+> the variance and track the technical debt, rather than forcing a singular,
+> inaccurate type.
+>
+> **Applies To:** Frontend API interface definitions and REST service layers.
+>
+> **Why:** Two related backend chat endpoints returned data under different keys
+> (`response` vs `chat_response`). Forcing the frontend to expect only
+> `response` broke conversation loading, necessitating explicit optional types.
+> Failing to adhere to this typically results in **Type Safety Violation**.
+
+**Trap 1: Assuming a single payload structure across disparate endpoints and
+experiencing undefined runtime property access.**
+
+**Don't:**
+
+```typescript
+// BAD: Forces all endpoints to return 'response'
+interface ConversationTurn {
+  response: ChatResponse;
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Accurately model the backend variance and track the tech debt
+interface ConversationTurn {
+  response: ChatResponse;
+  // TODO: Clean this up - when loadConversation is used we get chat_response instead of response
+  chat_response?: ChatResponse;
+}
+```
+
+## Chapter: AI Context & Telemetry Payload Optimization
+
+**Context:** This domain governs the client-side lifecycle and optimization of
+data structures sent to AI agents and telemetry pipelines. It strictly dictates
+payload deduplication strategies, transparent AI token constraint surfacing, and
+rigorous parsing validation to prevent silent context loss.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T7-01** | Elimination of Implicit   | Medium   | Manually injecting the    |
+:           : Telemetry Payload Fields  :          : current domain/host into  :
+:           :                           :          : the analytics event       :
+:           :                           :          : detail object.            :
+| **T7-02** | Deduplication of          | Medium   | Defining telemetry        |
+:           : Telemetry Payload         :          : interfaces that manually  :
+:           : Attributes                :          : require context variables :
+:           :                           :          : easily derivable from the :
+:           :                           :          : current session or        :
+:           :                           :          : backend data warehouse.   :
+| **T7-03** | Surface AI Context Prompt | Medium   | Rendering an empty        |
+:           : Sizes in the UI           :          : container or entirely     :
+:           :                           :          : omitting the size         :
+:           :                           :          : calculation for AI prompt :
+:           :                           :          : payloads.                 :
+| **T7-04** | Validating AI Context     | High     | Filtering out undefined   |
+:           : Data Parsing              :          : parsed items before       :
+:           :                           :          : validating if the parsing :
+:           :                           :          : step encountered          :
+:           :                           :          : failures.                 :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T7-01: Elimination of Implicit Telemetry Payload Fields
+
+> **Rule:** Never include implicit environmental data like domain or host origin
+> in frontend telemetry payloads. Always rely entirely on backend log enrichment
+> to capture network request origins.
+>
+> **What:** Exclude implicit environmental data (e.g., the browser's host or
+> domain origin) from explicit frontend telemetry interaction payloads, relying
+> instead on backend log enrichment.
+>
+> **Applies To:** Frontend telemetry and metrics reporting services.
+>
+> **Why:** Sending fields like `window.location.host` in interaction reporting
+> payloads creates redundant data bloat, as the logging infrastructure
+> automatically captures the origin of the network request. Failing to adhere to
+> this typically results in **Telemetry Payload Bloat**.
+
+**Trap 1: Manually injecting the current domain/host into the analytics event
+detail object.**
+
+**Don't:**
+
+```typescript
+const details: AiAgentEventDetails = {
+  host: window.location.host,
+  agentId,
+  conversationId: this.conversationId,
+};
+this.reportingService.reportInteraction(Interaction.AI_AGENT, details);
+```
+
+**Do:**
+
+```typescript
+const details: Pick<AiAgentEventDetails, 'agentId' | 'conversationId'> = {
+  agentId,
+  conversationId: this.conversationId,
+};
+this.reportingService.reportInteraction(Interaction.AI_AGENT, details);
+```
+
+--------------------------------------------------------------------------------
+
+#### T7-02: Deduplication of Telemetry Payload Attributes
+
+> **Rule:** Must omit redundant context variables from telemetry payloads if
+> they can be derived via backend SQL joins. Delegate session identity and role
+> resolution to the downstream data pipeline.
+>
+> **What:** Telemetry payloads sent from frontend components must not include
+> redundant context variables (like changeId or userRole) if they can be
+> attached via global session context or derived via SQL joins on the backend.
+>
+> **Applies To:** Frontend reporting services, constants defining EventDetails,
+> and UI components firing interaction events.
+>
+> **Why:** The telemetry payloads for AI interactions manually calculated and
+> passed user roles and change IDs, unnecessarily bloating the payload and
+> duplicating data already captured globally by the reporting service. Failing
+> to adhere to this typically results in **Redundant Payload Bloat**.
+
+**Trap 1: Defining telemetry interfaces that manually require context variables
+easily derivable from the current session or backend data warehouse.**
+
+**Don't:**
+
+```typescript
+export type AiAgentEventDetails = {
+  changeId: string;
+  userRole: 'author' | 'reviewer';
+  // ... other fields
+};
+```
+
+**Do:**
+
+```typescript
+export type AiAgentEventDetails = {
+  // Rely on global session_id attached by reporting service
+  agentId: string;
+  turnIndex: number;
+};
+```
+
+**Trap 2: Performing complex logic on the client to derive a metric that can be
+natively handled using a SQL join during data analysis.**
+
+**Don't:**
+
+```typescript
+const userRole = this.account?._account_id === this.change.owner._account_id ? 'author' : 'reviewer';
+this.reportingService.reportInteraction('action', { userRole });
+```
+
+**Do:**
+
+*   Fire the core event identifier with the minimal context required. Delegate
+    the user role resolution to the data pipeline via SQL joins using the global
+    session identity.
+
+--------------------------------------------------------------------------------
+
+#### T7-03: Surface AI Context Prompt Sizes in the UI
+
+> **Rule:** Always calculate and display prompt capacities (such as word or
+> token counts) directly in the UI to make context limits explicit to the user.
+>
+> **What:** Dialogs or interfaces handling AI prompt generation must calculate
+> and explicitly display the prompt size (word or token count) to provide the
+> user with clear context limits.
+>
+> **Applies To:** AI prompt dialogue components and context builders.
+>
+> **Why:** Failing to display prompt sizes left users blind to context
+> limitations, leading to rejected backend payloads or silently truncated
+> context when the payload exceeded token bounds. Failing to adhere to this
+> typically results in **Unpredictable AI Truncation**.
+
+**Trap 1: Rendering an empty container or entirely omitting the size calculation
+for AI prompt payloads.**
+
+**Don't:**
+
+```html
+<div class="actions">
+  <div class="size"></div>
+  <gr-button>Copy Prompt</gr-button>
+</div>
+```
+
+**Do:**
+
+```html
+<div class="actions">
+  <div class="size">
+    ${this.calculatePromptWordCount(this.promptText)} words
+  </div>
+  <gr-button>Copy Prompt</gr-button>
+</div>
+```
+
+--------------------------------------------------------------------------------
+
+#### T7-04: Validating AI Context Data Parsing
+
+> **Rule:** Must assert the full structural integrity of parsed AI context
+> datasets before executing any filtering logic. Never silently drop undefined
+> items without evaluating parsing failures.
+>
+> **What:** When parsing unstructured or semi-structured data for AI context
+> payloads, validation logic must assert the integrity of the entire dataset
+> before filtering out invalid entries.
+>
+> **Applies To:** Frontend chat panels and AI context item parsers.
+>
+> **Why:** Historically, the application filtered out undefined context items
+> immediately after parsing. This swallowed partial parsing failures, meaning
+> users were not alerted when some of their context links failed to load,
+> leading to degraded AI prompts. Failing to adhere to this typically results in
+> **Silent Data Loss**.
+
+**Trap 1: Filtering out undefined parsed items before validating if the parsing
+step encountered failures.**
+
+**Don't:**
+
+```typescript
+// BAD: The check evaluates the array after undefined items are already removed.
+const contextItems = (links ?? [])
+  .map(link => parseLink(link))
+  .filter(isDefined);
+
+if (contextItems.some(item => !item)) {
+  fireAlert('Failed to parse links.');
+}
+```
+
+**Do:**
+
+```typescript
+// GOOD: Count items before and after filtering to detect failures, or check the mapped array prior to filtering.
+const parsedItems = (links ?? []).map(link => parseLink(link));
+const validItems = parsedItems.filter(isDefined);
+
+if (links.length > 0 && validItems.length === 0) {
+  fireAlert('Failed to parse one or more context item links.');
+}
+```
diff --git a/configs/skills/gerrit_hygiene_operations/SKILL.md b/configs/skills/gerrit_hygiene_operations/SKILL.md
new file mode 100644
index 0000000..095cfe4
--- /dev/null
+++ b/configs/skills/gerrit_hygiene_operations/SKILL.md
@@ -0,0 +1,538 @@
+---
+name: gerrit-hygiene-operations
+description: Provides rules, patterns, and best practices for code hygiene, formatting, downstream plugin dependencies, and release operations in Gerrit.
+---
+
+# Gerrit Hygiene & Operations Engineering Guide
+
+## Executive Summary
+
+Welcome to the authoritative engineering guide for maintaining code hygiene,
+managing downstream deployments, and optimizing operations within the codebase.
+This living repository serves as the definitive source of tribal knowledge aimed
+at preventing technical debt and keeping our development ecosystem scalable,
+predictable, and resilient against integration friction.
+
+The domains covered in this payload target the most common sources of technical
+debt, pipeline flakiness, and deployment desynchronization. You will find
+mandates covering the strict encapsulation of proprietary infrastructure to
+protect external contributors from opaque tooling, strategies for eliminating
+visual regression test flakiness, and protocols for dead-code elimination during
+experiment decommissioning. Furthermore, it outlines performance optimization
+standards—such as client-side request parallelization—and mandates strict CI/CD
+hygiene via TypeScript formatting rules and automated release note generation.
+
+Engineers are expected to internalize these guidelines when architecting UI
+components, authoring API documentation, or coordinating downstream releases.
+Adhering to these principles directly reduces deployment noise, ensures a
+seamless developer experience for both internal and open-source contributors,
+and fortifies the integrity of our continuous integration pipelines.
+
+## Summary
+
+| Chapter Theme / Title               | Scope & Objective                      |
+| :---------------------------------- | :------------------------------------- |
+| **Downstream Ecosystem Deployment   | Governs the coordination of release    |
+: Synchronization**                   : timelines and cross-project dependency :
+:                                     : management for external plugins across :
+:                                     : isolated, downstream Gerrit            :
+:                                     : deployments. Requires strict auditing  :
+:                                     : to prevent integration failures during :
+:                                     : core platform rollouts.                :
+| **TypeScript Code Formatting &      | Defines structural code style and      |
+: Syntax Normalization**              : linting mandates for frontend          :
+:                                     : TypeScript components. Strict          :
+:                                     : enforcement of formatting rules, such  :
+:                                     : as line-length constraints, ensures    :
+:                                     : optimal diff readability and prevents  :
+:                                     : automated CI pipeline failures.        :
+| **Proprietary Infrastructure        | Establishes strict boundaries for      |
+: Encapsulation**                     : documenting public APIs by forbidding  :
+:                                     : the leakage of proprietary backend     :
+:                                     : paths or internal corporate URLs.      :
+:                                     : Ensures environment encapsulation and  :
+:                                     : prevents exposing dead links or opaque :
+:                                     : references to open-source              :
+:                                     : contributors.                          :
+| **Client-Side Request               | Governs the optimization of frontend   |
+: Parallelization**                   : loading metrics by transitioning       :
+:                                     : monolithic or batched server-side      :
+:                                     : queries into parallelized,             :
+:                                     : asynchronous client-side fan-out       :
+:                                     : requests. Mandates concurrent          :
+:                                     : execution to improve performance and   :
+:                                     : simplify caching logic.                :
+| **Experiment Decommissioning & Dead | Dictates the mandatory cleanup         |
+: Code Elimination**                  : required when promoting successful     :
+:                                     : experimental features to default       :
+:                                     : behavior. Enforces the strict          :
+:                                     : elimination of legacy fallback         :
+:                                     : methods, feature flag evaluations, and :
+:                                     : outdated service dependencies to       :
+:                                     : prevent dead code accumulation.        :
+| **Commit Metadata & Release Note    | Mandates the injection of structured   |
+: Automation**                        : git footers into commit messages.      :
+:                                     : Strict adherence ensures that          :
+:                                     : automated CI/CD pipelines successfully :
+:                                     : parse and generate accurate changelogs :
+:                                     : and release notes without manual       :
+:                                     : intervention.                          :
+| **Visual Regression Test            | Governs strategies for eliminating     |
+: Determinism**                       : visual regression test flakiness by    :
+:                                     : deliberately orchestrating             :
+:                                     : deterministic, transitional UI states. :
+:                                     : Establishes the standard of            :
+:                                     : intentionally omitting API mocks to    :
+:                                     : reliably capture loading states during :
+:                                     : screenshot baseline generation.        :
+
+--------------------------------------------------------------------------------
+--------------------------------------------------------------------------------
+
+## Chapter: Downstream Ecosystem Deployment Synchronization
+
+**Context:** This chapter governs the coordination of release timelines and
+cross-project dependency management for external plugins across isolated,
+downstream Gerrit deployments. Strict auditing of these external ecosystems is
+required to prevent integration failures when rolling out core platform updates.
+
+### Summary
+
+| Rule ID   | Principle / Constraint  | Priority | Primary Symptom / Trap      |
+| :-------- | :---------------------- | :------- | :-------------------------- |
+| **T1-01** | Downstream Plugin       | High     | Proceeding with a core      |
+:           : Dependency Verification :          : release timeline without    :
+:           : During Core Rollout     :          : verifying downstream custom :
+:           :                         :          : plugin compatibility.       :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T1-01: Downstream Plugin Dependency Verification During Core Rollout
+
+> **Rule:** Always audit and verify the compatibility of cross-project
+> dependencies, such as custom plugins, before executing core release updates to
+> isolated downstream instances.
+>
+> **What:** Before rolling out core release updates to isolated downstream
+> ecosystem instances, cross-project dependencies (e.g., custom plugins) must be
+> explicitly audited and verified for compatibility.
+>
+> **Applies To:** Release management and deployment synchronization to
+> downstream environments (e.g., Chromium, pdfium, v8).
+>
+> **Why:** Deploying core updates without simultaneously auditing and upgrading
+> heavily used plugins (like avatars-external) in downstream installations
+> historically risked deployment delays and integration failures. Failing to
+> adhere to this typically results in **Deployment Desynchronization**.
+
+**Trap 1: Proceeding with a core release timeline without verifying downstream
+custom plugin compatibility.**
+
+**Don't:**
+
+*   Roll out the core release independently of external plugin compatibility
+    states.
+
+**Do:**
+
+*   Audit and flag downstream plugin requirements (e.g., avatars-external) for
+    updates prior to or alongside the core release.
+
+## Chapter: TypeScript Code Formatting & Syntax Normalization
+
+**Context:** This section defines the structural code style and linting mandates
+for frontend TypeScript components. Strict enforcement of formatting rules, such
+as line-length constraints, ensures optimal diff readability and prevents
+automated CI pipeline failures.
+
+### Summary
+
+| Rule ID   | Principle / Constraint | Priority | Primary Symptom / Trap    |
+| :-------- | :--------------------- | :------- | :------------------------ |
+| **T2-01** | Strict Line Length     | Medium   | Chaining long promise     |
+:           : Formatting             :          : callbacks or variable     :
+:           :                        :          : assignments on a single   :
+:           :                        :          : line.                     :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T2-01: Strict Line Length Formatting
+
+> **Rule:** Always format TypeScript files to strictly adhere to linting limits
+> by wrapping long chained expressions. Never commit code that triggers structural
+> style violations.
+>
+> **What:** TypeScript frontend files must strictly adhere to linting standards
+> by properly wrapping long chained expressions.
+>
+> **Applies To:** TypeScript UI components (e.g., Lit elements like
+> `gr-reply-dialog.ts`).
+>
+> **Why:** Inconsistent formatting and over-extended lines caused unnecessary diff
+> noise and failed automated linting checks in the frontend CI pipeline. Failing
+> to adhere to this typically results in **Linting Pipeline Failure**.
+
+**Trap 1: Chaining long promise callbacks or variable assignments on a single
+line.**
+
+**Don't:**
+
+```typescript
+return this.saveReview(reviewInput, errFn).then(result => {
+```
+
+**Do:**
+
+```typescript
+return this.saveReview(reviewInput, errFn)
+  .then(result => {
+```
+
+## Chapter: Proprietary Infrastructure Encapsulation
+
+**Context:** This domain establishes strict boundaries for documenting
+public-facing APIs, Enums, and interface definitions by explicitly forbidding
+the leakage of proprietary backend paths (e.g., `google3/`) or internal
+corporate URLs. Adherence ensures environment encapsulation and prevents
+exposing dead links or opaque references to open-source contributors.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T3-01** | Omission of Proprietary   | Medium   | Pasting internal          |
+:           : Infrastructure Paths from :          : repository file paths     :
+:           : Enum Documentation        :          : into the docblock of an   :
+:           :                           :          : interface or Enum.        :
+| **T3-02** | Exclusion of Internal     | Medium   | Using proprietary         |
+:           : Code Search URIs from     :          : internal code search URLs :
+:           : Interface Types           :          : to provide examples of    :
+:           :                           :          : acceptable payload        :
+:           :                           :          : strings.                  :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T3-01: Omission of Proprietary Infrastructure Paths from Enum Documentation
+
+> **Rule:** Always strip internal directory paths from JSDoc or TSDoc comments
+> when defining public-facing data models.
+>
+> **What:** JSDoc or TSDoc comments defining data models (e.g., Enums) must not
+> leak proprietary, internal backend repository or directory paths.
+>
+> **Applies To:** Frontend API definitions, specifically Protobuf-mapped Enums.
+>
+> **Why:** Internal directory paths (e.g., google3/.../proto) were mistakenly
+> included in open-source frontend code documentation, breaking environment
+> encapsulation. Failing to adhere to this typically results in **Information
+> Leakage**.
+
+**Trap 1: Pasting internal repository file paths into the docblock of an
+interface or Enum.**
+
+**Don't:**
+
+```typescript
+/**
+ * Enum to match the Action proto from CRUAS.
+ * google3/path/to/internal/service/proto/file.proto.
+ */
+export enum ActionEnum { ... }
+```
+
+**Do:**
+
+```typescript
+/**
+ * Enum to match the Action proto from CRUAS.
+ */
+export enum ActionEnum { ... }
+```
+
+#### T3-02: Exclusion of Internal Code Search URIs from Interface Types
+
+> **Rule:** Never include proprietary code search URLs when documenting type
+> definitions or payload schemas.
+>
+> **What:** Code comments must not contain URL links to internal code search
+> tools or proprietary source depots when documenting interface field types.
+>
+> **Applies To:** API interface declarations and payload schemas (e.g.,
+> `ContextItem`).
+>
+> **Why:** A developer linked directly to a proprietary source code URI
+> (source.corp.google.com) to explain a type ID field, rendering the
+> documentation inaccessible and useless for external open-source contributors.
+> Failing to adhere to this typically results in **Dead Link / Opacity**.
+
+**Trap 1: Using proprietary internal code search URLs to provide examples of
+acceptable payload strings.**
+
+**Don't:**
+
+```typescript
+// type_id should match the types here: https://source.corp.google.com/piper///depot/google3/...
+type_id: string;
+```
+
+**Do:**
+
+```typescript
+// type_id should map to standard application contexts (e.g., 'gerrit_change', 'bug_tracker').
+type_id: string;
+```
+
+## Chapter: Client-Side Request Parallelization
+
+**Context:** This chapter governs the optimization of frontend loading metrics
+by transitioning monolithic or batched server-side queries into parallelized,
+asynchronous client-side fan-out requests. This approach mandates client-side
+concurrent execution to improve dashboard performance and simplify downstream
+caching logic.
+
+### Summary
+
+| Rule ID   | Principle / Constraint  | Priority | Primary Symptom / Trap |
+| :-------- | :---------------------- | :------- | :--------------------- |
+| **T4-01** | Client-Side Fan-out for | High     | Forwarding an array of |
+:           : Dashboard Query         :          : queries to a dedicated :
+:           : Processing              :          : multi-query endpoint   :
+:           :                         :          : method.                :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T4-01: Client-Side Fan-out for Dashboard Query Processing
+
+> **Rule:** Always map and execute multiple backend query requests concurrently
+> using client-side parallelization constructs. Never rely on batched,
+> single-request multi-query endpoints to aggregate data.
+>
+> **What:** Multiple backend query requests must be mapped and executed
+> concurrently using Promise.all on the client side, rather than relying on a
+> single, batched multi-query backend endpoint.
+>
+> **Applies To:** API Service layer (`GrRestApiServiceImpl`), specifically
+> dashboard data fetching routines.
+>
+> **Why:** A legacy approach prefetched batched queries in the backend, which
+> complicated caching and backend logic. Shifting to client-side parallel
+> requests explicitly improved the DashboardDisplayed performance metric.
+> Failing to adhere to this typically results in **Performance Degradation**.
+
+**Trap 1: Forwarding an array of queries to a dedicated multi-query endpoint
+method.**
+
+**Don't:**
+
+```typescript
+return this.getChangesForMultipleQueries(changesPerPage, queries, offset, options);
+```
+
+**Do:**
+
+```typescript
+const requestPromises = queries.map(query =>
+  this.getChanges(changesPerPage, query, offset, options)
+);
+return Promise.all(requestPromises).then(results => {
+  if (results.includes(undefined)) return undefined;
+  return results as ChangeInfo[][];
+});
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Downstream:** T5 | Experiment Decommissioning & Dead Code Elimination -
+    *Transitioning to client-side fan-outs routinely exposes obsolete backend
+    batching mechanisms and experimental feature flags that must be subsequently
+    purged.*
+
+## Chapter: Experiment Decommissioning & Dead Code Elimination
+
+**Context:** This domain governs the mandatory cleanup required when promoting
+successful experimental features to default behavior. It enforces the strict
+elimination of legacy fallback methods, feature flag evaluations, and outdated
+service dependencies to prevent dead code accumulation.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T5-01** | Aggressive Pruning of     | Medium   | Leaving the legacy        |
+:           : Decommissioned Experiment :          : fallback method in the    :
+:           : Fallbacks                 :          : class after removing the  :
+:           :                           :          : experiment flag toggle    :
+:           :                           :          : and its invocation block. :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T5-01: Aggressive Pruning of Decommissioned Experiment Fallbacks
+
+> **Rule:** Must completely delete all fallback methods, feature flags, and
+> obsolete service dependencies immediately upon graduating an experimental
+> feature to default behavior.
+>
+> **What:** When an experimental feature is promoted to default behavior, all
+> corresponding flag evaluations (`KnownExperimentId`), service dependencies
+> (`FlagsService`), and obsolete fallback routing methods must be entirely
+> deleted.
+>
+> **Applies To:** Service layer components and API implementations during
+> feature flag graduation.
+>
+> **Why:** Failing to aggressively remove obsolete fallback methods (like
+> `getChangesForMultipleQueries`) after adopting client-side parallel requests
+> natively led to dead code and confusion over correct API utilization. Failing
+> to adhere to this typically results in **Technical Debt Accumulation**.
+
+**Trap 1: Leaving the legacy fallback method in the class after removing the
+experiment flag toggle and its invocation block.**
+
+**Don't:**
+
+*   Remove the `if (experimentEnabled)` evaluation, but leave
+    `getChangesForMultipleQueries(queries)` declared in the class definition.
+
+**Do:**
+
+*   Delete the deprecated fallback method `getChangesForMultipleQueries`
+    entirely to ensure no other components attempt to invoke it.
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T4 | Client-Side Request Parallelization - *Transitioning to
+    parallelized client-side requests triggers the deprecation and subsequent
+    cleanup of legacy monolithic server-side queries.*
+
+## Chapter: Commit Metadata & Release Note Automation
+
+**Context:** This section mandates the injection of structured git footers into
+commit messages. Strict adherence ensures that automated CI/CD pipelines
+successfully parse and generate accurate changelogs and release notes without
+manual intervention.
+
+### Summary
+
+| Rule ID   | Principle / Constraint  | Priority | Primary Symptom / Trap      |
+| :-------- | :---------------------- | :------- | :-------------------------- |
+| **T6-01** | Mandatory Release-Notes | High     | Omitting the footer         |
+:           : Footer Injection        :          : entirely or formatting the  :
+:           :                         :          : release note description as :
+:           :                         :          : a multi-line paragraph.     :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T6-01: Mandatory Release-Notes Footer Injection
+
+> **Rule:** Always append a properly formatted, single-line `Release-Notes:`
+> footer to all commits. While a descriptive note is valuable for significant
+> updates, using `Release-Notes: skip` is completely acceptable and common for
+> minor UI changes, styling tweaks, small fixes, or any change where a detailed
+> public changelog entry is not necessary.
+>
+> **What:** Commit messages must include a properly formatted, single-line
+> 'Release-Notes:' footer to satisfy CI/CD submit requirements and trigger
+> automated changelog generation. If a change is minor or a public release note
+> is not needed (even for user-facing adjustments like adding spacing to prevent
+> accidental clicks), using the value 'skip' is fully valid and acceptable.
+>
+> **Applies To:** Git commit messages for all changes, including frontend UI,
+> backend, and configuration updates.
+>
+> **Why:** Without a dedicated footer, automated release note parsers or submission
+> checks can fail. However, not every commit needs a public release note; hence,
+> `Release-Notes: skip` is supported to keep git history clean and satisfy automation
+> without generating unnecessary public changelog entries.
+
+**Trap 1: Omitting the footer entirely or formatting the release note
+description as a multi-line paragraph.**
+
+**Don't:**
+
+*   Submit a commit without an explicit `Release-Notes:` footer, or spread the
+    release note description across multiple lines in the commit body.
+
+**Do:**
+
+*   For major features or configuration changes: Append a single-line footer with a
+    descriptive summary, e.g.:
+    `Release-Notes: Add config to control if review footers should be included into the commit message on submit`
+*   For minor adjustments (such as fixing accidental clicks by adding spacing),
+    trivial fixes, or refactorings: A detailed release note description can still be
+    provided if desired, but appending `Release-Notes: skip` is completely acceptable.
+
+**Exceptions:** None. The `Release-Notes:` footer must be present on all commits,
+but the value `skip` is always acceptable.
+
+## Chapter: Visual Regression Test Determinism
+
+**Context:** This chapter governs the strategies for eliminating visual
+regression test flakiness by deliberately orchestrating deterministic,
+transitional UI states. Specifically, it establishes the standard of
+intentionally omitting API mocks to reliably capture loading states during
+screenshot baseline generation.
+
+### Summary
+
+| Rule ID   | Principle / Constraint  | Priority | Primary Symptom / Trap      |
+| :-------- | :---------------------- | :------- | :-------------------------- |
+| **T7-01** | Intentional Omission of | High     | Mocking all APIs by default |
+:           : Mocks for Deterministic :          : in a screenshot test,       :
+:           : UI Baselines            :          : causing a race condition    :
+:           :                         :          : where the mock resolves     :
+:           :                         :          : faster than the screenshot  :
+:           :                         :          : is taken.                   :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T7-01: Intentional Omission of Mocks for Deterministic UI Baselines
+
+> **Rule:** Always omit API mocks when attempting to reliably capture
+> transitional UI states (such as loading spinners) in visual regression tests.
+> Never provide mock responses if the objective is to baseline a pre-resolved,
+> asynchronous view.
+>
+> **What:** To capture stable, deterministic transitional UI states (like
+> loading spinners) in visual regression tests, the underlying API endpoints
+> must be intentionally left unmocked.
+>
+> **Applies To:** Screenshot baseline testing (e.g., `_screenshot_test.ts`) for
+> asynchronous UI components.
+>
+> **Why:** Capturing fully resolved UI states was difficult to synchronize,
+> causing flaky screenshot tests. Relying on an unmocked API ensures the
+> component hangs reliably in the 'loading' state, producing a stable visual
+> baseline for the loader. Failing to adhere to this typically results in **Test
+> Flakiness**.
+
+**Trap 1: Mocking all APIs by default in a screenshot test, causing a race
+condition where the mock resolves faster than the screenshot is taken.**
+
+**Don't:**
+
+*   Providing mock responses for the API in order to capture the transitional
+    loading state, introducing timing-based flakiness.
+
+**Do:**
+
+*   Leave the data-fetching API unmocked so the component indefinitely hangs in
+    its transitional `loading` state, yielding a 100% deterministic screenshot
+    of the loader.
diff --git a/configs/skills/gerrit_system_logic/SKILL.md b/configs/skills/gerrit_system_logic/SKILL.md
new file mode 100644
index 0000000..823ff4c
--- /dev/null
+++ b/configs/skills/gerrit_system_logic/SKILL.md
@@ -0,0 +1,2559 @@
+---
+name: gerrit-system-logic
+description: Provides rules, patterns, and best practices for Gerrit backend system logic, Java APIs, performance, and correctness.
+---
+
+# System Logic & Correctness Engineering Guide
+
+## Executive Summary
+
+This engineering guide serves as the definitive reference for maintaining system
+logic and operational correctness within Gerrit's repository management and
+code review infrastructure. It exists to capture critical
+historical tribal knowledge, prevent the regression of known failure modes—such
+as JGit thread-safety violations or NoteDb schema lock-ins—and establish strict,
+non-negotiable architectural boundaries for newly contributed code. By
+standardizing these patterns, the guide provides incoming engineers with the
+necessary context to safely navigate and modify highly concurrent,
+state-dependent subsystems.
+
+Historically, undocumented assumptions surrounding asynchronous task resolution,
+object cache sizes, and concurrent worker pools led to silent data omissions,
+degraded latency, and complex race conditions. To mitigate this, the guide
+enforces strict constraints around execution environments, including explicit
+per-thread JGit object isolation, decoupled persistence models, and two-step
+schema rollouts. It additionally hardens the system's security posture by
+formalizing impersonation tracking, standardizing Contributor License Agreement
+(CLA) checks, and preventing identity-cycling vulnerabilities through rigorous
+account validation protocols.
+
+The overarching technical domains covered within this repository span concurrent
+thread safety, resilient NoteDb serialization and Protobuf schema evolution,
+optimized REST API payload handling, deterministic Guice-based test sandboxing,
+and Bazel build infrastructure alignment. Adhering to these documented policies
+guarantees that the platform remains scalable, secure, and resilient against
+regressions across both internal infrastructure workflows and distributed
+cluster upgrades.
+
+## Summary
+
+| Chapter Theme / Title                | Scope & Objective                     |
+| :----------------------------------- | :------------------------------------ |
+| **JGit Concurrency & Thread Safety** | To prevent severe race conditions and |
+:                                      : data corruption during parallel       :
+:                                      : processing, Gerrit requires strict    :
+:                                      : per-thread isolation of JGit          :
+:                                      : resources like `RevWalk`,             :
+:                                      : `Repository`, and `ObjectReader`.     :
+:                                      : Never share these non-thread-safe     :
+:                                      : instances across `ExecutorService`    :
+:                                      : boundaries or parallel streams.       :
+| **NoteDb Serialization & Schema      | Governs the serialization and schema  |
+: Evolution**                          : evolution strategies for persisting   :
+:                                      : Gerrit change metadata to Git notes   :
+:                                      : (NoteDb). Strictly enforces two-step  :
+:                                      : schema rollouts, decoupled transfer   :
+:                                      : objects, and permissive JSON parsing  :
+:                                      : to guarantee data integrity across    :
+:                                      : distributed cluster upgrades.         :
+| **Query Engine Performance &         | Governs the configuration,            |
+: Operator Enforcement**               : evaluation, and strict syntactic      :
+:                                      : enforcement of query predicates       :
+:                                      : within the repository engine. Focuses :
+:                                      : on decoupling strict user-facing      :
+:                                      : evaluation constraints from           :
+:                                      : background observability and          :
+:                                      : implementing phased, non-blocking     :
+:                                      : rollouts to prevent configuration     :
+:                                      : deadlocks.                            :
+| **Access Control & Impersonation     | Governs the evaluation and auditing   |
+: Security**                           : of impersonated access operations     :
+:                                      : (e.g., `SUBMIT_AS` and `RUN_AS`).     :
+:                                      : Defines strict mechanisms for         :
+:                                      : isolating the initiating "real user"  :
+:                                      : from the "impersonated user" during   :
+:                                      : dynamic permission checks, and        :
+:                                      : mandates automated, centralized       :
+:                                      : logging in NoteDb and Protobuf to     :
+:                                      : guarantee uncorrupted audit trails.   :
+| **Account Lifecycle & Vulnerability  | Defines constraints for handling      |
+: Mitigation**                         : deleted or recreated user accounts to :
+:                                      : prevent identity-cycling exploits,    :
+:                                      : specifically isolating validation     :
+:                                      : checks and filtering orphaned         :
+:                                      : approvals during merge operations     :
+:                                      : without degrading system performance. :
+| **REST API Resource Routing &        | REST API endpoints must strictly      |
+: Payload Optimization**               : reflect hierarchical entity           :
+:                                      : relationships while optimizing JSON   :
+:                                      : payloads to minimize bandwidth and    :
+:                                      : processing latency. Modifications to  :
+:                                      : serialization pipelines must          :
+:                                      : explicitly gate expensive permission  :
+:                                      : checks and gracefully handle          :
+:                                      : redundant or experimental fields      :
+:                                      : without causing UI ambiguity.         :
+| **Build Infrastructure & Dependency  | Governs the configuration,            |
+: Alignment**                          : versioning, and migration of the      :
+:                                      : build system infrastructure, focusing :
+:                                      : heavily on Bazel modules (`bzlmod`)   :
+:                                      : and dependency graphs. Establishes    :
+:                                      : constraints to ensure strict version  :
+:                                      : alignment, prevent compliance         :
+:                                      : pipeline stalls, and maintain         :
+:                                      : reliable developer bootstrapping      :
+:                                      : environments.                         :
+| **Asynchronous Notification          | Asynchronous notification pipelines   |
+: Consistency**                        : must capture exact entity state at    :
+:                                      : the moment of initialization rather   :
+:                                      : than fetching it dynamically during   :
+:                                      : execution. This guarantees data       :
+:                                      : consistency and prevents delayed      :
+:                                      : background tasks from inadvertently   :
+:                                      : processing future, out-of-band        :
+:                                      : updates from persistent storage.      :
+| **Test Suite Configuration &         | Dictates the implementation of        |
+: Isolation**                          : parameterized integration tests,      :
+:                                      : strict Guice dependency injection,    :
+:                                      : and granular flaky test isolation.    :
+:                                      : Ensures deterministic test coverage   :
+:                                      : across multiple backend               :
+:                                      : configurations without leaking        :
+:                                      : generic APIs or brittle global        :
+:                                      : states.                               :
+| **Diff Engine Computation Limits**   | Explicitly tracking timeouts and      |
+:                                      : fallback states for expensive file    :
+:                                      : diff computations guarantees that     :
+:                                      : frontend clients are alerted to       :
+:                                      : incomplete operations. This prevents  :
+:                                      : silent data omission and ensures      :
+:                                      : accurate representation of modified   :
+:                                      : files in the user interface.          :
+| **Concurrent Formatting & Caching**  | Parallelizing expensive API           |
+:                                      : formatting requires strict adherence  :
+:                                      : to explicit thread-local context      :
+:                                      : propagation, cache isolation for      :
+:                                      : mutated elements, and collection      :
+:                                      : pre-allocation. Failing to manage     :
+:                                      : concurrency strictly leads to context :
+:                                      : leakage across parallel streams,      :
+:                                      : memory overhead, and poisoned UI      :
+:                                      : states.                               :
+| **Protobuf Schema Management &       | Governs the mapping between Java      |
+: Conversion**                         : entities and Protobuf messages,       :
+:                                      : mandating automated reflection-based  :
+:                                      : validation and standardized converter :
+:                                      : abstractions to prevent cache         :
+:                                      : inconsistencies and silent field      :
+:                                      : loss.                                 :
+| **Compliance & CLA Enforcement**     | Strictly enforces Contributor License |
+:                                      : Agreement (CLA) checks across all     :
+:                                      : administrative REST API endpoints     :
+:                                      : that modify project configurations    :
+:                                      : and access rules. This guarantees     :
+:                                      : compliance consistency across both    :
+:                                      : direct code pushes and API-driven     :
+:                                      : administrative actions.               :
+
+--------------------------------------------------------------------------------
+--------------------------------------------------------------------------------
+
+## Chapter: JGit Concurrency & Thread Safety
+
+**Context:** To prevent severe race conditions and data corruption during
+parallel processing, Gerrit requires strict per-thread isolation of JGit
+resources like `RevWalk`, `Repository`, and `ObjectReader`. Never share these
+non-thread-safe instances across `ExecutorService` boundaries or parallel
+streams.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T1-01** | Strict Thread Isolation   | Critical | Passing a shared          |
+:           : of JGit RevWalk Instances :          : `RevWalk` instance from   :
+:           :                           :          : the main thread into      :
+:           :                           :          : worker tasks submitted to :
+:           :                           :          : an executor.              :
+| **T1-02** | Thread-Safe JGit          | Critical | Passing a single shared   |
+:           : Repository Instantiation  :          : JGit Repository instance  :
+:           : for Concurrent            :          : to multiple asynchronous  :
+:           : Computation               :          : worker threads.           :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T1-01: Strict Thread Isolation of JGit RevWalk Instances
+
+> **Rule:** Always instantiate JGit objects like `RevWalk`, `Repository`, and
+> `ObjectReader` per-thread when parallelizing tasks. Never share a single
+> instance across worker threads in an `ExecutorService`.
+>
+> **What:** JGit objects like `RevWalk`, `Repository`, and `ObjectReader` are
+> not thread-safe and must be strictly instantiated per-thread when
+> parallelizing tasks via an `ExecutorService`.
+>
+> **Applies To:** JGit operations, specifically diff computation and object
+> parsing within multi-threaded ExecutorServices.
+>
+> **Why:** Sharing a single RevWalk instance across parallel diff evaluation
+> tasks led to severe race conditions, resulting in corrupted diff results,
+> application crashes, or thread deadlocks. Failing to adhere to this typically
+> results in **Race Conditions / Data Corruption**.
+
+**Trap 1: Passing a shared `RevWalk` instance from the main thread into worker
+tasks submitted to an executor.**
+
+**Don't:**
+
+```java
+// BAD: Sharing a single RevWalk across executor threads
+RevWalk sharedRw = new RevWalk(repo);
+for (Key key : keys) {
+  executor.submit(() -> evaluator.execute(key, sharedRw));
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Opening a new Repository and RevWalk inside the submitted task
+for (Key key : keys) {
+  executor.submit(() -> {
+    try (Repository threadRepo = repoManager.openRepository(project);
+         ObjectReader threadReader = threadRepo.newObjectReader();
+         RevWalk threadRw = new RevWalk(threadReader)) {
+      return evaluator.execute(key, threadRw);
+    }
+  });
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T1-02: Thread-Safe JGit Repository Instantiation for Concurrent Computation
+
+> **Rule:** Must open individual `Repository` instances for each worker thread
+> when parallelizing background computations.
+>
+> **What:** JGit's `openRepository` returns a `Repository` object that is not
+> thread-safe. When parallelizing computations (such as file diffing), each
+> thread or worker must instantiate its own isolated repository handle rather
+> than sharing a single instance.
+>
+> **Applies To:** Background workers, parallel stream processors, and
+> `DiffExecutor` implementations interacting with JGit `Repository` instances.
+>
+> **Why:** Historically, reusing a single `Repository` handle across multiple
+> threads during parallel cache generation caused unpredictable speed-ups, race
+> conditions, and corrupted object reads. Failing to adhere to this typically
+> results in **Race Condition / State Corruption**.
+
+**Trap 1: Passing a single shared JGit Repository instance to multiple
+asynchronous worker threads.**
+
+**Don't:**
+
+```java
+Repository sharedRepo = repoManager.openRepository(project);
+for (FileDiffCacheKey key : keys) {
+  executor.submit(() -> computeDiff(sharedRepo, key));
+}
+```
+
+**Do:**
+
+```java
+for (FileDiffCacheKey key : keys) {
+  executor.submit(() -> {
+    try (Repository workerRepo = repoManager.openRepository(project)) {
+      return computeDiff(workerRepo, key);
+    }
+  });
+}
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Downstream:** T10 | Diff Engine Computation Limits - *Parallelizing file
+    diff computations requires strict per-thread JGit instantiation to safely
+    populate the FileDiffCache without race conditions.*
+*   **Downstream:** T11 | Concurrent Formatting & Caching - *Parallel streams
+    executing asynchronous tasks must internally allocate thread-local
+    Repository handles to prevent state leakage.*
+
+## Chapter: NoteDb Serialization & Schema Evolution
+
+**Context:** This chapter governs the serialization and schema evolution
+strategies for persisting Gerrit change metadata to Git notes (NoteDb). It
+strictly enforces two-step schema rollouts, decoupled transfer objects, and
+permissive JSON parsing to guarantee data integrity across distributed cluster
+upgrades.
+
+### Summary
+
+| Rule ID   | Principle /        | Priority | Primary Symptom / Trap           |
+:           : Constraint         :          :                                  :
+| :-------- | :----------------- | :------- | :------------------------------- |
+| **T2-01** | Two-Step Rollouts  | Critical | Updating reading and writing     |
+:           : for Cached         :          : logic simultaneously without     :
+:           : Serialization      :          : accounting for mixed-version     :
+:           : Schemas            :          : cluster environments.            :
+| **T2-02** | Forward-Compatible | High     | Deploying a strict JSON parser   |
+:           : NoteDb JSON        :          : that throws exceptions upon      :
+:           : Parsing            :          : encountering undocumented        :
+:           :                    :          : fields.                          :
+| **T2-03** | Decoupling         | High     | Serializing an internal database |
+:           : Persistent Storage :          : representation straight to a Git :
+:           : Format from Legacy :          : storage blob.                    :
+:           : DB Entities        :          :                                  :
+| **T2-04** | Push Certificate   | High     | Using negative boolean logic to  |
+:           : Isolation by       :          : conditionally process metadata   :
+:           : Comment Status     :          : that only applies to specific    :
+:           :                    :          : data domains.                    :
+| **T2-05** | Industry-Standard  | Medium   | Using non-standard slang to      |
+:           : Terminology for    :          : differentiate older formatting   :
+:           : Deprecated Logic   :          : methods from modern JSON ones.   :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T2-01: Two-Step Rollouts for Cached Serialization Schemas
+
+> **Rule:** Always execute a two-step rollout and explicitly increment the cache
+> version when mutating persistent cache schemas.
+>
+> **What:** Schema mutations in persistent caches must follow a two-step
+> rollout: Step 1 introduces reader support for the new schema while continuing
+> to write the old schema. Step 2 enables the new writer logic. A cache version
+> increment is required.
+>
+> **Applies To:** NoteDb serialization, `ChangeNotesState`, `ChangeNotesCache`.
+>
+> **Why:** If a schema change is deployed without a two-step rollout, older
+> instances of the application during a rolling restart will encounter cache
+> records written by updated instances. Unable to parse the new schema, the
+> older binaries will crash or drop data. Failing to adhere to this typically
+> results in **Deserialization Crash**.
+
+**Trap 1: Updating reading and writing logic simultaneously without accounting
+for mixed-version cluster environments.**
+
+**Don't:**
+
+*   Deploying a single patchset that modifies both the serialization and
+    deserialization formats of a persistent cache object without bumping the
+    cache version.
+
+**Do:**
+
+*   Deploying the schema change in two phases: first patchset updates
+    deserializers to handle both V1 and V2, and explicitly bumps the cache
+    version. A subsequent patchset updates the serializers to emit V2.
+
+--------------------------------------------------------------------------------
+
+#### T2-02: Forward-Compatible NoteDb JSON Parsing
+
+> **Rule:** Must configure JSON parsers to be inherently permissive and safely
+> ignore unknown fields when deserializing NoteDb revision notes.
+>
+> **What:** JSON parsers reading NoteDb revision notes must be inherently
+> permissive, safely ignoring unknown fields to ensure system resilience against
+> future metadata schema expansions.
+>
+> **Applies To:** NoteDb deserialization algorithms parsing Git blob byte arrays
+> into JSON objects.
+>
+> **Why:** When introducing a new JSON-based payload format for persistent
+> Gerrit metadata, strict parsing risked immediately breaking older service
+> instances or external consumers whenever a new, valid metadata field was
+> appended to the payload. Failing to adhere to this typically results in
+> **Parsing Exception**.
+
+**Trap 1: Deploying a strict JSON parser that throws exceptions upon
+encountering undocumented fields.**
+
+**Don't:**
+
+```java
+// BAD: Strict parsing crashes on new schema properties
+Gson strictGson = new GsonBuilder().create();
+RevisionNoteData data = strictGson.fromJson(reader, RevisionNoteData.class);
+```
+
+**Do:**
+
+```java
+// GOOD: Permissive parsing explicitly ignoring unknown fields + regression tests verifying this behavior
+Gson permissiveGson = new GsonBuilder().create();
+RevisionNoteData data = permissiveGson.fromJson(reader, RevisionNoteData.class);
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-03: Decoupling Persistent Storage Format from Legacy DB Entities
+
+> **Rule:** Never directly serialize legacy relational database objects into Git
+> persistent storage.
+>
+> **What:** Data models persisted to Git (NoteDb) must utilize dedicated,
+> format-specific DTOs rather than directly serializing legacy relational
+> database objects.
+>
+> **Applies To:** NoteDb JSON Serialization pipelines and storage layer
+> architecture.
+>
+> **Why:** Initially, legacy ReviewDb models were mapped directly into JSON
+> payloads. This inherited awkward relational quirks (e.g., missing critical
+> keys that were traditionally injected at runtime) and locked the new storage
+> schema to the constraints of an outgoing SQL-based design. Failing to adhere
+> to this typically results in **Schema Lock-In**.
+
+**Trap 1: Serializing an internal database representation straight to a Git
+storage blob.**
+
+**Don't:**
+
+```java
+// BAD: Serializing legacy database class
+PatchLineComment comment = db.getComment();
+gson.toJson(comment, outputStream);
+```
+
+**Do:**
+
+```java
+// GOOD: Mapping to a decoupled storage DTO
+RevisionNoteData.Comment noteComment = new RevisionNoteData.Comment(comment, serverId);
+gson.toJson(noteComment, outputStream);
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-04: Push Certificate Isolation by Comment Status
+
+> **Rule:** Must strictly map and extract push certificates exclusively when
+> evaluating PUBLISHED comment records.
+>
+> **What:** Push certificates must be strictly mapped and extracted only when
+> evaluating PUBLISHED comment records, as they inherently do not exist in the
+> isolated user refs storing DRAFT data.
+>
+> **Applies To:** NoteDb revision note parsing logic handling cryptographic push
+> metadata extraction.
+>
+> **Why:** Push certificates are persisted exclusively in the main change meta
+> ref. Draft comments are inherently personal and physically stored in isolated
+> refs within `All-Users`. Parsing logic initially failed to explicitly connect
+> the certificate to the published context, risking logically malformed data
+> associations. Failing to adhere to this typically results in **Metadata
+> Corruption**.
+
+**Trap 1: Using negative boolean logic to conditionally process metadata that
+only applies to specific data domains.**
+
+**Don't:**
+
+```java
+if (!draftsOnly) {
+  pushCert = parsePushCert(changeId, raw, p);
+}
+```
+
+**Do:**
+
+```java
+if (status == PatchLineComment.Status.PUBLISHED) {
+  // Certificates exist strictly in the published change context
+  pushCert = parsePushCert(changeId, raw, p);
+} else {
+  pushCert = null;
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T2-05: Industry-Standard Terminology for Deprecated Logic
+
+> **Rule:** Always use standard terminology like 'legacy' to designate older,
+> decoupled schema formats.
+>
+> **What:** Code paths, variables, and methods handling older, decoupled schema
+> formats must use standard terminology like 'legacy' rather than informal
+> slang.
+>
+> **Applies To:** NoteDb schema evolution, serialization backwards-compatibility
+> layers.
+>
+> **Why:** Engineers occasionally used informal suffixes (like 'Homebrew') to
+> designate older serialization logic, which obscured the architectural intent
+> for developers unfamiliar with the slang. Failing to adhere to this typically
+> results in **Maintainability Degradation**.
+
+**Trap 1: Using non-standard slang to differentiate older formatting methods
+from modern JSON ones.**
+
+**Don't:**
+
+```java
+private void buildNoteHomebrew(ChangeNoteUtil noteUtil, OutputStream out) {
+  noteUtil.buildNote(buildCommentMap(), out);
+}
+```
+
+**Do:**
+
+```java
+private void buildNoteLegacy(ChangeNoteUtil noteUtil, OutputStream out) {
+  noteUtil.buildNote(buildCommentMap(), out);
+}
+```
+
+## Chapter: Query Engine Performance & Operator Enforcement
+
+**Context:** This chapter governs the configuration, evaluation, and strict
+syntactic enforcement of query predicates within the repository engine. It
+focuses on decoupling strict user-facing evaluation constraints from background
+observability and implementing phased, non-blocking rollouts to prevent
+configuration deadlocks.
+
+### Summary
+
+| Rule ID   | Principle / Constraint            | Priority | Primary Symptom / |
+:           :                                   :          : Trap              :
+| :-------- | :-------------------------------- | :------- | :---------------- |
+| **T3-01** | Non-Contributor Label Query       | Medium   | Blanket-excluding |
+:           : Isolation                         :          : the 'owner' of    :
+:           :                                   :          : the change from   :
+:           :                                   :          : acting as a       :
+:           :                                   :          : reviewer, even if :
+:           :                                   :          : someone else      :
+:           :                                   :          : authored and      :
+:           :                                   :          : uploaded the      :
+:           :                                   :          : specific patchset :
+:           :                                   :          : being reviewed.   :
+| **T3-02** | Phased Rollout of Strict Query    | Critical | Using a single    |
+:           : Operator Enforcement              :          : feature toggle to :
+:           :                                   :          : immediately       :
+:           :                                   :          : reject bad syntax :
+:           :                                   :          : across the entire :
+:           :                                   :          : system.           :
+| **T3-03** | Memoization of Static             | Medium   | Querying the      |
+:           : Configuration in Query Evaluators :          : global            :
+:           :                                   :          : configuration     :
+:           :                                   :          : object repeatedly :
+:           :                                   :          : within the core   :
+:           :                                   :          : evaluation loop.  :
+| **T3-04** | Metrics Collection Decoupling     | High     | Passing global    |
+:           : from Strict Query Constraints     :          : strict-mode       :
+:           :                                   :          : configuration     :
+:           :                                   :          : booleans into     :
+:           :                                   :          : metrics-gathering :
+:           :                                   :          : query builders,   :
+:           :                                   :          : thereby enforcing :
+:           :                                   :          : user-facing rules :
+:           :                                   :          : on background     :
+:           :                                   :          : telemetry.        :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T3-01: Non-Contributor Label Query Isolation
+
+> **Rule:** Always explicitly exclude the uploader, committer, and author of the
+> current patchset when evaluating `non_contributor` queries, allowing the
+> change owner to act as an independent reviewer if they did not author the
+> specific patchset.
+>
+> **What:** When evaluating label queries for `non_contributor`, the query
+> engine must explicitly exclude votes from the uploader, committer, and author
+> of the current patchset, treating the change owner as a valid independent
+> reviewer only if they didn't author/upload the specific patchset.
+>
+> **Applies To:** Query Engine (`EqualsLabelPredicate.java`), Access Control
+> Policies.
+>
+> **Why:** To enforce the 'four-eyes' principle, the system needed a way to
+> distinguish between a change's code contributors and independent reviewers.
+> The logic explicitly handles cases where the owner and uploader are different
+> people, allowing the owner to act as an independent reviewer. Failing to
+> adhere to this typically results in **Policy Bypass / Self-Approval**.
+
+**Trap 1: Blanket-excluding the 'owner' of the change from acting as a reviewer,
+even if someone else authored and uploaded the specific patchset being
+reviewed.**
+
+**Don't:**
+
+```java
+// BAD: Excluding owner regardless of patchset authorship
+if (account.equals(NON_CONTRIBUTOR_ACCOUNT_ID)) {
+  if (approver.equals(cd.getOwner())) return false;
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Excluding only the uploader, committer, and author of the current patchset
+if (account.equals(NON_CONTRIBUTOR_ACCOUNT_ID)) {
+  if (uploader.equals(approver) || committer.equals(approver) || author.equals(approver)) {
+    return false;
+  }
+}
+```
+
+#### T3-02: Phased Rollout of Strict Query Operator Enforcement
+
+> **Rule:** Never use a single feature toggle to instantly enforce new query
+> syntax constraints; you must decouple read and write configurations to allow
+> manual migrations.
+>
+> **What:** Breaking syntactic requirements for repository queries must be
+> rolled out via decoupled read/write configuration flags to avoid circular
+> dependency deadlocks.
+>
+> **Applies To:** Submit Requirements evaluation and configuration validation
+> layers.
+>
+> **Why:** Instantly enforcing strict query syntax (removing default searches)
+> would break legacy submit configurations. Because a broken configuration
+> blocks all repository submissions, teams would be permanently locked out from
+> submitting the code necessary to fix the broken configuration. Failing to
+> adhere to this typically results in **Repository Deadlock**.
+
+**Trap 1: Using a single feature toggle to immediately reject bad syntax across
+the entire system.**
+
+**Don't:**
+
+*   Instantly rejecting all evaluation queries that lack explicit operators. If
+    a project has a broken legacy config, it instantly fails, blocking all
+    submissions including configuration fixes.
+
+**Do:**
+
+*   Split enforcement into two configs: `requireOperatorForUpdate` (blocks
+    saving new bad configs) and `requireOperatorForEvaluation` (throws errors
+    dynamically). Enable the update block first, migrate legacy rules manually,
+    then enable evaluation blocking.
+
+#### T3-03: Memoization of Static Configuration in Query Evaluators
+
+> **Rule:** Must cache query parsing and evaluation configurations during
+> component initialization instead of polling the configuration object per
+> execution.
+>
+> **What:** Configuration values that govern query parsing and evaluation must
+> be read once during component initialization rather than fetched dynamically
+> per execution.
+>
+> **Applies To:** Query Builders, Evaluators (e.g.,
+> SubmitRequirementsEvaluatorImpl), and high-frequency validation execution
+> paths.
+>
+> **Why:** Historically, reading configuration values directly from the Config
+> object upon every expression validation or evaluation incurred unnecessary CPU
+> overhead, degrading the performance of high-volume operations like submit
+> requirement checks. Failing to adhere to this typically results in
+> **Performance Degradation**.
+
+**Trap 1: Querying the global configuration object repeatedly within the core
+evaluation loop.**
+
+**Don't:**
+
+```java
+public SubmitRequirementExpressionResult evaluateExpression(
+    SubmitRequirementExpression expression, ChangeData changeData) {
+  // BAD: Reading config on every evaluation
+  boolean reqOp = config.getBoolean("submit-requirement", null, "requireOperatorForEvaluation", false);
+  Predicate<ChangeData> predicate = queryBuilderFactory.create(reqOp).parse(expression.expressionString());
+  // ...
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Cache the configuration value at initialization time
+public SubmitRequirementsEvaluatorImpl(...) {
+  this.requireOperatorForEvaluation = config.getBoolean("submit-requirement", null, "requireOperatorForEvaluation", false);
+}
+
+public SubmitRequirementExpressionResult evaluateExpression(
+    SubmitRequirementExpression expression, ChangeData changeData) {
+  Predicate<ChangeData> predicate = queryBuilderFactory.create(this.requireOperatorForEvaluation).parse(expression.expressionString());
+  // ...
+}
+```
+
+**Exceptions:** Dynamic configurations explicitly designed and documented to be
+hot-reloaded without server restarts.
+
+#### T3-04: Metrics Collection Decoupling from Strict Query Constraints
+
+> **Rule:** Always hard-code permissive configuration for metrics-gathering
+> systems to prevent observability failures caused by malformed user metadata.
+>
+> **What:** Metrics gathering systems must bypass strict, user-facing query
+> validation configurations to guarantee that observability pipelines do not
+> fail due to malformed metadata or legacy data.
+>
+> **Applies To:** Metrics exporters (e.g., MergeMetrics), Background Jobs, and
+> Observability Hooks.
+>
+> **Why:** Metrics collection for change merging relied on global server
+> configuration for query parsing. If strict operator evaluation was enabled
+> globally, legacy or malformed submit requirements would cause the metrics
+> collection to throw an exception, halting the entire telemetry pipeline.
+> Failing to adhere to this typically results in **Metrics Data Loss**.
+
+**Trap 1: Passing global strict-mode configuration booleans into
+metrics-gathering query builders, thereby enforcing user-facing rules on
+background telemetry.**
+
+**Don't:**
+
+```java
+// BAD: Tying metrics collection to strict user configurations
+boolean strictEvaluation = config.getBoolean("submit-requirement", null, "requireOperatorForEvaluation", false);
+Predicate<ChangeData> predicate = submitRequirementChangequeryBuilderFactory
+    .create(strictEvaluation)
+    .parse(submitRequirement.submittabilityExpression());
+```
+
+**Do:**
+
+```java
+// GOOD: Hard-code permissive configuration to guarantee metrics stability
+Predicate<ChangeData> predicate = submitRequirementChangequeryBuilderFactory
+    .create(false) // Hardcoded leniency
+    .parse(submitRequirement.submittabilityExpression());
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T4 | Access Control & Impersonation Security - *Access control
+    models dictate the contributor versus independent reviewer roles enforced
+    during query evaluation.*
+*   **Downstream:** T5 | Account Lifecycle & Vulnerability Mitigation - *The
+    query engine's submit requirement evaluations act as the enforcement barrier
+    against orphaned approvals from deleted accounts.*
+
+## Chapter: Access Control & Impersonation Security
+
+**Context:** This chapter governs the evaluation and auditing of impersonated
+access operations (e.g., `SUBMIT_AS` and `RUN_AS`). It defines strict mechanisms
+for isolating the initiating "real user" from the "impersonated user" during
+dynamic permission checks, and mandates automated, centralized logging in NoteDb
+and Protobuf to guarantee uncorrupted audit trails.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T4-01** | Impersonation Log         | Medium   | Allowing an impersonated  |
+:           : Suppression for Reviewer  :          : session to generate a     :
+:           : Updates                   :          : change log message for a  :
+:           :                           :          : reviewer addition.        :
+| **T4-02** | Strict Context Evaluation | Critical | Checking for `SUBMIT_AS`  |
+:           : for Impersonated Access   :          : within a pre-calculated   :
+:           : Control                   :          : collection of permissions :
+:           :                           :          : belonging to the user     :
+:           :                           :          : context being             :
+:           :                           :          : impersonated.             :
+| **T4-03** | Centralized Impersonation | High     | Appending the             |
+:           : Auditing in NoteDb        :          : impersonation string      :
+:           : Commits                   :          : manually in specific API  :
+:           :                           :          : endpoints.                :
+| **T4-04** | System Metadata           | High     | Manually mutating the     |
+:           : Preservation During       :          : change message to inject  :
+:           : Impersonation Updates     :          : audit metadata during a   :
+:           :                           :          : batch update operation.   :
+| **T4-05** | Explicit Distinction of   | Medium   | Adding tracking fields to |
+:           : Effective vs Real         :          : Protobuf messages without :
+:           : Identity in Protobuf      :          : clarifying the            :
+:           : Schemas                   :          : impersonation             :
+:           :                           :          : relationship.             :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T4-01: Impersonation Log Suppression for Reviewer Updates
+
+> **Rule:** Always suppress system-generated impersonation change messages
+> during operations that strictly update reviewers to avoid redundant logs.
+>
+> **What:** System-generated impersonation change messages must be suppressed
+> for operations that explicitly update reviewers, as reviewer updates
+> inherently persist their own metadata.
+>
+> **Applies To:** Gerrit server API, specifically `AddReviewersOp.java` and
+> operations modifying `ReviewerUpdateInfo`.
+>
+> **Why:** Operations like adding a reviewer do not generate standard change
+> messages by default, relying instead on structured reviewer-update lists.
+> Allowing impersonation logic to inject a change message for these operations
+> created redundant, noisy log entries. Failing to adhere to this typically
+> results in **Audit Log Noise**.
+
+**Trap 1: Allowing an impersonated session to generate a change log message for
+a reviewer addition.**
+
+**Don't:**
+
+```java
+change = ctx.getChange();
+if (!accountIds.isEmpty()) {
+  // Proceeds with update, implicitly allowing impersonation messages
+}
+```
+
+**Do:**
+
+```java
+change = ctx.getChange();
+// Reviewer updates do not create change messages. In case of impersonation, we do not want to add an extra message to the log.
+ctx.getUpdate(change.currentPatchSetId()).setSuppressImpersonationMessage(true);
+if (!accountIds.isEmpty()) {
+  // Proceeds with update
+}
+```
+
+#### T4-02: Strict Context Evaluation for Impersonated Access Control
+
+> **Rule:** Never authorize impersonated actions against the bulk permission set
+> of the impersonated user; always evaluate against the real user initiating the
+> impersonation.
+>
+> **What:** When authorizing impersonated actions (e.g., `SUBMIT_AS`),
+> permissions must be verified against the `REAL_USER` (the identity initiating
+> the impersonation) rather than checking the bulk permission set of the
+> impersonated user.
+>
+> **Applies To:** Access control layers, `PermissionBackend`, and operational
+> checkpoints like `MergeOp.java`.
+>
+> **Why:** Evaluating `SUBMIT_AS` against the general permissions pool of the
+> impersonated user context was conceptually flawed and risked allowing
+> unauthorized users to execute privileged actions if the real user lacked
+> explicit impersonation rights. Failing to adhere to this typically results in
+> **Security Bypass**.
+
+**Trap 1: Checking for `SUBMIT_AS` within a pre-calculated collection of
+permissions belonging to the user context being impersonated.**
+
+**Don't:**
+
+```java
+// BAD: Checking against the overall 'can' permission set which may not represent the real user's explicit rights.
+if (!can.contains(ChangePermission.SUBMIT_AS)) {
+  // reject
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Permissions are checked against the user who initiated the impersonation (REAL_USER).
+if (!permissionBackend
+    .user(caller, ImpersonationPermissionMode.REAL_USER)
+    .change(cd)
+    .test(ChangePermission.SUBMIT_AS)) {
+  // reject
+}
+```
+
+#### T4-03: Centralized Impersonation Auditing in NoteDb Commits
+
+> **Rule:** Must centrally inject impersonation clauses directly into NoteDb
+> commit builders to guarantee consistent audit trails across all paths.
+>
+> **What:** Impersonation clauses (e.g., 'Performed by X on behalf of Y') must
+> be automatically centralized within the NoteDb `AbstractChangeUpdate` commit
+> builder, guaranteeing their presence in the system's versioned storage
+> regardless of the operation or whether a user-provided message exists.
+>
+> **Applies To:** NoteDb mutation layers, `AbstractChangeUpdate.java`, and all
+> REST endpoints modifying change states.
+>
+> **Why:** Historically, impersonation messages were manually appended by
+> individual REST API handlers (like `PostReview`). This decentralized approach
+> led to inconsistent audit trails where certain backend or automated operations
+> failed to properly record the real user identity in the underlying Git commit.
+> Failing to adhere to this typically results in **Incomplete Audit Trail**.
+
+**Trap 1: Appending the impersonation string manually in specific API
+endpoints.**
+
+**Don't:**
+
+```java
+// BAD: Decentralized in PostReview.java
+String impersonationClause = String.format("(Posted by %s on behalf of %s)", caller, reviewer);
+if (Strings.isNullOrEmpty(in.message)) {
+  in.message = impersonationClause;
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Centralized in AbstractChangeUpdate.java for all NoteDb writes
+private void addOptionalImpersonationMessage(CommitBuilder cb) {
+  if (realAccountId == null || realAccountId.equals(accountId)) return;
+  String impersonationClause = String.format("(Performed by %s on behalf of %s)", realLoggableName, loggableName);
+  // Safely inject before the footer
+}
+```
+
+**Trap 2: Skipping the impersonation clause if the change update has no
+user-visible commit message body.**
+
+**Don't:**
+
+```java
+int firstFooterLine = indexOfFirstFooterLine(commitMsgLines);
+// BAD: Bailing out if the message has no body
+if (firstFooterLine == 2) return;
+```
+
+**Do:**
+
+```java
+// GOOD: Append the clause regardless of the message body length using standard string manipulation.
+Stream.concat(Arrays.stream(commitMsgLines).limit(firstFooterLine), Stream.of(impersonationClause, ""))...
+```
+
+**Exceptions:** Operations where the `realAccountId` strictly equals the
+`accountId` (no impersonation taking place).
+
+#### T4-04: System Metadata Preservation During Impersonation Updates
+
+> **Rule:** Never manually mutate standard system change messages to document
+> batch update reviewer modifications on behalf of other users.
+>
+> **What:** Manual override of change messages must be avoided when making
+> system-level reviewer updates on behalf of other users, as it corrupts or
+> suppresses structured audit logs.
+>
+> **Applies To:** REST API mutation endpoints, specifically batch updates
+> involving user impersonation (`RUN_AS` / `onBehalfOf`).
+>
+> **Why:** Adding a manual "on behalf of" message to a reviewer modification
+> suppressed the default system message (e.g., "Bob added to reviewers"), wiping
+> out the primary context. Furthermore, tests verifying impersonation broke
+> because reviewer updates bypass standard visible change messages entirely.
+> Failing to adhere to this typically results in **Audit Trail Corruption**.
+
+**Trap 1: Manually mutating the change message to inject audit metadata during a
+batch update operation.**
+
+**Don't:**
+
+```java
+update.setChangeMessage(String.format("Reviewer added by %s on behalf of %s", realUser, impersonatedUser));
+```
+
+**Do:**
+
+*   Allow the backend to populate the structured `ReviewerUpdateInfo`
+    automatically. Do not append manual strings to the batch update.
+
+**Trap 2: Writing tests that assert against visible change messages to verify
+background auditing.**
+
+**Don't:**
+
+```java
+ChangeMessage m = Iterables.getLast(cmUtil.byChange(r.getChange().notes()));
+assertThat(m.getMessage()).contains(expectedReviewerName);
+```
+
+**Do:**
+
+```java
+ChangeMessageInfo lastMessage = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
+assertThat(lastMessage.realAuthor._accountId).isEqualTo(realUser.id().get());
+assertThat(lastMessage.author._accountId).isEqualTo(impersonatedUser.id().get());
+```
+
+#### T4-05: Explicit Distinction of Effective vs Real Identity in Protobuf Schemas
+
+> **Rule:** Must explicitly document Protobuf schema fields storing
+> impersonation audit trails with their exact `RUN_AS` semantics.
+>
+> **What:** Fields storing impersonation audit trails in Protobuf schemas must
+> contain explicit inline documentation demonstrating exact `RUN_AS` semantics.
+>
+> **Applies To:** Protobuf schemas, particularly caching and status update
+> models like `ReviewerStatusUpdateProto`.
+>
+> **Why:** Ambiguity in the protobuf schema left developers unsure which field
+> represented the impersonated account vs the actual administrative account
+> performing the action. Failing to adhere to this typically results in
+> **Misinterpreted Audit Logs**.
+
+**Trap 1: Adding tracking fields to Protobuf messages without clarifying the
+impersonation relationship.**
+
+**Don't:**
+
+```proto
+int32 updated_by = 2;
+int32 real_updated_by = 8;
+```
+
+**Do:**
+
+```proto
+// Account ID of the effective user.
+int32 updated_by = 2;
+// Account ID of the real user. Set when impersonating using the RUN_AS permission.
+// Example: if User X is impersonating user Y, real_updated_by is X.
+int32 real_updated_by = 8;
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Downstream:** T2 | NoteDb Serialization & Schema Evolution - *Centralized
+    impersonation auditing forces structured formatting and strict insertion
+    behaviors on underlying Git commits generated via NoteDb.*
+*   **Downstream:** T12 | Protobuf Schema Management & Conversion - *Protobuf
+    messages tracking access control state require rigorous entity documentation
+    to preserve the distinction between real and effective users during cache
+    serialization.*
+
+## Chapter: Account Lifecycle & Vulnerability Mitigation
+
+**Context:** This theme defines constraints for handling deleted or recreated
+user accounts to prevent identity-cycling exploits, specifically isolating
+validation checks and filtering orphaned approvals during merge operations
+without degrading system performance.
+
+### Summary
+
+| Rule ID   | Principle /       | Priority | Primary Symptom / Trap            |
+:           : Constraint        :          :                                   :
+| :-------- | :---------------- | :------- | :-------------------------------- |
+| **T5-01** | Invalidation of   | Critical | Evaluating submittability by      |
+:           : Approvals from    :          : aggregating all present votes     :
+:           : Deleted Accounts  :          : without verifying if the account  :
+:           :                   :          : IDs associated with those votes   :
+:           :                   :          : still exist.                      :
+| **T5-02** | Deferral of       | High     | Triggering cache lookups for      |
+:           : Expensive Account :          : every account associated with a   :
+:           : Validation to     :          : change during standard page loads :
+:           : Submission Time   :          : or SR recalculations.             :
+| **T5-03** | Graceful          | High     | Throwing a terminal exception as  |
+:           : Degradation for   :          : soon as an orphaned or invalid    :
+:           : Invalid Approval  :          : property is discovered during an  :
+:           : Entities          :          : aggregation check.                :
+| **T5-04** | Positive-Intent   | Medium   | Creating a flag that requires an  |
+:           : Feature Toggles   :          : administrator to explicitly turn  :
+:           : for               :          : *on* a necessary security patch.  :
+:           : Secure-by-Default :          :                                   :
+:           : States            :          :                                   :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T5-01: Invalidation of Approvals from Deleted Accounts
+
+> **Rule:** Always dynamically filter and ignore `PatchSetApproval` votes
+> originating from deleted accounts during submission checks.
+>
+> **What:** Code review approvals (`PatchSetApproval` votes) from deleted
+> accounts must be dynamically filtered and ignored during submission checks to
+> prevent identity-cycling exploits.
+>
+> **Applies To:** Submission validation (`MergeOp.checkSubmitRequirements`),
+> Submit Requirement evaluation, and IAM lifecycle processing.
+>
+> **Why:** An exploit vector was identified where a user could upload a patch,
+> delete their Gerrit account, and recreate an account linked to the same
+> external ID. The new account inherited external group permissions (e.g.,
+> Code-Review+2) and could self-approve the change made by the now-deleted
+> identity, bypassing the 'no self-approval' rule. Failing to adhere to this
+> typically results in **Security Bypass / Self-Approval Exploit**.
+
+**Trap 1: Evaluating submittability by aggregating all present votes without
+verifying if the account IDs associated with those votes still exist.**
+
+**Don't:**
+
+```java
+for (PatchSetApproval psa : cd.currentApprovals()) {
+  if (psa.isApproved()) return true;
+}
+```
+
+**Do:**
+
+```java
+for (PatchSetApproval psa : filterOutApprovalsOfDeletedAccounts(cd.currentApprovals())) {
+  if (psa.isApproved()) return true;
+}
+```
+
+#### T5-02: Deferral of Expensive Account Validation to Submission Time
+
+> **Rule:** Never execute costly cache lookups for account existence during
+> continuous operations; must defer these validations to the final submission
+> phase.
+>
+> **What:** Costly verification checks, such as querying an `AccountCache` for
+> account existence, must not be integrated into continuous operations like
+> general Submit Requirement (SR) evaluation. They must be deferred to the final
+> `MergeOp` submission phase.
+>
+> **Applies To:** Submit Requirements engine and `MergeOp`.
+>
+> **Why:** An initial attempt to fix a vulnerability by filtering deleted
+> account votes inside the continuous Submit Requirements engine caused severe
+> global latency regressions because `AccountCache` was queried excessively. The
+> fix had to be moved strictly to the submit button execution path. Failing to
+> adhere to this typically results in **Severe Latency / System Degradation**.
+
+**Trap 1: Triggering cache lookups for every account associated with a change
+during standard page loads or SR recalculations.**
+
+**Don't:**
+
+*   Embedding `accountCache.get(accountId).isPresent()` inside the highly
+    trafficked Submit Requirement evaluation engine.
+
+**Do:**
+
+*   Executing the account validation loop only within
+    `MergeOp.checkSubmitRequirements()` directly prior to the final merge
+    action.
+
+#### T5-03: Graceful Degradation for Invalid Approval Entities
+
+> **Rule:** Always silently filter out invalid or orphaned entities on a
+> resource rather than hard-failing the operation.
+>
+> **What:** The presence of an invalid or orphaned entity (e.g., an approval
+> from a deleted user) on a resource must not hard-fail operations on that
+> resource. The invalid entity should be filtered out, and the operation allowed
+> to proceed if the remaining valid entities satisfy the requirements.
+>
+> **Applies To:** Access validation, Submit requirement evaluation, and Merge
+> logic.
+>
+> **Why:** A proposed solution suggested throwing a `ResourceConflictException`
+> if *any* deleted account vote was detected on a change. This was rejected
+> because it would block a change from being merged even if it possessed enough
+> valid votes from active users to pass independently. Failing to adhere to this
+> typically results in **Unnecessary Operation Blocking**.
+
+**Trap 1: Throwing a terminal exception as soon as an orphaned or invalid
+property is discovered during an aggregation check.**
+
+**Don't:**
+
+```java
+if (isDeleted(account)) {
+  throw new ResourceConflictException("Approval made by deleted account");
+}
+```
+
+**Do:**
+
+```java
+// Silently filter invalid data and evaluate based on remaining valid data
+Iterable<PatchSetApproval> validVotes = Iterables.filter(votes, v -> !isDeleted(v.accountId()));
+```
+
+#### T5-04: Positive-Intent Feature Toggles for Secure-by-Default States
+
+> **Rule:** Must name and implement feature toggles for security fixes such that
+> the insecure legacy behavior requires an explicit opt-in.
+>
+> **What:** When introducing a critical security or architectural fix guarded by
+> a feature toggle, the toggle's name and logic must represent the opt-in of the
+> *legacy/insecure* behavior, ensuring the new secure behavior is the
+> unconfigured system default.
+>
+> **Applies To:** Experiment Flag definitions (`ExperimentFeaturesConstants`)
+> and Feature Toggle conditionals.
+>
+> **Why:** A security patch introduced a flag named
+> `IGNORE_VOTES_OF_DELETED_ACCOUNTS`. Code reviewers forced an inversion of the
+> flag to `CONSIDER_VOTES_OF_DELETED_ACCOUNTS` so that administrators did not
+> have to explicitly enable the fix, preventing unpatched defaults. Failing to
+> adhere to this typically results in **Accidental Misconfiguration / Security
+> Hole**.
+
+**Trap 1: Creating a flag that requires an administrator to explicitly turn *on*
+a necessary security patch.**
+
+**Don't:**
+
+```java
+if (experimentFeatures.isFeatureEnabled("IGNORE_VOTES_OF_DELETED_ACCOUNTS")) {
+  filterDeletedAccounts();
+}
+```
+
+**Do:**
+
+```java
+if (!experimentFeatures.isFeatureEnabled("CONSIDER_VOTES_OF_DELETED_ACCOUNTS")) {
+  filterDeletedAccounts();
+}
+```
+
+## Chapter: REST API Resource Routing & Payload Optimization
+
+**Context:** REST API endpoints must strictly reflect hierarchical entity
+relationships while optimizing JSON payloads to minimize bandwidth and
+processing latency. Modifications to serialization pipelines must explicitly
+gate expensive permission checks and gracefully handle redundant or experimental
+fields without causing UI ambiguity.
+
+### Summary
+
+| Rule ID   | Principle / Constraint    | Priority | Primary Symptom / Trap    |
+| :-------- | :------------------------ | :------- | :------------------------ |
+| **T6-01** | Explicit Boolean          | Medium   | Using a `toBoolean`       |
+:           : Rendering for             :          : helper that converts      :
+:           : Experimental UI Features  :          : `false` to `null` to      :
+:           :                           :          : minimize payload size,    :
+:           :                           :          : unintentionally leaving   :
+:           :                           :          : the UI client ambiguous   :
+:           :                           :          : about whether the feature :
+:           :                           :          : is disabled or simply     :
+:           :                           :          : unconfigured.             :
+| **T6-02** | Hierarchical REST API     | Medium   | Exposing a sub-resource   |
+:           : Resource Routing          :          : directly under a          :
+:           :                           :          : high-level container      :
+:           :                           :          : because it seems more     :
+:           :                           :          : direct.                   :
+| **T6-03** | Explicit Query Parameters | Medium   | Defaulting a new diff     |
+:           : for Extensible Listing    :          : endpoint to return only   :
+:           : APIs                      :          : file names without        :
+:           :                           :          : requiring the client to   :
+:           :                           :          : ask for that specific     :
+:           :                           :          : format.                   :
+| **T6-04** | Performance Justification | High     | Appending a permission    |
+:           : for Payload-Embedded      :          : verification step to      :
+:           : Permission Checks         :          : every response object     :
+:           :                           :          : regardless of the         :
+:           :                           :          : client's actual need for  :
+:           :                           :          : that data.                :
+| **T6-05** | Omission of Redundant     | Medium   | Falling back to the       |
+:           : Real-User Data in API     :          : primary user object if a  :
+:           : Payloads                  :          : distinct real user isn't  :
+:           :                           :          : found.                    :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T6-01: Explicit Boolean Rendering for Experimental UI Features
+
+> **Rule:** Always explicitly serialize `false` for experimental UI feature
+> flags to ensure strict UI gating, temporarily bypassing standard payload
+> nullification.
+>
+> **What:** REST API responses should temporarily bypass the standard payload
+> optimization (which drops `false` and `null` values) for experimental UI
+> feature flags, explicitly returning `false` to ensure strict UI gating.
+>
+> **Applies To:** REST API JSON serializers (`ChangeJson`), UI Feature Flags.
+>
+> **Why:** To prevent an experimental AI review feature from leaking in the UI
+> when permission was denied, the API was modified to explicitly serialize the
+> `canAiReview = false` state rather than omitting it as the framework usually
+> does for falsy values. Failing to adhere to this typically results in **UI
+> Feature Leakage**.
+
+**Trap 1: Using a `toBoolean` helper that converts `false` to `null` to minimize
+payload size, unintentionally leaving the UI client ambiguous about whether the
+feature is disabled or simply unconfigured.**
+
+**Don't:**
+
+```java
+// BAD: Omitting false values from payload
+info.canAiReview = toBoolean(permissionBackend.test(AI_REVIEW));
+```
+
+**Do:**
+
+```java
+// GOOD: Explicitly forcing false to be serialized
+info.canAiReview = permissionBackend.test(AI_REVIEW) ? true : false;
+```
+
+**Exceptions:** This is a temporary technical debt exception strictly permitted
+until the `experiments.UiFeature__enable_ai_chat` feature flag is sunsetted.
+
+--------------------------------------------------------------------------------
+
+#### T6-02: Hierarchical REST API Resource Routing
+
+> **Rule:** Never expose sub-resources at the root level; always nest them under
+> their specific context-defining parent resources.
+>
+> **What:** REST API endpoints must reflect strict hierarchical entity
+> relationships. Sub-resources must be nested under their specific
+> context-defining parent resources rather than being exposed at the root level.
+>
+> **Applies To:** REST API Controller definitions, API endpoint URL schemas, and
+> resource mapping.
+>
+> **Why:** Designing flat API endpoints (e.g., requesting a file diff directly
+> from the project root) created ambiguities, as the backend could not
+> definitively validate the file's existence without knowing the specific commit
+> context. Failing to adhere to this typically results in **Ambiguous Resource
+> Resolution**.
+
+**Trap 1: Exposing a sub-resource directly under a high-level container because
+it seems more direct.**
+
+**Don't:**
+
+```text
+// BAD: Missing the commit context required to resolve the file
+GET /projects/{project}/files/{file}/diff?old={sha1}&new={sha1}
+```
+
+**Do:**
+
+```text
+// GOOD: Nesting the file diff under the explicit commit it belongs to
+GET /projects/{project}/commits/{commit-id}/files/{file}/diff?base={sha1}
+```
+
+--------------------------------------------------------------------------------
+
+#### T6-03: Explicit Query Parameters for Extensible Listing APIs
+
+> **Rule:** Must require an explicit query parameter for REST endpoints designed
+> to return sparse payloads to preserve future backwards-compatibility.
+>
+> **What:** When creating a REST API endpoint that deliberately returns a sparse
+> payload (e.g., listing only file names), it must require an explicit query
+> parameter indicating that reduced scope, allowing future backwards-compatible
+> payload expansions.
+>
+> **Applies To:** REST API Endpoint design, list views, and differential payload
+> returns.
+>
+> **Why:** Endpoints originally designed to return sparse data became locked
+> into that format. When full data payloads were later needed, developers had to
+> create entirely new endpoints because the default behavior could not be safely
+> changed. Failing to adhere to this typically results in **API Backward
+> Incompatibility**.
+
+**Trap 1: Defaulting a new diff endpoint to return only file names without
+requiring the client to ask for that specific format.**
+
+**Don't:**
+
+```text
+// BAD: Locks the API into only ever returning filenames
+GET /projects/{project}/commits/{commit-id}/diff
+```
+
+**Do:**
+
+```text
+// GOOD: Requires the client to acknowledge the limited scope
+GET /projects/{project}/commits/{commit-id}/diff?nameOnly
+```
+
+**Exceptions:** Endpoints whose domain definition inherently restricts them to
+simple lists (e.g., `GET /ids`).
+
+--------------------------------------------------------------------------------
+
+#### T6-04: Performance Justification for Payload-Embedded Permission Checks
+
+> **Rule:** Always gate newly injected backend permission evaluations in heavily
+> trafficked JSON payloads behind explicit client options to prevent global
+> latency degradation.
+>
+> **What:** Injecting new backend permission evaluations into heavily trafficked
+> REST API JSON payloads must be critically analyzed against the latency penalty
+> and explicitly gated by granular request options.
+>
+> **Applies To:** REST API response serializers, specifically `ChangeJson`
+> formatting pipelines.
+>
+> **Why:** Adding mandatory permission checks (like AI_REVIEW) to core change
+> listing APIs increased the time complexity of the request for all users,
+> introducing latency that could have been avoided by using a separate API or a
+> request parameter gate. Failing to adhere to this typically results in **API
+> Latency Degradation**.
+
+**Trap 1: Appending a permission verification step to every response object
+regardless of the client's actual need for that data.**
+
+**Don't:**
+
+```java
+// BAD: Unconditional permission check slows down all queries
+out.canAiReview = permissionBackend.user(user).test(AI_REVIEW);
+```
+
+**Do:**
+
+```java
+// GOOD: Gating the expensive check behind explicit client options and experiment flags
+if (has(CURRENT_ACTIONS) && experiments.isEnabled(ENABLE_AI_CHAT)) {
+  out.canAiReview = permissionBackend.user(user).test(AI_REVIEW);
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T6-05: Omission of Redundant Real-User Data in API Payloads
+
+> **Rule:** Must omit the `real_updated_by` field (set to null) when serializing
+> objects if it is identical to the primary `updated_by` field to conserve
+> bandwidth.
+>
+> **What:** When serializing objects that support impersonation (like
+> `ReviewerUpdateInfo`), the `real_updated_by` field must be omitted (set to
+> null) if it is identical to the `updated_by` field, rather than duplicating
+> the payload data.
+>
+> **Applies To:** REST API serialization layers, specifically `ChangeJson.java`
+> and TypeScript interface definitions.
+>
+> **Why:** Returning the same account identity for both the primary actor and
+> the real actor bloated the JSON payload. By omitting it, the API clearly
+> signals when impersonation has not occurred while conserving bandwidth.
+> Failing to adhere to this typically results in **Payload Bloat**.
+
+**Trap 1: Falling back to the primary user object if a distinct real user isn't
+found.**
+
+**Don't:**
+
+```java
+// BAD: Duplicating the object if no distinct real user exists
+new ReviewerUpdateInfo(
+  c.date(),
+  accountLoader.get(c.updatedBy()),
+  c.realUpdatedBy().map(accountLoader::get).orElseGet(() -> accountLoader.get(c.updatedBy())),
+  ...
+```
+
+**Do:**
+
+```java
+// GOOD: Return null to omit the field entirely
+new ReviewerUpdateInfo(
+  c.date(),
+  accountLoader.get(c.updatedBy()),
+  c.realUpdatedBy().map(accountLoader::get).orElse(null),
+  ...
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T4 | Access Control & Impersonation Security - *API
+    serialization layers invoke permission checks and impersonation contexts
+    that must be explicitly scoped to prevent payload bloat and latency
+    degradation.*
+
+## Chapter: Build Infrastructure & Dependency Alignment
+
+**Context:** This chapter governs the configuration, versioning, and migration
+of the build system infrastructure, focusing heavily on Bazel modules (`bzlmod`)
+and dependency graphs. It establishes constraints to ensure strict version
+alignment, prevent compliance pipeline stalls, and maintain reliable developer
+bootstrapping environments.
+
+### Summary
+
+| Rule ID   | Principle / Constraint   | Priority | Primary Symptom / Trap     |
+| :-------- | :----------------------- | :------- | :------------------------- |
+| **T7-01** | Bazelisk Version         | High     | Relying on deprecated      |
+:           : Constraints for Bzlmod   :          : Bazelisk versions while    :
+:           : Migrations               :          : altering legacy Bazel      :
+:           :                          :          : boundary markers           :
+:           :                          :          : (`WORKSPACE`).             :
+| **T7-02** | Strict Version Alignment | High     | Pinning a specific older   |
+:           : in Bazel Dependency      :          : minor version of a library :
+:           : Graphs                   :          : for a single module        :
+:           :                          :          : without checking global or :
+:           :                          :          : internal mirror alignment. :
+| **T7-03** | Synchronized Dependency  | Medium   | Deprecating a build        |
+:           : Manifest Documentation   :          : configuration file while   :
+:           :                          :          : leaving stale references   :
+:           :                          :          : to its usage in textual    :
+:           :                          :          : documentation.             :
+| **T7-04** | String Typing for Bazel  | High     | Passing a native boolean   |
+:           : amend_artifact           :          : `True` to a strictly       :
+:           : force_version            :          : string-typed attribute in  :
+:           :                          :          : a Bazel macro.             :
+| **T7-05** | Segregation of External  | Medium   | Placing non-internal       |
+:           : Dependencies for         :          : dependencies alongside     :
+:           : Compliance Bypassing     :          : standard organizational    :
+:           :                          :          : dependencies in global     :
+:           :                          :          : dependency files.          :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T7-01: Bazelisk Version Constraints for Bzlmod Migrations
+
+> **Rule:** Always upgrade `bazelisk` to at least v1.27.0 when migrating to
+> `bzlmod` and removing or renaming the root `WORKSPACE` file.
+>
+> **What:** When migrating to Bazel Modules (`bzlmod`) and removing or renaming
+> the root `WORKSPACE` file, `bazelisk` must be upgraded to at least v1.27.0 to
+> correctly detect the `.bazelversion` file.
+>
+> **Applies To:** Build System / CI pipelines / Developer environment
+> bootstrapping.
+>
+> **Why:** During the migration to Bzlmod, the `WORKSPACE` file was renamed to
+> `WORKSPACE.bzlmod`. Older versions of `bazelisk` failed to locate the project
+> root without a file strictly named `WORKSPACE`, ignoring the pinned
+> `.bazelversion` and downloading the newest incompatible Bazel binary (e.g.,
+> v9.0.1). Failing to adhere to this typically results in **Build Failure /
+> Incorrect Toolchain**.
+
+**Trap 1: Relying on deprecated Bazelisk versions while altering legacy Bazel
+boundary markers (`WORKSPACE`).**
+
+**Don't:**
+
+*   Attempting to build a Bzlmod-enabled project with an outdated `bazelisk`
+    that defaults to Bazel 9.x when `WORKSPACE` is missing.
+
+**Do:**
+
+*   Require `bazelisk >= 1.27.0` which resolves `.bazelversion` even in purely
+    `bzlmod`-driven repositories lacking a `WORKSPACE` file.
+
+#### T7-02: Strict Version Alignment in Bazel Dependency Graphs
+
+> **Rule:** Must explicitly align external JVM dependencies to a single
+> validated minor version across the entire build graph.
+>
+> **What:** External JVM dependencies must strictly align with a single
+> validated minor version to prevent build system conflicts in
+> `rules_jvm_external` and ensure compatibility with internal monolithic
+> dependency mirrors.
+>
+> **Applies To:** Bazel build configurations, `WORKSPACE`, and `deps.toml`
+> defining external library versions.
+>
+> **Why:** Introducing conflicting minor versions of transitive libraries (e.g.,
+> pulling Bytebuddy 1.18.4 while another module expects 1.18.5) caused the build
+> system to trigger duplicate version checks and fail the build graph
+> resolution. Failing to adhere to this typically results in **Build Failure /
+> Duplicate Version**.
+
+**Trap 1: Pinning a specific older minor version of a library for a single
+module without checking global or internal mirror alignment.**
+
+**Don't:**
+
+*   Declaring `bytebuddy:1.18.4` in the JGit servlet dependency chain while the
+    rest of the build relies on `1.18.5`.
+
+**Do:**
+
+*   Upgrading the local module dependency to match the globally available
+    version: `bytebuddy:1.18.5`, ensuring a single version traverses the entire
+    graph.
+
+#### T7-03: Synchronized Dependency Manifest Documentation
+
+> **Rule:** Always update developer documentation atomically in the exact same
+> commit when modifying or deprecating dependency manifests.
+>
+> **What:** Build system developer documentation must be atomically updated in
+> the exact same commit whenever dependency declaration manifests or build
+> targets are migrated or deprecated.
+>
+> **Applies To:** Build system configuration (e.g., Bazel WORKSPACE/MODULE
+> files) and corresponding developer-facing documentation (e.g., dev-bazel.txt).
+>
+> **Why:** During a migration of dependency management workflows, the legacy
+> dependency configuration file was emptied, but the developer documentation
+> still explicitly instructed engineers to reference the deprecated file path
+> and run outdated Bazel pin targets. Failing to adhere to this typically
+> results in **Developer Process Failure**.
+
+**Trap 1: Deprecating a build configuration file while leaving stale references
+to its usage in textual documentation.**
+
+**Don't:**
+
+*   Emptying legacy configuration files (e.g., `tools/deps.bzl`) but failing to
+    update documentation containing old execution targets like `bazel run
+    @gerrit_deps//:pin`.
+
+**Do:**
+
+*   Atomically updating documentation to reference the new configuration files
+    (e.g., `tools/deps.toml`) and correctly mapped targets like `bazel run
+    @external_deps//:pin` within the deprecation commit.
+
+#### T7-04: String Typing for Bazel amend_artifact force_version
+
+> **Rule:** Never pass a Starlark boolean to the `force_version` attribute in
+> `rules_jvm_external`; it must be strictly typed as a string.
+>
+> **What:** The `force_version` attribute within the `rules_jvm_external` Bazel
+> module extension must be strictly passed as a string, not a native Starlark
+> boolean.
+>
+> **Applies To:** Bazel build scripts (`MODULE.bazel`) leveraging the
+> `rules_jvm_external` extension for Maven dependency resolution.
+>
+> **Why:** An attempt was made to enforce root-level dependency precedence over
+> layered modules using a boolean data type. The underlying extension API
+> structurally requires a string representation of the boolean value. Failing to
+> adhere to this typically results in **Bazel Evaluation Failure**.
+
+**Trap 1: Passing a native boolean `True` to a strictly string-typed attribute
+in a Bazel macro.**
+
+**Don't:**
+
+```python
+maven.amend_artifact(
+    name = "external_deps",
+    coordinates = coord,
+    force_version = True,
+)
+```
+
+**Do:**
+
+```python
+maven.amend_artifact(
+    name = "external_deps",
+    coordinates = coord,
+    force_version = "true",
+)
+```
+
+#### T7-05: Segregation of External Dependencies for Compliance Bypassing
+
+> **Rule:** Must isolate external dependencies into dedicated configuration
+> files to avoid triggering unnecessary internal compliance reviews.
+>
+> **What:** Dependencies not utilized internally by the host organization must
+> be isolated into dedicated build configurations to avoid triggering
+> unnecessary compliance reviews.
+>
+> **Applies To:** Bazel workspace configurations, dependency management (e.g.,
+> deps.bzl, nongoogle.bzl).
+>
+> **Why:** Routine version bumps to open-source libraries (e.g., H2 database)
+> that were only used in external distributions triggered mandatory internal
+> 'Library-Compliance' votes, blocking development velocity and complicating
+> functional PRs. Failing to adhere to this typically results in **Build
+> Pipeline Stalls**.
+
+**Trap 1: Placing non-internal dependencies alongside standard organizational
+dependencies in global dependency files.**
+
+**Don't:**
+
+*   Define the H2 database dependency within the global `tools/deps.bzl` file
+    alongside core infrastructure libraries.
+
+**Do:**
+
+*   Move the H2 database dependency into a segregated `tools/nongoogle.bzl` file
+    to explicitly demarcate its exclusion from internal compliance checks.
+
+**Trap 2: Bundling dependency location moves with functional, logical code
+changes.**
+
+**Don't:**
+
+*   Submitting a single patchset that upgrades the H2 database version, changes
+    database logic, and moves the dependency to nongoogle.bzl.
+
+**Do:**
+
+*   Submit the structural move to `nongoogle.bzl` as an independent parent
+    change to keep the functional logic updates completely decoupled.
+
+**Exceptions:** Dependencies that share utilization across both internal
+infrastructure and external deployments.
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T13 | Compliance & CLA Enforcement - *Organizational
+    compliance rules and automated pipelines directly dictate the necessity of
+    structurally segregating external dependencies in the build graph.*
+
+## Chapter: Asynchronous Notification Consistency
+
+**Context:** Asynchronous notification pipelines must capture exact entity state
+at the moment of initialization rather than fetching it dynamically during
+execution. This guarantees data consistency and prevents delayed background
+tasks from inadvertently processing future, out-of-band updates from persistent
+storage.
+
+### Summary
+
+| Rule ID   | Principle / Constraint | Priority | Primary Symptom / Trap       |
+| :-------- | :--------------------- | :------- | :--------------------------- |
+| **T8-01** | Snapshot State Capture | High     | Passing database identifiers |
+:           : for Asynchronous       :          : to a background task and     :
+:           : Notifications          :          : reloading the entity during  :
+:           :                        :          : execution.                   :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T8-01: Snapshot State Capture for Asynchronous Notifications
+
+> **Rule:** Must materialize and pass the exact entity state synchronously to
+> asynchronous background tasks before handoff.
+>
+> **What:** Asynchronous tasks (like email dispatchers) must capture the exact
+> entity state at initialization instead of fetching it from the persistent
+> store dynamically during execution.
+>
+> **Applies To:** Asynchronous notification pipelines and background task
+> executors.
+>
+> **Why:** A delayed asynchronous email task would query NoteDb directly during
+> execution. If another thread performed an update during the delay, the email
+> would accidentally pull 'too-new' state (e.g., a mismatched subject line) that
+> belonged to the subsequent transaction. Failing to adhere to this typically
+> results in **Race Condition / Stale Data**.
+
+**Trap 1: Passing database identifiers to a background task and reloading the
+entity during execution.**
+
+**Don't:**
+
+```java
+public ChangeEmailImpl(@Provided EmailArguments args, Project.NameKey project, Change.Id changeId) {
+  // Anti-pattern: Storing IDs and reading the database asynchronously later
+  this.changeId = changeId;
+}
+```
+
+**Do:**
+
+```java
+public ChangeEmailImpl(@Provided EmailArguments args, Change change) {
+  // Materialize state synchronously before handing off to the async thread
+  this.changeData = args.changeDataFactory.create(change);
+  this.change = changeData.change();
+}
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T2 | NoteDb Serialization & Schema Evolution - *NoteDb acts as
+    the persistent storage layer that asynchronous notifications must avoid
+    querying during delayed executions to prevent reading advanced, out-of-band
+    states.*
+
+## Chapter: Test Suite Configuration & Isolation
+
+**Context:** This chapter dictates the implementation of parameterized
+integration tests, strict Guice dependency injection, and granular flaky test
+isolation. It ensures deterministic test coverage across multiple backend
+configurations without leaking generic APIs or brittle global states.
+
+### Summary
+
+| Rule ID   | Principle / Constraint   | Priority | Primary Symptom / Trap     |
+| :-------- | :----------------------- | :------- | :------------------------- |
+| **T9-01** | Method-Level Granularity | Medium   | Using class-level          |
+:           : for Integration Test     :          : isolation to fix a single  :
+:           : Sandboxing               :          : flaky test, penalizing the :
+:           :                          :          : execution time of the      :
+:           :                          :          : entire suite.              :
+| **T9-02** | Test Class Interface     | Medium   | Making a test class        |
+:           : Segregation              :          : implement `Provider<T>`    :
+:           :                          :          : merely to bind a variable  :
+:           :                          :          : in Guice.                  :
+| **T9-03** | Strict Propagation of    | High     | Ignoring the parameterized |
+:           : Parameterized Test       :          : configuration in Guice     :
+:           : Configurations           :          : module installation.       :
+| **T9-04** | Direct Instance Binding  | Low      | Binding a locally          |
+:           : in Guice Modules         :          : available instance by      :
+:           :                          :          : wrapping it in a Guice     :
+:           :                          :          : Provider.                  :
+| **T9-05** | Framework-Driven Test    | High     | Flipping global static     |
+:           : Parametrization          :          : variables inside an        :
+:           :                          :          : inherited test class's     :
+:           :                          :          : @Before method.            :
+| **T9-06** | Declarative Dependency   | Medium   | Manually invoking the      |
+:           : Injection via @Inject    :          : injector inside a method   :
+:           : over Manual Resolution   :          : body.                      :
+| **T9-07** | Deterministic Build      | Medium   | Checking for transient     |
+:           : Environment Detection in :          : directories like 'build'   :
+:           : Tests                    :          : or 'buck-out' to dictate   :
+:           :                          :          : conditional test logic.    :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T9-01: Method-Level Granularity for Integration Test Sandboxing
+
+> **Rule:** Always apply the `@Sandboxed` annotation strictly to specific flaky
+> or state-dependent test methods rather than applying it globally to the test
+> class.
+>
+> **What:** The `@Sandboxed` annotation must be applied directly to specific
+> flaky or state-dependent test methods rather than at the class level.
+>
+> **Applies To:** Integration test framework (`AbstractDaemonTest`).
+>
+> **Why:** Applying the sandbox annotation to an entire class spins up a
+> completely new database and daemon instance for every single test in the
+> class, drastically increasing test execution time for tests that do not
+> actually suffer from cross-test state interference. Failing to adhere to this
+> typically results in **Test Suite Performance Degradation**.
+
+**Trap 1: Using class-level isolation to fix a single flaky test, penalizing the
+execution time of the entire suite.**
+
+**Don't:**
+
+```java
+// BAD: Class-level sandboxing
+@Sandboxed
+public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
+  @Test public void testA() { ... }
+  @Test public void testB() { ... }
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Method-level sandboxing
+public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
+  @Test public void testA() { ... }
+
+  @Test
+  @Sandboxed
+  public void flakyTestB() { ... }
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T9-02: Test Class Interface Segregation
+
+> **Rule:** Never implement generic framework interfaces directly on test base
+> classes to satisfy dependency injection bindings.
+>
+> **What:** Test base classes must not directly implement generic framework
+> interfaces (like Guice's Provider<T>) to satisfy dependency injection
+> bindings, as this leaks generic methods into the test class's public API.
+>
+> **Applies To:** Guice Dependency Injection, Unit/Integration Test
+> Infrastructure.
+>
+> **Why:** Tests were modified to implement Guice Provider interfaces directly.
+> This introduced overly generic methods (like `get()`) onto the base class,
+> muddying the class's API footprint and risking unintended shadowing. Failing
+> to adhere to this typically results in **Interface Pollution**.
+
+**Trap 1: Making a test class implement `Provider<T>` merely to bind a variable
+in Guice.**
+
+**Don't:**
+
+```java
+public abstract class AbstractChangeNotesTest extends GerritBaseTests implements Provider<Config> {
+  @ConfigSuite.Parameter
+  public Config testConfig;
+
+  @Override
+  public Config get() {
+    return testConfig != null ? testConfig : new Config();
+  }
+}
+```
+
+**Do:**
+
+```java
+// Use framework utilities like Providers.of() or nested classes instead of direct interface implementation.
+bind(Config.class).annotatedWith(GerritServerConfig.class).toProvider(Providers.of(testConfig));
+```
+
+--------------------------------------------------------------------------------
+
+#### T9-03: Strict Propagation of Parameterized Test Configurations
+
+> **Rule:** Must explicitly inject the dynamically provided configuration object
+> into the system under test to ensure valid parametrization.
+>
+> **What:** When utilizing a parameterized test suite, explicitly inject the
+> provided configuration object into the system under test rather than
+> instantiating a default state, which silently bypasses test parameters.
+>
+> **Applies To:** ConfigSuite runner; specifically Guice module installation and
+> parameter injection.
+>
+> **Why:** A test setup block instantiated a new, empty Config rather than using
+> the configuration variant provided by the test suite framework, completely
+> nullifying the multi-backend test coverage. Failing to adhere to this
+> typically results in **False Positive Test Coverage**.
+
+**Trap 1: Ignoring the parameterized configuration in Guice module
+installation.**
+
+**Don't:**
+
+```java
+@ConfigSuite.Parameter
+public Config testConfig;
+
+// BAD: Installing with a default empty config, ignoring the test runner variations.
+install(NoteDbModule.forTest(new Config()));
+```
+
+**Do:**
+
+```java
+@ConfigSuite.Parameter
+public Config testConfig;
+
+// GOOD: Passing the dynamically injected testConfig down into the module.
+install(NoteDbModule.forTest(testConfig));
+```
+
+--------------------------------------------------------------------------------
+
+#### T9-04: Direct Instance Binding in Guice Modules
+
+> **Rule:** Always use the `.toInstance()` DSL when binding pre-instantiated
+> objects in Guice modules.
+>
+> **What:** When binding a pre-existing object instance in a Guice module, use
+> the direct `.toInstance()` DSL instead of wrapping it in redundant Provider
+> abstractions.
+>
+> **Applies To:** Guice module configuration (`configure()` blocks).
+>
+> **Why:** Engineers wrapped pre-instantiated variables inside `Providers.of()`
+> and bound them via `.toProvider()`, creating unnecessary object allocation and
+> visually cluttered dependency setups. Failing to adhere to this typically
+> results in **Boilerplate / Decreased Readability**.
+
+**Trap 1: Binding a locally available instance by wrapping it in a Guice
+Provider.**
+
+**Don't:**
+
+```java
+bind(Config.class).annotatedWith(GerritServerConfig.class)
+    .toProvider(Providers.of(testConfig));
+```
+
+**Do:**
+
+```java
+bind(Config.class).annotatedWith(GerritServerConfig.class)
+    .toInstance(testConfig);
+```
+
+--------------------------------------------------------------------------------
+
+#### T9-05: Framework-Driven Test Parametrization
+
+> **Rule:** Must utilize declarative suite parameterization (e.g.,
+> `@ConfigSuite`) to drive data variations, completely avoiding mutable static
+> state and test hierarchy hacks.
+>
+> **What:** Test configurations intended to exercise multiple backend
+> representations must use explicit framework parameterization (e.g.,
+> ConfigSuite) rather than manual subclassing combined with mutable global
+> state.
+>
+> **Applies To:** Test suites covering variable storage backends (e.g., NoteDb
+> legacy format vs. JSON).
+>
+> **Why:** Developers previously verified new data formats by manually creating
+> child test classes that flipped a static global boolean. This broke test
+> isolation and created brittle, hard-to-follow test hierarchies. Failing to
+> adhere to this typically results in **Test State Leakage / Brittle
+> Inheritance**.
+
+**Trap 1: Flipping global static variables inside an inherited test class's
+@Before method.**
+
+**Don't:**
+
+```java
+public class ChangeNotesJsonTest extends ChangeNotesTest {
+  @Before
+  public void setJson() {
+    ChangeNoteUtil.writeJsonDefault = true;
+  }
+}
+```
+
+**Do:**
+
+```java
+@RunWith(ConfigSuite.class)
+public abstract class AbstractChangeNotesTest {
+  @ConfigSuite.Default
+  public static Config legacyConfig() {
+    // return legacy config
+  }
+
+  @ConfigSuite.Config
+  public static Config jsonConfig() {
+    // return json config
+  }
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T9-06: Declarative Dependency Injection via @Inject over Manual Resolution
+
+> **Rule:** Always enforce automated component lifecycles using `@Inject`
+> annotations rather than querying the Guice Injector directly inside method
+> bodies.
+>
+> **What:** Rely on the framework's automatic member injection lifecycle (e.g.,
+> `@Inject`) rather than manually fetching dependencies via the Injector locator
+> pattern.
+>
+> **Applies To:** Guice Dependency Injection within Test instances and Service
+> classes.
+>
+> **Why:** Tests routinely invoked `injector.getInstance()` inside helper
+> methods. This obfuscated dependency requirements and broke cleanly when the
+> test runner reset the injector under varying configurations. Failing to adhere
+> to this typically results in **Hidden Dependencies / Lifecycle Desync**.
+
+**Trap 1: Manually invoking the injector inside a method body.**
+
+**Don't:**
+
+```java
+void someTestMethod() {
+  ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+  // use noteUtil
+}
+```
+
+**Do:**
+
+```java
+@Inject
+private ChangeNoteUtil noteUtil;
+
+void someTestMethod() {
+  // use noteUtil directly
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T9-07: Deterministic Build Environment Detection in Tests
+
+> **Rule:** Must establish build environment parameters using static source
+> control metadata files, strictly ignoring volatile build output directories.
+>
+> **What:** Test setup frameworks must not infer project environments or build
+> systems based on transient output directories that may not yet exist.
+>
+> **Applies To:** Integration and Plugin test acceptance frameworks.
+>
+> **Why:** The test framework erroneously classified Buck projects as Maven
+> projects if the repository was freshly cloned and the `buck-out` directory
+> hadn't been generated by an initial build yet, causing tests to misconfigure
+> the environment. Failing to adhere to this typically results in **Flaky
+> Environment Initialization**.
+
+**Trap 1: Checking for transient directories like 'build' or 'buck-out' to
+dictate conditional test logic.**
+
+**Don't:**
+
+```java
+boolean isMaven = !Files.exists(pluginRoot.resolve("buck-out"));
+```
+
+**Do:**
+
+*   Rely strictly on deterministic source control metadata (e.g., checking for a
+    `pom.xml` or `BUCK` file) or explicitly scope out untested environments.
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T7 | Build Infrastructure & Dependency Alignment -
+    *Deterministic test environment detection relies on the core build system
+    declarations defined here.*
+*   **Downstream:** T2 | NoteDb Serialization & Schema Evolution - *NoteDb
+    legacy versus JSON backend variations natively consume the parameterized
+    suite structures enforced by this domain.*
+
+## Chapter: Diff Engine Computation Limits
+
+**Context:** Explicitly tracking timeouts and fallback states for expensive file
+diff computations guarantees that frontend clients are alerted to incomplete
+operations. This prevents silent data omission and ensures accurate
+representation of modified files in the user interface.
+
+### Summary
+
+| Rule ID    | Principle / Constraint | Priority | Primary Symptom / Trap      |
+| :--------- | :--------------------- | :------- | :-------------------------- |
+| **T10-01** | Explicit Signaling of  | High     | Discarding files from the   |
+:            : Computation Limits in  :          : result list or relying on   :
+:            : File Diffs             :          : opaque negative cache hits  :
+:            :                        :          : when computation limits are :
+:            :                        :          : exceeded.                   :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T10-01: Explicit Signaling of Computation Limits in File Diffs
+
+> **Rule:** Always explicitly flag timed-out file diffs in the API payload
+> instead of silently omitting them or grouping them into generic negative
+> caches.
+>
+> **What:** Expensive processes like file diff algorithms must respect strict
+> timeouts and explicitly mark aborted files in the API response using a
+> dedicated flag, rather than dropping the file or classifying it generically.
+>
+> **Applies To:** Diff algorithms, `FileInfo` REST API responses, and
+> `FileDiffCache` outputs.
+>
+> **Why:** When a file's diff took too long to compute, the system previously
+> marked it with a generic 'negative' cache flag or dropped it, which misled
+> users into believing the file was unmodified instead of alerting them to the
+> computation failure. Failing to adhere to this typically results in **Silent
+> Data Omission / Misleading UI**.
+
+**Trap 1: Discarding files from the result list or relying on opaque negative
+cache hits when computation limits are exceeded.**
+
+**Don't:**
+
+```java
+// BAD: Silently omitting the timed-out file
+if (fileDiffOutput.isEmpty() || fileDiffOutput.isNegative()) {
+  continue;
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Including the file but explicitly flagging it as too expensive
+if (fileDiffOutput.isEmpty() && !fileDiffOutput.isNegative()) {
+  continue;
+}
+// API serializes the result with tooExpensiveToCompute = true
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Downstream:** T6 | REST API Resource Routing & Payload Optimization -
+    *Serializing explicit computation limit flags directly dictates the schema
+    and payload structure of REST API `FileInfo` responses.*
+
+## Chapter: Concurrent Formatting & Caching
+
+**Context:** *Parallelizing expensive API formatting requires strict adherence
+to explicit thread-local context propagation, cache isolation for mutated
+elements, and collection pre-allocation. Failing to manage concurrency strictly
+leads to context leakage across parallel streams, memory overhead, and poisoned
+UI states.*
+
+### Summary
+
+| Rule ID    | Principle / Constraint          | Priority | Primary Symptom / |
+:            :                                 :          : Trap              :
+| :--------- | :------------------------------ | :------- | :---------------- |
+| **T11-01** | Explicit User Context           | High     | Calling a         |
+:            : Propagation in Parallel Streams :          : Provider to fetch :
+:            :                                 :          : the current user  :
+:            :                                 :          : context directly  :
+:            :                                 :          : inside the        :
+:            :                                 :          : mapping function  :
+:            :                                 :          : of a parallel     :
+:            :                                 :          : stream.           :
+| **T11-02** | Isolation of Mutated Query      | High     | Caching all       |
+:            : Elements in Shared Caches       :          : elements of a     :
+:            :                                 :          : query result      :
+:            :                                 :          : indiscriminately  :
+:            :                                 :          : before applying   :
+:            :                                 :          : list-level        :
+:            :                                 :          : pagination logic. :
+| **T11-03** | Pre-allocation of Thread-Safe   | Medium   | Initializing a    |
+:            : Caches for Large Queries        :          : ConcurrentHashMap :
+:            :                                 :          : using the default :
+:            :                                 :          : empty constructor :
+:            :                                 :          : despite knowing   :
+:            :                                 :          : the size of the   :
+:            :                                 :          : input list.       :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T11-01: Explicit User Context Propagation in Parallel Streams
+
+> **Rule:** Always resolve thread-local user contexts on the main execution
+> thread and explicitly pass them into parallel worker threads.
+>
+> **What:** When processing data using Java parallel streams, thread-local user
+> contexts (like `CurrentUser`) must be resolved on the main thread prior to
+> stream execution and explicitly passed to the worker threads.
+>
+> **Applies To:** API response formatting, `parallelStream()` operations, and
+> any Guice Provider resolution requiring request scope.
+>
+> **Why:** Relying on request-scoped Guice providers within parallel stream
+> lambdas caused context leaks, where worker threads inherited the wrong
+> thread-local state or threw out-of-scope exceptions. Failing to adhere to this
+> typically results in **Context Loss / State Leakage**.
+
+**Trap 1: Calling a Provider to fetch the current user context directly inside
+the mapping function of a parallel stream.**
+
+**Don't:**
+
+```java
+// BAD: Evaluating the provider dynamically inside a parallel worker thread
+list.parallelStream().map(item -> {
+  return format(item, userProvider.get());
+}).collect(toList());
+```
+
+**Do:**
+
+```java
+// GOOD: Resolving context on the main thread and passing it explicitly
+CurrentUser user = userProvider.get();
+list.parallelStream().map(item -> {
+  return format(item, user);
+}).collect(toList());
+```
+
+--------------------------------------------------------------------------------
+
+#### T11-02: Isolation of Mutated Query Elements in Shared Caches
+
+> **Rule:** Never cache elements of a query result that are subject to
+> post-processing mutation, such as list-terminating pagination flags.
+>
+> **What:** When caching sequential or parallel query results, elements that are
+> susceptible to post-processing mutation (such as appending a pagination flag
+> like `_moreChanges`) must be explicitly excluded from the shared cache.
+>
+> **Applies To:** API response formatters, caching layers, and thread-safe
+> collections (`ConcurrentHashMap`) used during pagination.
+>
+> **Why:** Caching the final element of a paginated list caused the
+> `_moreChanges` boolean to leak into subsequent, unrelated queries that
+> retrieved the same entity from the cache, yielding incorrect pagination states
+> in the UI. Failing to adhere to this typically results in **Cache Poisoning /
+> UI Pagination Errors**.
+
+**Trap 1: Caching all elements of a query result indiscriminately before
+applying list-level pagination logic.**
+
+**Don't:**
+
+```java
+// BAD: Caching all elements, then mutating the last one
+for (ChangeData cd : changes) {
+  ChangeInfo info = format(cd);
+  cache.put(cd.getId(), info);
+  changeInfos.add(info);
+}
+changeInfos.get(changeInfos.size() - 1)._moreChanges = true;
+```
+
+**Do:**
+
+```java
+// GOOD: Excluding the potentially mutated last element from the cache
+for (int i = 0; i < changes.size(); i++) {
+  boolean isCacheable = cacheQueryResults && (i != changes.size() - 1);
+  ChangeInfo info = format(changes.get(i));
+  if (isCacheable) {
+    cache.put(changes.get(i).getId(), info);
+  }
+  changeInfos.add(info);
+}
+```
+
+--------------------------------------------------------------------------------
+
+#### T11-03: Pre-allocation of Thread-Safe Caches for Large Queries
+
+> **Rule:** Must initialize expected capacities when instantiating thread-safe
+> collections to process large parallel processing operations.
+>
+> **What:** When initializing a thread-safe map (`ConcurrentHashMap`) that will
+> accommodate thousands of entries during parallel processing, explicitly
+> provide the expected size/capacity to the constructor.
+>
+> **Applies To:** Concurrent processing layers and result caches for bulk data
+> retrieval.
+>
+> **Why:** Failing to initialize capacities on heavily used collections led to
+> unnecessary memory reallocation and rehashing overhead during parallel stream
+> processing of large change lists. Failing to adhere to this typically results
+> in **Memory Overhead / CPU Churn**.
+
+**Trap 1: Initializing a ConcurrentHashMap using the default empty constructor
+despite knowing the size of the input list.**
+
+**Don't:**
+
+```java
+// BAD: Default capacity leads to frequent rehashing
+Map<Change.Id, ChangeInfo> cache = new ConcurrentHashMap<>();
+```
+
+**Do:**
+
+```java
+// GOOD: Pre-allocating capacity based on known input size
+Map<Change.Id, ChangeInfo> cache = new ConcurrentHashMap<>(in.size());
+```
+
+**Exceptions:** Queries where the result set is guaranteed to be trivial (e.g.,
+< 10 items).
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T9 | Test Suite Configuration & Isolation - *Guice Provider
+    configurations dictate the request-scoped boundaries that necessitate
+    explicit context extraction on the main thread.*
+*   **Downstream:** T6 | REST API Resource Routing & Payload Optimization -
+    *Thread-safe concurrent formatting directly accelerates the generation and
+    optimization of the final JSON payloads delivered by the API.*
+
+## Chapter: Protobuf Schema Management & Conversion
+
+**Context:** This section governs the mapping between Java entities and Protobuf
+messages, mandating automated reflection-based validation and standardized
+converter abstractions to prevent cache inconsistencies and silent field loss.
+
+### Summary
+
+| Rule ID    | Principle /        | Priority | Primary Symptom / Trap          |
+:            : Constraint         :          :                                 :
+| :--------- | :----------------- | :------- | :------------------------------ |
+| **T12-01** | Java Entity to     | Critical | Modifying a Java data class     |
+:            : Protobuf           :          : without programmatically        :
+:            : Round-Trip         :          : enforcing that the new field is :
+:            : Validation         :          : mapped to the Protobuf schema.  :
+| **T12-02** | Standardized       | Medium   | Manually querying Protobuf      |
+:            : Protobuf           :          : FieldDescriptors by integer     :
+:            : Serialization via  :          : index and explicitly copying    :
+:            : SafeProtoConverter :          : values to a builder.            :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T12-01: Java Entity to Protobuf Round-Trip Validation
+
+> **Rule:** Must verify any field additions to cached Java entities via
+> reflection-based round-trip tests to guarantee absolute parity with their
+> Protobuf serializers.
+>
+> **What:** Any field additions to Java entities persisted in caches must be
+> verified via reflection-based round-trip unit tests to ensure absolute parity
+> with their Protobuf serializers.
+>
+> **Applies To:** Data storage models, cache serializers (e.g.,
+> `AccountProtoConverter`), and persistent entities.
+>
+> **Why:** Adding new fields (like avatar properties) to an entity without
+> strictly validating the corresponding Protobuf converter caused those fields
+> to be silently dropped when objects were retrieved from the cache. Failing to
+> adhere to this typically results in **Silent Data Loss / Cache
+> Inconsistency**.
+
+**Trap 1: Modifying a Java data class without programmatically enforcing that
+the new field is mapped to the Protobuf schema.**
+
+**Don't:**
+
+```java
+// BAD: Updating entity without ensuring serialization mapping
+public class Account {
+  public String avatarEmail; // New field added, silently ignored by cache
+}
+```
+
+**Do:**
+
+```java
+// GOOD: Using reflection in a test suite to detect missed fields
+@Test
+public void accountFieldsMatchExpected() {
+  // Automatically fails if Account.class gains a field not handled by the converter
+  assertAllFieldsMapped(Account.class, AccountProtoConverter.class);
+}
+```
+
+**Exceptions:** Transient fields explicitly marked to be ignored during
+serialization.
+
+--------------------------------------------------------------------------------
+
+#### T12-02: Standardized Protobuf Serialization via SafeProtoConverter
+
+> **Rule:** Never use manual Protobuf-to-Java serialization with direct
+> `FieldDescriptor` lookups; always utilize the standardized
+> `SafeProtoConverter` abstraction.
+>
+> **What:** Manual Protobuf-to-Java serialization and deserialization using
+> direct `FieldDescriptor` lookups and explicit builder mapping should be
+> avoided in favor of the standardized `SafeProtoConverter` abstraction.
+>
+> **Applies To:** Cache serialization logic (e.g., `FileDiffOutput.Serializer`)
+> and any mapping between Java data objects and Protobuf entities.
+>
+> **Why:** Manual field mapping for Protobuf entities requires verbose
+> boilerplate that is prone to field omission, index misalignment, and caching
+> corruption during schema evolution. Failing to adhere to this typically
+> results in **Data Loss / Schema Misalignment**.
+
+**Trap 1: Manually querying Protobuf FieldDescriptors by integer index and
+explicitly copying values to a builder.**
+
+**Don't:**
+
+```java
+private static final FieldDescriptor TOO_EXPENSIVE_DESCRIPTOR = FileDiffOutputProto.getDescriptor().findFieldByNumber(17);
+if (proto.hasField(TOO_EXPENSIVE_DESCRIPTOR)) {
+  builder.tooExpensiveToCompute(Optional.of(proto.getTooExpensiveToCompute()));
+}
+```
+
+**Do:**
+
+*   Implement and utilize a standard SafeProtoConverter utility to abstract away
+    raw descriptor mappings.
+
+```java
+return SafeProtoConverterUtil.fromProto(proto);
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Downstream:** T5 | Account Lifecycle & Vulnerability Mitigation - *The
+    account cache relies on strictly validated Protobuf schemas to prevent
+    silent data loss of user attributes.*
+*   **Downstream:** T10 | Diff Engine Computation Limits - *File diff cache
+    entities rely on standardized Protobuf converters to serialize expensive
+    computation fallbacks.*
+
+## Chapter: Compliance & CLA Enforcement
+
+**Context:** Must strictly enforce Contributor License Agreement (CLA) checks
+across all administrative REST API endpoints that modify project configurations
+and access rules. This guarantees compliance consistency across both direct code
+pushes and API-driven administrative actions.
+
+### Summary
+
+| Rule ID    | Principle / Constraint | Priority | Primary Symptom / Trap     |
+| :--------- | :--------------------- | :------- | :------------------------- |
+| **T13-01** | CLA Enforcement on     | Critical | Relying on standard source |
+:            : Project Configuration  :          : code pushing paths for CLA :
+:            : Endpoints              :          : enforcement while ignoring :
+:            :                        :          : REST-based project         :
+:            :                        :          : administration paths.      :
+
+--------------------------------------------------------------------------------
+
+### Rules
+
+#### T13-01: CLA Enforcement on Project Configuration Endpoints
+
+> **Rule:** Always verify Contributor License Agreements explicitly via
+> `ContributorAgreementsChecker` before processing access review payloads.
+>
+> **What:** Contributor License Agreement (CLA) checks must be strictly enforced
+> on administrative endpoints that create or modify project access rules.
+>
+> **Applies To:** REST API (`/projects/{project}/access:review`) and
+> `RepoMetaDataUpdater`.
+>
+> **Why:** Bypassing the standard CLA requirements when proposing changes
+> directly via the project access review API allowed unverified users to submit
+> project-level configuration modifications. Failing to adhere to this typically
+> results in **Compliance / Security Bypass**.
+
+**Trap 1: Relying on standard source code pushing paths for CLA enforcement
+while ignoring REST-based project administration paths.**
+
+**Don't:**
+
+*   Permitting REST API calls to `/access:review` to succeed if the user is
+    authenticated, without checking their legal agreement status.
+
+**Do:**
+
+```java
+// Verify CLA explicitly before applying access review payloads
+contributorAgreementsChecker.check(user.getAccountId());
+```
+
+--------------------------------------------------------------------------------
+
+### Cross-Domain Dependencies
+
+*   **Upstream:** T4 | Access Control & Impersonation Security - *Base user
+    permissions and identity must be established and validated before executing
+    legal agreement compliance checks.*
+*   **Downstream:** T6 | REST API Resource Routing & Payload Optimization -
+    *REST endpoint handlers must integrate explicit compliance checkers prior to
+    validating and persisting configuration payloads.*
diff --git a/configs/skills/typescript_style_review/SKILL.md b/configs/skills/typescript_style_review/SKILL.md
new file mode 100644
index 0000000..d1e79f2
--- /dev/null
+++ b/configs/skills/typescript_style_review/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: typescript-style-review
+description: Reviews TypeScript code against the Google TS Style Guide.
+---
+
+# TypeScript Style Review Guide
+
+## Executive Summary
+
+This guide serves as the instruction set for reviewing TypeScript code. The
+goal is to ensure that all TypeScript code adheres to the official Google
+TypeScript Style Guide.
+
+## Guidelines
+
+You must review the TypeScript files in the changelist and ensure they comply
+with the rules and best practices outlined in the official Google TypeScript
+Style Guide.
+
+### Reference
+*   **Google TypeScript Style Guide**:
+    https://google.github.io/styleguide/tsguide.html
+
+## Review Instructions
+
+When performing the review:
+1.  Identify any violations of the Google TypeScript Style Guide.
+2.  For each violation, provide a clear explanation of the problem, a
+    suggestion for how to fix it (with a code snippet if appropriate), and a
+    reference to the relevant section of the style guide.
+3.  Format your comments using the following template:
+
+### Problem
+Clearly and succinctly describe the style guide violation.
+
+### Suggestion
+Provide a suggestion for improvement, including a code snippet.
+
+### Reference
+Provide the link to the relevant section in the Google TypeScript Style Guide
+(e.g.,
+`https://google.github.io/styleguide/tsguide.html#visibility-of-properties-accessed-from-templates`).
diff --git a/contrib/bash_completion b/contrib/bash_completion
index 19060a5c..a1cfd91 100644
--- a/contrib/bash_completion
+++ b/contrib/bash_completion
@@ -65,7 +65,7 @@
     COMPREPLY=()
     cur="${COMP_WORDS[COMP_CWORD]}"
     prev="${COMP_WORDS[COMP_CWORD-1]}"
-    opts="check restart run start status stop supervise threads"
+    opts="check histogram restart run start status stop supervise threads"
 
     COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
 }
diff --git a/contrib/git-exproll.sh b/contrib/git-exproll.sh
deleted file mode 100644
index 9ad7a85..0000000
--- a/contrib/git-exproll.sh
+++ /dev/null
@@ -1,566 +0,0 @@
-#!/usr/bin/env bash
-# Copyright (c) 2012, Code Aurora Forum. All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or without
-# modification, are permitted provided that the following conditions are
-# met:
-#    # Redistributions of source code must retain the above copyright
-#       notice, this list of conditions and the following disclaimer.
-#    # Redistributions in binary form must reproduce the above
-#       copyright notice, this list of conditions and the following
-#       disclaimer in the documentation and/or other materials provided
-#       with the distribution.
-#    # Neither the name of Code Aurora Forum, Inc. nor the names of its
-#       contributors may be used to endorse or promote products derived
-#       from this software without specific prior written permission.
-#
-# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED
-# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT
-# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS
-# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
-# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
-# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
-# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
-# IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-usage() { # error_message
-
-    cat <<-EOF
-		usage: $(basename $0) [-unvt] [--noref] [--nolosse] [-r|--ratio number]
-		                      [git gc option...] git.repo
-
-		-u|-h                usage/help
-		-v verbose
-		-n dry-run           don't actually repack anything
-		-t touch             treat repo as if it had been touched
-		--noref              avoid extra ref packing timestamp checking
-		--noloose            do not run just because there are loose object dirs
-		                     (repacking may still run if they are referenced)
-		-r ratio <number>    packfile ratio to aim for (default 10)
-
-		git gc option        will be passed as args to git gc
-
-		git.repo             to run gc against
-
-		Garbage collect using a pseudo logarithmic packfile maintenance
-		approach.  This approach attempts to minimize packfile churn
-		by keeping several generations of varying sized packfiles around
-		and only consolidating packfiles (or loose objects) which are
-		either new packfiles, or packfiles close to the same size as
-		another packfile.
-
-		An estimate is used to predict when rollups (one consolidation
-		would cause another consolidation) would occur so that this
-		rollup can be done all at once via a single repack.  This reduces
-		both the runtime and the pack file churn in rollup cases.
-
-		Approach: plan each consolidation by creating a table like this:
-
-		Id Keep Size           Sha1(or consolidation list)      Actions(repack down up note)
-		1     - 11356          9052edfb7392646cd4e5f362b953675985f01f96 y - - New
-		2     - 429088         010904d5c11cd26a79fda91b01ab454d1001b402 y - - New
-		c1    - 440444         [1,2]                                    - - -
-
-		Id:    numbers preceded by a c are estimated "c pack" files
-		Keep:  - none, k private keep, o our keep
-		Size:  in disk blocks (default du output)
-		Sha1:  of packfile, or consolidation list of packfile ids
-		Actions
-		repack: - n no, y yes
-		down:   - noop, ^ consolidate with a file above
-		up:     - noop, v consolidate with a file below
-		note:   Human description of script decisions:
-		         New (file is a new packfile)
-		         Consolidate with:<list of packfile ids>
-		         (too far from:<list of packfile ids>)
-
-		On the first pass, always consolidate any new packfiles along
-		with loose objects and along with any packfiles which are within
-		the ratio size of their predecessors (note, the list is ordered
-		by increasing size).  After each consolidation, insert a fake
-		consolidation, or "c pack", to naively represent the size and
-		ordered positioning of the anticipated new consolidated pack.
-		Every time a new pack is planned, rescan the list in case the
-		new "c pack" would cause more consolidation...
-
-		Once the packfiles which need consolidation are determined, the
-		packfiles which will not be consolidated are marked with a .keep
-		file, and those which will be consolidated will have their .keep
-		removed if they have one.  Thus, the packfiles with a .keep will
-		not get repacked.
-
-		Packfile consolidation is determined by the --ratio parameter
-		(default is 10).  This ratio is somewhat of a tradeoff.  The
-		smaller the number, the more packfiles will be kept on average;
-		this increases disk utilization somewhat.  However, a larger
-		ratio causes greater churn and may increase disk utilization due
-		to deleted packfiles not being reclaimed since they may still be
-		kept open by long running applications such as Gerrit.  Sane
-		ratio values are probably between 2 and 10.  Since most
-		consolidations actually end up smaller than the estimated
-		consolidated packfile size (due to compression), the true ratio
-		achieved will likely be 1 to 2 greater than the target ratio.
-		The smaller the target ratio, the greater this discrepancy.
-
-		Finally, attempt to skip garbage collection entirely on untouched
-		repos.  In order to determine if a repo has been touched, use the
-		timestamp on the script's keep files, if any relevant file/dir
-		is newer than a keep marker file, assume that the repo has been
-		touched and gc needs to run.  Also assume gc needs to run whenever
-		there are loose object dirs since they may contain untouched
-		unreferenced loose objects which need to be pruned (once they
-		expire).
-
-		In order to allow the keep files to be an effective timestamp
-		marker to detect relevant changes in a repo since the last run,
-		all relevant files and directories which may be modified during a
-		gc run (even during a noop gc run), must have their timestamps
-		reset to the same time as the keep files or gc will always run
-		even on untouched repos.  The relevant files/dirs are all those
-		files and directories which garbage collection, object packing,
-		ref packing and pruning might change during noop actions.
-EOF
-
-    [ -n "$1" ] && info "ERROR $1"
-
-    exit 128
-}
-
-debug() { [ -n "$SW_V" ] && info "$1" ; }
-info() { echo "$1" >&2 ; }
-
-array_copy() { #v2 # array_src array_dst
-    local src=$1 dst=$2
-    local s i=0
-    eval s=\${#$src[@]}
-    while [ $i -lt $s ] ; do
-        eval $dst[$i]=\"\${$src[$i]}\"
-        i=$(($i + 1))
-    done
-}
-
-array_equals() { #v2 # array_name [vals...]
-    local a=$1 ; shift
-    local s=0 t=() val
-    array_copy "$a" t
-    for s in "${!t[@]}" ; do s=$((s+1)) ; done
-    [ "$s" -ne "$#" ] && return 1
-    for val in "${t[@]}" ; do
-        [ "$val" = "$1" ] || return 2
-        shift
-    done
-    return 0
-}
-
-packs_sizes() { # git.repo > "size pack"...
-    du -s "$1"/objects/pack/pack-$SHA1.pack | sort -n 2> /dev/null
-}
-
-is_ourkeep() { grep -q "$KEEP" "$1" 2> /dev/null ; } # keep
-has_ourkeep() { is_ourkeep "$(keep_for "$1")" ; } # pack
-has_keep() { [ -f "$(keep_for "$1")" ] ; } # pack
-is_repo() { [ -d "$1/objects" ] && [ -d "$1/refs/heads" ] ; } # git.repo
-
-keep() { # pack   # returns true if we added our keep
-    keep=$(keep_for "$1")
-    [ -f "$keep" ] && return 1
-    echo "$KEEP" > "$keep"
-    return 0
-}
-
-keep_for() { # packfile > keepfile
-    local keep=$(echo "$1" | sed -es'/\.pack$/.keep/')
-    [ "${keep/.keep}" = "$keep" ] && return 1
-    echo "$keep"
-}
-
-idx_for() { # packfile > idxfile
-    local idx=$(echo "$1" | sed -es'/\.pack$/.idx/')
-    [ "${idx/.idx}" = "$idx" ] && return 1
-    echo "$idx"
-}
-
-# pack_or_keep_file > sha
-sha_for() { echo "$1" | sed -es'|\(.*/\)*pack-\([^.]*\)\..*$|\2|' ; }
-
-private_keeps() { # git.repo -> sets pkeeps
-    local repo=$1 ary=$2
-    local keep keeps=("$repo"/objects/pack/pack-$SHA1.keep)
-    pkeeps=()
-    for keep in "${keeps[@]}" ; do
-        is_ourkeep "$keep" || pkeeps=("${pkeeps[@]}" "$keep")
-    done
-}
-
-is_tooclose() { [ "$(($1 * $RATIO))" -gt "$2" ] ; } # smaller larger
-
-unique() { # [args...] > unique_words
-    local lines=$(while [ $# -gt 0 ] ; do echo "$1" ; shift ; done)
-    lines=$(echo "$lines" | sort -u)
-    echo $lines  # as words
-}
-
-outfs() { # fs [args...] > argfs...
-    local fs=$1 ; shift
-    [ $# -gt 0 ] && echo -n "$1" ; shift
-    while [ $# -gt 0 ] ; do echo -n "$fs$1" ; shift ; done
-}
-
-sort_list() { # < list > formatted_list
-    # n has_keep size sha repack down up note
-    awk '{ note=$8; for(i=8;i<NF;i++) note=note " "$(i+1)
-           printf("%-5s %s %-14s %-40s %s %s %s %s\n", \
-                     $1,$2,   $3,  $4, $5,$6,$7,note)}' |\
-        sort -k 3,3n -k 1,1n
-}
-
-is_touched() { # git.repo
-    local repo=$1
-    local loose keep ours newer
-    [ -n "$SW_T" ] && { debug "$SW_T -> treat as touched" ; return 0 ; }
-
-    if [ -z "$SW_LOOSE" ] ; then
-        # If there are loose objects, they may need to be pruned,
-        # run even if nothing has really been touched.
-        loose=$(find "$repo/objects" -type d \
-                      -wholename "$repo/objects/[0-9][0-9]"
-                      -print -quit 2>/dev/null)
-        [ -n "$loose" ] && { info "There are loose object directories" ; return 0 ; }
-    fi
-
-    # If we don't have a keep, the current packfiles may not have been
-    # compressed with the current gc policy (gc may never have been run),
-    # so run at least once to repack everything.  Also, we need a marker
-    # file for timestamp tracking (a dir needs to detect changes within
-    # it, so it cannot be a marker) and our keeps are something we control,
-    # use them.
-    for keep in "$repo"/objects/pack/pack-$SHA1.keep ; do
-        is_ourkeep "$keep" && { ours=$keep ; break ; }
-    done
-    [ -z "$ours" ] && { info 'We have no keep (we have never run?): run' ; return 0 ; }
-
-    debug "Our timestamp keep: $ours"
-    # The wholename stuff seems to get touched by a noop git gc
-    newer=$(find "$repo/objects" "$repo/refs" "$repo/packed-refs" \
-                  '!' -wholename "$repo/objects/info" \
-                  '!' -wholename "$repo/objects/info/*" \
-                  -newer "$ours" \
-                  -print -quit 2>/dev/null)
-    [ -z "$newer" ] && return 1
-
-    info "Touched since last run: $newer"
-    return 0
-}
-
-touch_refs() { # git.repo start_date refs
-    local repo=$1 start_date=$2 refs=$3
-    (
-        debug "Setting start date($start_date) on unpacked refs:"
-        debug "$refs"
-        cd "$repo/refs" || return
-        # safe to assume no newlines in a ref name
-        echo "$refs" | xargs -d '\n' -n 1 touch -c -d "$start_date"
-    )
-}
-
-set_start_date() { # git.repo start_date refs refdirs packedrefs [packs]
-    local repo=$1 start_date=$2 refs=$3 refdirs=$4 packedrefs=$5 ; shift 5
-    local pack keep idx repacked
-
-    # This stuff is touched during object packs
-    while [ $# -gt 0 ] ; do
-        pack=$1 ; shift
-        keep="$(keep_for "$pack")"
-        idx="$(idx_for "$pack")"
-        touch -c -d "$start_date" "$pack" "$keep" "$idx"
-        debug "Setting start date on: $pack $keep $idx"
-    done
-    # This will prevent us from detecting any deletes in the pack dir
-    # since gc ran, except for private keeps which we are checking
-    # manually.  But there really shouldn't be any other relevant deletes
-    # in this dir which should cause us to rerun next time, deleting a
-    # pack or index file by anything but gc would be bad!
-    debug "Setting start date on pack dir: $start_date"
-    touch -c -d "$start_date" "$repo/objects/pack"
-
-
-    if [ -z "$SW_REFS" ] ; then
-        repacked=$(find "$repo/packed-refs" -newer "$repo/objects/pack"
-                      -print -quit 2>/dev/null)
-        if [ -n "$repacked" ] ; then
-            # The ref dirs and packed-ref files seem to get touched even on
-            # a noop refpacking
-            debug "Setting start date on packed-refs"
-            touch -c -d "$start_date" "$repo/packed-refs"
-            touch_refs "$repo" "$start_date" "$refdirs"
-
-            # A ref repack does not imply a ref change, but since it is
-            # hard to tell, simply assume so
-            if [ "$refs" != "$(cd "$repo/refs" ; find -depth)" ] || \
-               [ "$packedrefs" != "$(<"$repo/packed-refs")" ] ; then
-                # We retouch if needed (instead of simply checking then
-                # touching) to avoid a race between the check and the set.
-                debug "  but refs actually got packed, so retouch packed-refs"
-                touch -c "$repo/packed-refs"
-            fi
-        fi
-    fi
-}
-
-note_consolidate() { # note entry > note (no duplicated consolidated entries)
-    local note=$1 entry=$2
-    local entries=() ifs=$IFS
-    if  echo "$note" | grep -q 'Consolidate with:[0-9,c]' ; then
-        IFS=,
-        entries=( $(echo "$note" | sed -es'/^.*Consolidate with:\([0-9,c]*\).*$/\1/') )
-        note=( $(echo "$note" | sed -es'/Consolidate with:[0-9,c]*//') )
-        IFS=$ifs
-    fi
-    entries=( $(unique "${entries[@]}" "$entry") )
-    echo "$note Consolidate with:$(outfs , "${entries[@]}")"
-}
-
-note_toofar() { # note entry > note (no duplicated "too far" entries)
-    local note=$1 entry=$2
-    local entries=() ifs=$IFS
-    if  echo "$note" | grep -q '(too far from:[0-9,c]*)' ; then
-        IFS=,
-        entries=( $(echo "$note" | sed -es'/^.*(too far from:\([0-9,c]*\)).*$/\1/') )
-        note=( $(echo "$note" | sed -es'/(too far from:[0-9,c]*)//') )
-        IFS=$ifs
-    fi
-    entries=( $(unique "${entries[@]}" "$entry") )
-    echo "$note (too far from:$(outfs , "${entries[@]}"))"
-}
-
-last_entry() { # isRepack pline repackline > last_rows_entry
-    local size_hit=$1 pline=$2 repackline=$3
-    if [ -n "$pline" ] ; then
-        if [ -n "$size_hit" ] ; then
-            echo "$repack_line"
-        else
-            echo "$pline"
-        fi
-    fi
-}
-
-init_list() { # git.repo > shortlist
-    local repo=$1
-    local file
-    local n has_keep size sha repack
-
-    packs_sizes "$1" | {
-        while read size file ; do
-            n=$((n+1))
-            repack=n
-            has_keep=-
-            if has_keep "$file" ; then
-                has_keep=k
-                has_ourkeep "$file" && has_keep=o
-            fi
-            sha=$(sha_for "$file")
-            echo "$n $has_keep $size $sha $repack"
-        done
-    } | sort_list
-}
-
-consolidate_list() { # run < list > list
-    local run=$1
-    local sum=0 psize=0 sum_size=0 size_hit pn clist pline repackline
-    local n has_keep size sha repack down up note
-
-    {
-        while read n has_keep size sha repack down up note; do
-            [ -z "$up" ] && up='-'
-            [ -z "$down" ] && down="-"
-
-            if [ "$has_keep" = "k" ] ; then
-                echo "$n $has_keep $size $sha $repack - - Private"
-                continue
-            fi
-
-            if [ "$repack" = "n" ] ; then
-                if is_tooclose $psize $size ; then
-                    size_hit=y
-                    repack=y
-                    sum=$(($sum + $sum_size + $size))
-                    sum_size=0 # Prevents double summing this entry
-                    clist=($(unique "${clist[@]}" $pn $n))
-                    down="^"
-                    [ "$has_keep" = "-" ] && note="$note New +"
-                    note=$(note_consolidate "$note" "$pn")
-                elif [ "$has_keep" = "-" ] ; then
-                    repack=y
-                    sum=$(($sum + $size))
-                    sum_size=0 # Prevents double summing this entry
-                    clist=($(unique "${clist[@]}" $n))
-                    note="$note New"
-                elif [ $psize -ne 0 ] ; then
-                    sum_size=$size
-                    down="!"
-                    note=$(note_toofar "$note" "$pn")
-                else
-                    sum_size=$size
-                fi
-            else
-                sum_size=$size
-            fi
-
-            # By preventing "c files" (consolidated) from being marked
-            # "repack" they won't get keeps
-            repack2=y
-            [ "${n/c}" != "$n" ] && { repack=- ; repack2=- ; }
-
-            last_entry "$size_hit" "$pline" "$repack_line"
-            # Delay the printout until we know whether we are
-            # being consolidated with the entry following us
-            # (we won't know until the next iteration).
-            # size_hit is used to determine which of the lines
-            # below will actually get printed above on the next
-            # iteration.
-            pline="$n $has_keep $size $sha $repack $down $up $note"
-            repack_line="$n $has_keep $size $sha $repack2 $down v $note"
-
-            pn=$n ; psize=$size # previous entry data
-            size_hit='' # will not be consolidated up
-
-        done
-        last_entry "$size_hit" "$pline" "$repack_line"
-
-        [ $sum -gt 0 ] && echo "c$run - $sum [$(outfs , "${clist[@]}")] - - -"
-
-    } | sort_list
-}
-
-process_list() { # git.repo > list
-    local list=$(init_list "$1")  plist run=0
-
-    while true ; do
-        plist=$list
-        run=$((run +1))
-        list=$(echo "$list" | consolidate_list "$run")
-        if [ "$plist" != "$list" ] ; then
-            debug "------------------------------------------------------------------------------------"
-            debug "$HEADER"
-            debug "$list"
-        else
-            break
-        fi
-    done
-    debug "------------------------------------------------------------------------------------"
-    echo "$list"
-}
-
-repack_list() { # git.repo < list
-    local repo=$1
-    local start_date newpacks=0 pkeeps keeps=1 refs refdirs rtn
-    local packedrefs=$(<"$repo/packed-refs")
-
-    # so they don't appear touched after a noop refpacking
-    if [ -z "$SW_REFS" ] ; then
-        refs=$(cd "$repo/refs" ; find -depth)
-        refdirs=$(cd "$repo/refs" ; find -type d -depth)
-        debug "Before refs:"
-        debug "$refs"
-    fi
-
-    # Find a private keep snapshot which has not changed from
-    # before our start_date so private keep deletions during gc
-    # can be detected
-    while ! array_equals pkeeps "${keeps[@]}" ; do
-       debug "Getting a private keep snapshot"
-       private_keeps "$repo"
-       keeps=("${pkeeps[@]}")
-       debug "before keeps: ${keeps[*]}"
-       start_date=$(date)
-       private_keeps "$repo"
-       debug "after keeps: ${pkeeps[*]}"
-    done
-
-    while read n has_keep size sha repack down up note; do
-        if [ "$repack" = "y" ] ; then
-            keep="$repo/objects/pack/pack-$sha.keep"
-            info "Repacking $repo/objects/pack/pack-$sha.pack"
-            [ -f "$keep" ] && rm -f "$keep"
-        fi
-    done
-
-    ( cd "$repo" && git gc "${GC_OPTS[@]}" ) ; rtn=$?
-
-    # Mark any files withoug a .keep with our .keep
-    packs=("$repo"/objects/pack/pack-$SHA1.pack)
-    for pack in "${packs[@]}" ; do
-        if keep "$pack" ; then
-            info "New pack: $pack"
-            newpacks=$((newpacks+1))
-        fi
-    done
-
-    # Record start_time.  If there is more than 1 new packfile, we
-    # don't want to risk touching it with an older date since that
-    # would prevent consolidation on the next run.  If the private
-    # keeps have changed, then we should run next time no matter what.
-    if [ $newpacks -le 1 ] || ! array_equals pkeeps "${keeps[@]}" ; then
-        set_start_date "$repo" "$start_date" "$refs" "$refdirs" "$packedrefs" "${packs[@]}"
-    fi
-
-    return $rtn # we really only care about the gc error code
-}
-
-git_gc() { # git.repo
-    local list=$(process_list "$1")
-    if [ -z "$SW_V" ] ; then
-        info "Running $PROG on $1.  git gc options: ${GC_OPTS[@]}"
-        echo "$HEADER" >&2
-        echo "$list" >&2 ;
-    fi
-    echo "$list" | repack_list "$1"
-}
-
-
-PROG=$(basename "$0")
-HEADER="Id Keep Size           Sha1(or consolidation list)      Actions(repack down up note)"
-KEEP=git-exproll
-HEX='[0-9a-f]'
-HEX10=$HEX$HEX$HEX$HEX$HEX$HEX$HEX$HEX$HEX$HEX
-SHA1=$HEX10$HEX10$HEX10$HEX10
-
-RATIO=10
-SW_N='' ; SW_V='' ; SW_T='' ; SW_REFS='' ; SW_LOOSE='' ; GC_OPTS=()
-while [ $# -gt 0 ] ; do
-    case "$1" in
-        -u|-h)  usage ;;
-        -n)  SW_N="$1" ;;
-        -v)  SW_V="$1" ;;
-
-        -t)  SW_T="$1" ;;
-        --norefs)  SW_REFS="$1" ;;
-        --noloose) SW_LOOSE="$1" ;;
-
-        -r|--ratio)  shift ; RATIO="$1" ;;
-
-        *)  [ $# -le 1 ] && break
-            GC_OPTS=( "${GC_OPTS[@]}" "$1" )
-            ;;
-    esac
-    shift
-done
-
-
-REPO="$1"
-if ! is_repo "$REPO" ; then
-    REPO=$REPO/.git
-    is_repo "$REPO" || usage "($1) is not likely a git repo"
-fi
-
-
-if [ -z "$SW_N" ] ; then
-    is_touched "$REPO" || { info "Repo untouched since last run" ; exit ; }
-    git_gc "$REPO"
-else
-    is_touched "$REPO" || info "Repo untouched since last run, analyze anyway."
-    process_list "$REPO" >&2
-fi
diff --git a/contrib/maintenance/gerrit/site.py b/contrib/maintenance/gerrit/site.py
index faf6c02..450acaa 100644
--- a/contrib/maintenance/gerrit/site.py
+++ b/contrib/maintenance/gerrit/site.py
@@ -37,11 +37,11 @@
             ) as cfg:
                 config_base_path = cfg.get("gerrit", None, "basePath", "git")
                 if os.path.isabs(config_base_path):
-                    self.basePath = config_base_path
+                    self.base_path = config_base_path
                 else:
-                    self.basePath = os.path.join(self.path, config_base_path)
+                    self.base_path = os.path.join(self.path, config_base_path)
 
-        return self.basePath
+        return self.base_path
 
     def get_projects(self, excludes=None):
         for current, dirs, _ in os.walk(self.get_base_path(), topdown=True):
diff --git a/external_deps.lock.json b/external_deps.lock.json
index a0a3c50..b08ab27 100644
--- a/external_deps.lock.json
+++ b/external_deps.lock.json
@@ -8,10 +8,10 @@
     "com.github.ben-manes.caffeine:caffeine": 1735183231,
     "com.github.ben-manes.caffeine:guava": -2131703186,
     "com.github.rholder:guava-retrying": 1309513165,
-    "com.google.auto.factory:auto-factory": -1233360965,
-    "com.google.auto.service:auto-service-annotations": 953830824,
-    "com.google.auto.value:auto-value": 1146221104,
-    "com.google.auto.value:auto-value-annotations": -1101276935,
+    "com.google.auto.factory:auto-factory": -1222857861,
+    "com.google.auto.service:auto-service-annotations": 964333928,
+    "com.google.auto.value:auto-value": -661233359,
+    "com.google.auto.value:auto-value-annotations": 1386235898,
     "com.google.auto:auto-common": -832702775,
     "com.google.code.findbugs:jsr305": -1992157670,
     "com.google.code.gson:gson": 2063056812,
@@ -23,18 +23,16 @@
     "com.google.flogger:flogger-log4j-backend": 1730247028,
     "com.google.flogger:flogger-system-backend": 1522076251,
     "com.google.flogger:google-extensions": -355130314,
-    "com.google.gitiles:blame-cache": -1424275928,
-    "com.google.gitiles:gitiles-servlet": 1993647825,
     "com.google.guava:failureaccess": -2032498474,
     "com.google.guava:guava": -1756621521,
     "com.google.guava:guava-testlib": -203887467,
-    "com.google.inject.extensions:guice-assistedinject": -1667539622,
-    "com.google.inject.extensions:guice-servlet": 569202692,
-    "com.google.inject:guice": -1660789120,
+    "com.google.inject.extensions:guice-assistedinject": -1742632647,
+    "com.google.inject.extensions:guice-servlet": -1284557873,
+    "com.google.inject:guice": 2106361664,
     "com.google.j2objc:j2objc-annotations": -727464895,
     "com.google.jimfs:jimfs": -1004381565,
-    "com.google.protobuf:protobuf-java": 1906581597,
-    "com.google.template:soy": -1478719887,
+    "com.google.protobuf:protobuf-java": 1938090909,
+    "com.google.template:soy": -843524660,
     "com.google.truth.extensions:truth-java8-extension": -129319374,
     "com.google.truth.extensions:truth-liteproto-extension": 1463279446,
     "com.google.truth.extensions:truth-proto-extension": 1270333764,
@@ -44,11 +42,9 @@
     "com.googlecode.prolog-cafe:prolog-compiler": -348984778,
     "com.googlecode.prolog-cafe:prolog-io": -1081577073,
     "com.googlecode.prolog-cafe:prolog-runtime": 1849117715,
-    "com.h2database:h2": 867138720,
+    "com.h2database:h2": 377222208,
     "com.ibm.icu:icu4j": -802150924,
     "com.icegreen:greenmail": -1151466560,
-    "com.jcraft:jsch": 1133842314,
-    "com.jcraft:jzlib": 864660349,
     "com.ryanharter.auto.value:auto-value-gson-extension": 520453879,
     "com.ryanharter.auto.value:auto-value-gson-factory": 1068512450,
     "com.ryanharter.auto.value:auto-value-gson-runtime": 187755792,
@@ -109,7 +105,7 @@
     "commons-dbcp:commons-dbcp": -873877417,
     "commons-digester:commons-digester": 1688456634,
     "commons-io:commons-io": 1305681826,
-    "commons-logging:commons-logging": 243315756,
+    "commons-logging:commons-logging": -264381874,
     "commons-net:commons-net": 1227155931,
     "commons-pool:commons-pool": -2015226625,
     "commons-validator:commons-validator": -89255997,
@@ -126,18 +122,19 @@
     "javax.servlet:javax.servlet-api": 669233360,
     "junit:junit": -744267592,
     "log4j:log4j": 182326902,
-    "net.bytebuddy:byte-buddy": 731630558,
-    "net.bytebuddy:byte-buddy-agent": 294689510,
-    "net.java.dev.jna:jna": 929040997,
-    "net.java.dev.jna:jna-platform": 1235639073,
+    "net.bytebuddy:byte-buddy": 612145055,
+    "net.bytebuddy:byte-buddy-agent": -48125545,
+    "net.java.dev.jna:jna": -867910362,
+    "net.java.dev.jna:jna-platform": -561312286,
     "net.minidev:json-smart": -1043043954,
     "net.sf.jopt-simple:jopt-simple": 906822697,
     "net.sourceforge.nekohtml:nekohtml": 1723624706,
     "org.antlr:antlr": -2055062274,
     "org.antlr:antlr-runtime": -2145792567,
     "org.antlr:stringtemplate": -752719922,
+    "org.apache.ant:ant": 51716534,
     "org.apache.commons:commons-compress": -1289113474,
-    "org.apache.commons:commons-lang3": 109544183,
+    "org.apache.commons:commons-lang3": 1729335886,
     "org.apache.commons:commons-math3": -1738699872,
     "org.apache.commons:commons-text": -1886494041,
     "org.apache.httpcomponents:fluent-hc": 58615850,
@@ -145,15 +142,15 @@
     "org.apache.httpcomponents:httpcore": -1696303652,
     "org.apache.james:apache-mime4j-core": -753273784,
     "org.apache.james:apache-mime4j-dom": -1889888125,
-    "org.apache.lucene:lucene-analysis-common": 582251708,
-    "org.apache.lucene:lucene-backward-codecs": 1414616849,
-    "org.apache.lucene:lucene-core": 299918137,
+    "org.apache.lucene:lucene-analysis-common": 1272813182,
+    "org.apache.lucene:lucene-backward-codecs": 2105178323,
+    "org.apache.lucene:lucene-core": 990479611,
     "org.apache.lucene:lucene-facet": -125344464,
-    "org.apache.lucene:lucene-misc": -1756758452,
+    "org.apache.lucene:lucene-misc": -1066196978,
     "org.apache.lucene:lucene-queries": 1341801569,
-    "org.apache.lucene:lucene-queryparser": 2090205297,
+    "org.apache.lucene:lucene-queryparser": -1514200525,
     "org.apache.lucene:lucene-sandbox": 1803509280,
-    "org.apache.mina:mina-core": -955969490,
+    "org.apache.mina:mina-core": -1403307213,
     "org.apache.sshd:sshd-common": 1844247612,
     "org.apache.sshd:sshd-core": 602454664,
     "org.apache.sshd:sshd-mina": 582747848,
@@ -161,26 +158,27 @@
     "org.apache.sshd:sshd-sftp": 292810504,
     "org.asciidoctor:asciidoctorj": -457860213,
     "org.assertj:assertj-core": -1145412507,
-    "org.bouncycastle:bcpg-jdk18on": -1572213535,
-    "org.bouncycastle:bcpkix-jdk18on": 146639060,
-    "org.bouncycastle:bcprov-jdk18on": -1405390253,
-    "org.bouncycastle:bcutil-jdk18on": -469511060,
+    "org.bouncycastle:bcpg-jdk18on": 915299298,
+    "org.bouncycastle:bcpkix-jdk18on": -1660815403,
+    "org.bouncycastle:bcprov-jdk18on": -1559819624,
+    "org.bouncycastle:bcutil-jdk18on": 2018001773,
     "org.commonmark:commonmark": 1129543740,
     "org.commonmark:commonmark-ext-autolink": -1853742120,
     "org.commonmark:commonmark-ext-gfm-strikethrough": 350394231,
     "org.commonmark:commonmark-ext-gfm-tables": 1881582931,
-    "org.eclipse.jetty.ee10:jetty-ee10-servlet": 754433625,
-    "org.eclipse.jetty.ee8:jetty-ee8-nested": -1627496831,
-    "org.eclipse.jetty.ee8:jetty-ee8-security": 1632270986,
-    "org.eclipse.jetty.ee8:jetty-ee8-servlet": -1338697711,
-    "org.eclipse.jetty:jetty-http": -235909997,
-    "org.eclipse.jetty:jetty-io": -44878927,
-    "org.eclipse.jetty:jetty-jmx": 1781043564,
-    "org.eclipse.jetty:jetty-security": -1819173301,
-    "org.eclipse.jetty:jetty-server": -130172210,
-    "org.eclipse.jetty:jetty-session": -1378386067,
-    "org.eclipse.jetty:jetty-util": -444622835,
-    "org.eclipse.jetty:jetty-util-ajax": -622085534,
+    "org.commonmark:commonmark-ext-yaml-front-matter": -1519651186,
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": 1925464069,
+    "org.eclipse.jetty.ee8:jetty-ee8-nested": -947438461,
+    "org.eclipse.jetty.ee8:jetty-ee8-security": -1982637940,
+    "org.eclipse.jetty.ee8:jetty-ee8-servlet": -658639341,
+    "org.eclipse.jetty:jetty-http": 444148373,
+    "org.eclipse.jetty:jetty-io": 635179443,
+    "org.eclipse.jetty:jetty-jmx": -1833865362,
+    "org.eclipse.jetty:jetty-security": -1139114931,
+    "org.eclipse.jetty:jetty-server": 549886160,
+    "org.eclipse.jetty:jetty-session": -698327697,
+    "org.eclipse.jetty:jetty-util": 235435535,
+    "org.eclipse.jetty:jetty-util-ajax": 57972836,
     "org.hamcrest:hamcrest": 1547523135,
     "org.jruby:jruby-complete": -2103568068,
     "org.json:json": -811907600,
@@ -188,7 +186,7 @@
     "org.mockito:mockito-core": 1330163800,
     "org.nibor.autolink:autolink": -342487050,
     "org.objenesis:objenesis": 748376655,
-    "org.openid4java:openid4java": -842286787,
+    "org.openid4java:openid4java": -1889179110,
     "org.openjdk.jmh:jmh-core": 983716932,
     "org.openjdk.jmh:jmh-generator-annprocess": -1162360421,
     "org.ow2.asm:asm": 1206815935,
@@ -210,304 +208,302 @@
     "xml-apis:xml-apis": -113825062
   },
   "__RESOLVED_ARTIFACTS_HASH": {
-    "antlr:antlr": 2120497295,
-    "aopalliance:aopalliance": -1268465981,
-    "aopalliance:aopalliance:jar:sources": 442956464,
-    "args4j:args4j": 1165073517,
-    "args4j:args4j:jar:sources": -1061197748,
-    "ch.qos.reload4j:reload4j": 1742597253,
-    "ch.qos.reload4j:reload4j:jar:sources": -505557081,
-    "com.beust:jcommander": -86812675,
-    "com.beust:jcommander:jar:sources": -377940076,
-    "com.github.ben-manes.caffeine:caffeine": 386266583,
-    "com.github.ben-manes.caffeine:caffeine:jar:sources": 824791053,
-    "com.github.ben-manes.caffeine:guava": -586447867,
-    "com.github.ben-manes.caffeine:guava:jar:sources": -2102940169,
-    "com.github.rholder:guava-retrying": -193656863,
-    "com.github.rholder:guava-retrying:jar:sources": 402259526,
-    "com.google.auto.factory:auto-factory": 117391549,
-    "com.google.auto.factory:auto-factory:jar:sources": 1626334411,
-    "com.google.auto.service:auto-service-annotations": -2030804522,
-    "com.google.auto.service:auto-service-annotations:jar:sources": 1880995980,
-    "com.google.auto.value:auto-value": 1610847974,
-    "com.google.auto.value:auto-value-annotations": 818774630,
-    "com.google.auto.value:auto-value-annotations:jar:sources": -1313058273,
-    "com.google.auto.value:auto-value:jar:sources": -1087092342,
-    "com.google.auto:auto-common": 1232278285,
-    "com.google.auto:auto-common:jar:sources": 1238804620,
-    "com.google.code.findbugs:jsr305": 1028218835,
-    "com.google.code.findbugs:jsr305:jar:sources": 1130389911,
-    "com.google.code.gson:gson": 1676184452,
-    "com.google.code.gson:gson:jar:sources": -1194250318,
-    "com.google.common.html.types:types": -560413073,
-    "com.google.common.html.types:types:jar:sources": 1541167117,
-    "com.google.errorprone:error_prone_annotations": -2118374750,
-    "com.google.errorprone:error_prone_annotations:jar:sources": -88858373,
-    "com.google.flogger:flogger": 2071094150,
-    "com.google.flogger:flogger-log4j-backend": -1752942291,
-    "com.google.flogger:flogger-log4j-backend:jar:sources": 167088335,
-    "com.google.flogger:flogger-system-backend": 170636970,
-    "com.google.flogger:flogger-system-backend:jar:sources": 267353177,
-    "com.google.flogger:flogger:jar:sources": -399970922,
-    "com.google.flogger:google-extensions": 683610995,
-    "com.google.flogger:google-extensions:jar:sources": -362108063,
-    "com.google.gitiles:blame-cache": -1735353948,
-    "com.google.gitiles:blame-cache:jar:sources": -370259038,
-    "com.google.gitiles:gitiles-servlet": -1411302355,
-    "com.google.gitiles:gitiles-servlet:jar:sources": 192620509,
-    "com.google.guava:failureaccess": -121989663,
-    "com.google.guava:failureaccess:jar:sources": 2092951686,
-    "com.google.guava:guava": -1983533712,
-    "com.google.guava:guava-testlib": 869030181,
-    "com.google.guava:guava-testlib:jar:sources": -1116521441,
-    "com.google.guava:guava:jar:sources": 1163674882,
-    "com.google.guava:listenablefuture": -181371066,
-    "com.google.inject.extensions:guice-assistedinject": -191835468,
-    "com.google.inject.extensions:guice-assistedinject:jar:sources": 1682505664,
-    "com.google.inject.extensions:guice-servlet": -1455734849,
-    "com.google.inject.extensions:guice-servlet:jar:sources": 1055060429,
-    "com.google.inject:guice": -1714385564,
-    "com.google.inject:guice:jar:sources": 1581524423,
-    "com.google.j2objc:j2objc-annotations": -1833492981,
-    "com.google.j2objc:j2objc-annotations:jar:sources": 1596689722,
-    "com.google.jimfs:jimfs": -542987600,
-    "com.google.jimfs:jimfs:jar:sources": 1692501412,
-    "com.google.jsinterop:jsinterop-annotations": 1916195800,
-    "com.google.jsinterop:jsinterop-annotations:jar:sources": -1738684177,
-    "com.google.protobuf:protobuf-java": -1428941287,
-    "com.google.protobuf:protobuf-java:jar:sources": -121171885,
-    "com.google.template:soy": 296053403,
-    "com.google.template:soy:jar:sources": 934091432,
-    "com.google.truth.extensions:truth-java8-extension": 2072975728,
-    "com.google.truth.extensions:truth-java8-extension:jar:sources": -404559504,
-    "com.google.truth.extensions:truth-liteproto-extension": -47385409,
-    "com.google.truth.extensions:truth-liteproto-extension:jar:sources": -96134878,
-    "com.google.truth.extensions:truth-proto-extension": 92287925,
-    "com.google.truth.extensions:truth-proto-extension:jar:sources": -1961892523,
-    "com.google.truth:truth": 1710077790,
-    "com.google.truth:truth:jar:sources": -1194452779,
-    "com.googlecode.javaewah:JavaEWAH": 157530325,
-    "com.googlecode.javaewah:JavaEWAH:jar:sources": 728434282,
-    "com.googlecode.prolog-cafe:prolog-cafeteria": 1166081954,
-    "com.googlecode.prolog-cafe:prolog-cafeteria:jar:sources": 186391213,
-    "com.googlecode.prolog-cafe:prolog-compiler": -741003406,
-    "com.googlecode.prolog-cafe:prolog-compiler:jar:sources": 126666107,
-    "com.googlecode.prolog-cafe:prolog-io": -900599131,
-    "com.googlecode.prolog-cafe:prolog-io:jar:sources": -1440088888,
-    "com.googlecode.prolog-cafe:prolog-runtime": 828309397,
-    "com.googlecode.prolog-cafe:prolog-runtime:jar:sources": -1157091735,
-    "com.h2database:h2": 682770038,
-    "com.h2database:h2:jar:sources": -675004040,
-    "com.ibm.icu:icu4j": -1964869803,
-    "com.ibm.icu:icu4j:jar:sources": 864352766,
-    "com.icegreen:greenmail": 537606032,
-    "com.icegreen:greenmail:jar:sources": -1978817971,
-    "com.jcraft:jsch": 1986987929,
-    "com.jcraft:jsch:jar:sources": 707308247,
-    "com.jcraft:jzlib": 1581025040,
-    "com.jcraft:jzlib:jar:sources": 807243431,
-    "com.ryanharter.auto.value:auto-value-gson-extension": 1639397257,
-    "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources": 1925708692,
-    "com.ryanharter.auto.value:auto-value-gson-factory": 1817252669,
-    "com.ryanharter.auto.value:auto-value-gson-factory:jar:sources": -768025607,
-    "com.ryanharter.auto.value:auto-value-gson-runtime": 1015116695,
-    "com.ryanharter.auto.value:auto-value-gson-runtime:jar:sources": -831897407,
-    "com.squareup:javapoet": 1313128977,
-    "com.squareup:javapoet:jar:sources": -1200205912,
-    "com.sun.mail:javax.mail": -382507283,
-    "com.sun.mail:javax.mail:jar:sources": 1073816789,
-    "com.vladsch.flexmark:flexmark-all:jar:lib": 347351331,
-    "commons-codec:commons-codec": 1048744614,
-    "commons-codec:commons-codec:jar:sources": -1637317619,
-    "commons-dbcp:commons-dbcp": -1946826524,
-    "commons-dbcp:commons-dbcp:jar:sources": -1238094905,
-    "commons-io:commons-io": 1463238719,
-    "commons-io:commons-io:jar:sources": -120466886,
-    "commons-net:commons-net": 29132340,
-    "commons-net:commons-net:jar:sources": 1898783167,
-    "commons-pool:commons-pool": -3173032,
-    "commons-pool:commons-pool:jar:sources": 1763080609,
-    "commons-validator:commons-validator": -1615658806,
-    "commons-validator:commons-validator:jar:sources": 1267489728,
-    "dk.brics:automaton": 1622735787,
-    "dk.brics:automaton:jar:sources": 1946522418,
-    "eu.medsea.mimeutil:mime-util": 1812001452,
-    "io.dropwizard.metrics:metrics-core": -1856132969,
-    "io.dropwizard.metrics:metrics-core:jar:sources": 159424675,
-    "io.github.java-diff-utils:java-diff-utils": 457660153,
-    "io.github.java-diff-utils:java-diff-utils:jar:sources": -619235043,
-    "io.sweers.autotransient:autotransient": 1340991802,
-    "io.sweers.autotransient:autotransient:jar:sources": 80080981,
-    "jakarta.inject:jakarta.inject-api": 851002214,
-    "jakarta.inject:jakarta.inject-api:jar:sources": -1570274750,
-    "jakarta.servlet:jakarta.servlet-api": -92202749,
-    "jakarta.servlet:jakarta.servlet-api:jar:sources": -1017144686,
-    "javax.activation:activation": -1092171588,
-    "javax.activation:activation:jar:sources": 1310123688,
-    "javax.inject:javax.inject": -1960241368,
-    "javax.inject:javax.inject:jar:sources": -34689928,
-    "javax.servlet:javax.servlet-api": 1796323811,
-    "javax.servlet:javax.servlet-api:jar:sources": 137081253,
-    "junit:junit": 238187285,
-    "junit:junit:jar:sources": 1084731434,
-    "net.bytebuddy:byte-buddy": 1931414768,
-    "net.bytebuddy:byte-buddy-agent": 1328010878,
-    "net.bytebuddy:byte-buddy-agent:jar:sources": -899388612,
-    "net.bytebuddy:byte-buddy:jar:sources": 1062390101,
-    "net.java.dev.jna:jna": -1916526385,
-    "net.java.dev.jna:jna-platform": 459618853,
-    "net.java.dev.jna:jna-platform:jar:sources": -2077304647,
-    "net.java.dev.jna:jna:jar:sources": 1518406952,
-    "net.minidev:json-smart": -1913865264,
-    "net.minidev:json-smart:jar:sources": 475976688,
-    "net.sf.jopt-simple:jopt-simple": 1531230776,
-    "net.sf.jopt-simple:jopt-simple:jar:sources": -1087190884,
-    "net.sourceforge.nekohtml:nekohtml": 589300296,
-    "net.sourceforge.nekohtml:nekohtml:jar:sources": 1455941061,
-    "org.antlr:ST4": 943007212,
-    "org.antlr:ST4:jar:sources": 1076411807,
-    "org.antlr:antlr": -10615566,
-    "org.antlr:antlr-runtime": -20800628,
-    "org.antlr:antlr-runtime:jar:sources": 1652502717,
-    "org.antlr:antlr:jar:sources": -1207538557,
-    "org.antlr:stringtemplate": -1826141279,
-    "org.antlr:stringtemplate:jar:sources": 831821310,
-    "org.apache.commons:commons-compress": -2083070990,
-    "org.apache.commons:commons-compress:jar:sources": 128824058,
-    "org.apache.commons:commons-lang3": 1593572986,
-    "org.apache.commons:commons-lang3:jar:sources": 1589181154,
-    "org.apache.commons:commons-math3": 1532637713,
-    "org.apache.commons:commons-math3:jar:sources": 1655744467,
-    "org.apache.commons:commons-text": -1205775100,
-    "org.apache.commons:commons-text:jar:sources": 1247506591,
-    "org.apache.httpcomponents:fluent-hc": 1857459163,
-    "org.apache.httpcomponents:fluent-hc:jar:sources": 1555727994,
-    "org.apache.httpcomponents:httpclient": -940371367,
-    "org.apache.httpcomponents:httpclient:jar:sources": 1069741198,
-    "org.apache.httpcomponents:httpcore": -279989236,
-    "org.apache.httpcomponents:httpcore:jar:sources": 1796128621,
-    "org.apache.james:apache-mime4j-core": 596731567,
-    "org.apache.james:apache-mime4j-core:jar:sources": 1221134342,
-    "org.apache.james:apache-mime4j-dom": 2052814931,
-    "org.apache.james:apache-mime4j-dom:jar:sources": 2056952874,
-    "org.apache.lucene:lucene-analysis-common": 1674710283,
-    "org.apache.lucene:lucene-analysis-common:jar:sources": 334029180,
-    "org.apache.lucene:lucene-backward-codecs": -508942990,
-    "org.apache.lucene:lucene-backward-codecs:jar:sources": 1999324390,
-    "org.apache.lucene:lucene-core": -1994692613,
-    "org.apache.lucene:lucene-core:jar:sources": -164995544,
-    "org.apache.lucene:lucene-misc": -739909715,
-    "org.apache.lucene:lucene-misc:jar:sources": -128984682,
-    "org.apache.lucene:lucene-queryparser": -1416736164,
-    "org.apache.lucene:lucene-queryparser:jar:sources": 1960943881,
-    "org.apache.mina:mina-core": 603384434,
-    "org.apache.mina:mina-core:jar:sources": 1763715353,
-    "org.apache.sshd:sshd-mina": -1227097077,
-    "org.apache.sshd:sshd-mina:jar:sources": -1203058265,
-    "org.apache.sshd:sshd-osgi": -306944970,
-    "org.apache.sshd:sshd-osgi:jar:sources": 943047981,
-    "org.apache.sshd:sshd-sftp": -672871177,
-    "org.apache.sshd:sshd-sftp:jar:sources": 835278429,
-    "org.asciidoctor:asciidoctorj": -943976727,
-    "org.asciidoctor:asciidoctorj:jar:sources": -1022891917,
-    "org.assertj:assertj-core": 1465577983,
-    "org.assertj:assertj-core:jar:sources": 1906472612,
-    "org.bouncycastle:bcpg-jdk18on": 726067526,
-    "org.bouncycastle:bcpg-jdk18on:jar:sources": 812126316,
-    "org.bouncycastle:bcpkix-jdk18on": 2070253037,
-    "org.bouncycastle:bcpkix-jdk18on:jar:sources": -1497475064,
-    "org.bouncycastle:bcprov-jdk18on": -1778261676,
-    "org.bouncycastle:bcprov-jdk18on:jar:sources": -1245673492,
-    "org.bouncycastle:bcutil-jdk18on": 86705488,
-    "org.bouncycastle:bcutil-jdk18on:jar:sources": -784396673,
-    "org.checkerframework:checker-compat-qual": -1678975214,
-    "org.checkerframework:checker-compat-qual:jar:sources": -673395382,
-    "org.checkerframework:checker-qual": -1657280421,
-    "org.checkerframework:checker-qual:jar:sources": 324669507,
-    "org.commonmark:commonmark": -1467575831,
-    "org.commonmark:commonmark-ext-autolink": -1808977749,
-    "org.commonmark:commonmark-ext-autolink:jar:sources": -1324197024,
-    "org.commonmark:commonmark-ext-gfm-strikethrough": 1872267513,
-    "org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources": 1099767676,
-    "org.commonmark:commonmark-ext-gfm-tables": -435057552,
-    "org.commonmark:commonmark-ext-gfm-tables:jar:sources": -1247551792,
-    "org.commonmark:commonmark:jar:sources": -1275158082,
-    "org.eclipse.jetty.ee10:jetty-ee10-servlet": 72362488,
-    "org.eclipse.jetty.ee10:jetty-ee10-servlet:jar:sources": -916702046,
-    "org.eclipse.jetty.ee8:jetty-ee8-nested": -358625262,
-    "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": -259350089,
-    "org.eclipse.jetty.ee8:jetty-ee8-security": -2741086,
-    "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources": -741677001,
-    "org.eclipse.jetty.ee8:jetty-ee8-servlet": 1299305256,
-    "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources": 1072157307,
-    "org.eclipse.jetty.toolchain:jetty-servlet-api": 380579650,
-    "org.eclipse.jetty.toolchain:jetty-servlet-api:jar:sources": 212656908,
-    "org.eclipse.jetty:jetty-http": -469722928,
-    "org.eclipse.jetty:jetty-http:jar:sources": 1226271223,
-    "org.eclipse.jetty:jetty-io": 433684656,
-    "org.eclipse.jetty:jetty-io:jar:sources": -326976094,
-    "org.eclipse.jetty:jetty-jmx": -364185700,
-    "org.eclipse.jetty:jetty-jmx:jar:sources": -1132220518,
-    "org.eclipse.jetty:jetty-security": -1889906675,
-    "org.eclipse.jetty:jetty-security:jar:sources": -1709000645,
-    "org.eclipse.jetty:jetty-server": -65189690,
-    "org.eclipse.jetty:jetty-server:jar:sources": -2040105502,
-    "org.eclipse.jetty:jetty-session": -2074191661,
-    "org.eclipse.jetty:jetty-session:jar:sources": -1197680927,
-    "org.eclipse.jetty:jetty-util": 750724466,
-    "org.eclipse.jetty:jetty-util-ajax": -2113339989,
-    "org.eclipse.jetty:jetty-util-ajax:jar:sources": 793410527,
-    "org.eclipse.jetty:jetty-util:jar:sources": -1240837016,
-    "org.hamcrest:hamcrest": -1550813651,
-    "org.hamcrest:hamcrest-core": -1198150244,
-    "org.hamcrest:hamcrest-core:jar:sources": -1576492927,
-    "org.hamcrest:hamcrest:jar:sources": -1807813747,
-    "org.jruby:jruby-complete": 407729900,
-    "org.jruby:jruby-complete:jar:sources": 554963637,
-    "org.jsoup:jsoup": 789840847,
-    "org.jsoup:jsoup:jar:sources": 1377510617,
-    "org.jspecify:jspecify": -797399878,
-    "org.jspecify:jspecify:jar:sources": 1011232509,
-    "org.mockito:mockito-core": -1882151935,
-    "org.mockito:mockito-core:jar:sources": 41691546,
-    "org.nibor.autolink:autolink": 1237374319,
-    "org.nibor.autolink:autolink:jar:sources": 1695391615,
-    "org.objenesis:objenesis": -1055367721,
-    "org.objenesis:objenesis:jar:sources": 707329220,
-    "org.openid4java:openid4java": 1656473252,
-    "org.openid4java:openid4java:jar:sources": 789962531,
-    "org.openjdk.jmh:jmh-core": -381500549,
-    "org.openjdk.jmh:jmh-core:jar:sources": -1014071512,
-    "org.openjdk.jmh:jmh-generator-annprocess": -1315975534,
-    "org.openjdk.jmh:jmh-generator-annprocess:jar:sources": 81479330,
-    "org.ow2.asm:asm": 1540593910,
-    "org.ow2.asm:asm-analysis": 1735619699,
-    "org.ow2.asm:asm-analysis:jar:sources": 609078251,
-    "org.ow2.asm:asm-commons": -231881389,
-    "org.ow2.asm:asm-commons:jar:sources": 1140590651,
-    "org.ow2.asm:asm-tree": 1288506580,
-    "org.ow2.asm:asm-tree:jar:sources": -1050091087,
-    "org.ow2.asm:asm-util": 247171895,
-    "org.ow2.asm:asm-util:jar:sources": 354115585,
-    "org.ow2.asm:asm:jar:sources": 511988412,
-    "org.roaringbitmap:RoaringBitmap": 506688526,
-    "org.roaringbitmap:RoaringBitmap:jar:sources": 1956160126,
-    "org.roaringbitmap:shims": -1352997269,
-    "org.roaringbitmap:shims:jar:sources": -981899312,
-    "org.slf4j:jcl-over-slf4j": 429539128,
-    "org.slf4j:jcl-over-slf4j:jar:sources": 619461888,
-    "org.slf4j:slf4j-api": -630423788,
-    "org.slf4j:slf4j-api:jar:sources": -1112457040,
-    "org.slf4j:slf4j-ext": -1747709512,
-    "org.slf4j:slf4j-ext:jar:sources": 2070817143,
-    "org.slf4j:slf4j-reload4j": -1999639452,
-    "org.slf4j:slf4j-reload4j:jar:sources": -1774817644,
-    "org.slf4j:slf4j-simple": 426427623,
-    "org.slf4j:slf4j-simple:jar:sources": 1024382854,
-    "org.tukaani:xz": 1514570375,
-    "org.tukaani:xz:jar:sources": -610773207,
-    "xerces:xercesImpl": -723395208,
-    "xerces:xercesImpl:jar:sources": -127516017
+    "antlr:antlr": -947608540,
+    "aopalliance:aopalliance": 1403132272,
+    "aopalliance:aopalliance:jar:sources": -1259601821,
+    "args4j:args4j": 208424838,
+    "args4j:args4j:jar:sources": -370825913,
+    "ch.qos.reload4j:reload4j": -523782034,
+    "ch.qos.reload4j:reload4j:jar:sources": -338991604,
+    "com.beust:jcommander": -1223620106,
+    "com.beust:jcommander:jar:sources": 1270399231,
+    "com.github.ben-manes.caffeine:caffeine": -1342502459,
+    "com.github.ben-manes.caffeine:caffeine:jar:sources": 1780037606,
+    "com.github.ben-manes.caffeine:guava": 1717885520,
+    "com.github.ben-manes.caffeine:guava:jar:sources": 1590068668,
+    "com.github.rholder:guava-retrying": -789983219,
+    "com.github.rholder:guava-retrying:jar:sources": 2030887181,
+    "com.google.auto.factory:auto-factory": 2025609377,
+    "com.google.auto.factory:auto-factory:jar:sources": -861333607,
+    "com.google.auto.service:auto-service-annotations": 195984873,
+    "com.google.auto.service:auto-service-annotations:jar:sources": -902556490,
+    "com.google.auto.value:auto-value": -1504415167,
+    "com.google.auto.value:auto-value-annotations": 641752776,
+    "com.google.auto.value:auto-value-annotations:jar:sources": -2083311588,
+    "com.google.auto.value:auto-value:jar:sources": -30292358,
+    "com.google.auto:auto-common": -195836273,
+    "com.google.auto:auto-common:jar:sources": -2100626169,
+    "com.google.code.findbugs:jsr305": -998441376,
+    "com.google.code.findbugs:jsr305:jar:sources": -640520676,
+    "com.google.code.gson:gson": -2014404431,
+    "com.google.code.gson:gson:jar:sources": 935710753,
+    "com.google.common.html.types:types": -1909511587,
+    "com.google.common.html.types:types:jar:sources": -1323749402,
+    "com.google.errorprone:error_prone_annotations": 804114225,
+    "com.google.errorprone:error_prone_annotations:jar:sources": -2115535816,
+    "com.google.flogger:flogger": -1346444653,
+    "com.google.flogger:flogger-log4j-backend": 661355127,
+    "com.google.flogger:flogger-log4j-backend:jar:sources": 1768481764,
+    "com.google.flogger:flogger-system-backend": 1919472289,
+    "com.google.flogger:flogger-system-backend:jar:sources": 2143617050,
+    "com.google.flogger:flogger:jar:sources": -1464119363,
+    "com.google.flogger:google-extensions": 1202868209,
+    "com.google.flogger:google-extensions:jar:sources": -1667103726,
+    "com.google.guava:failureaccess": 1715931538,
+    "com.google.guava:failureaccess:jar:sources": 1303858893,
+    "com.google.guava:guava": 555169272,
+    "com.google.guava:guava-testlib": 1085873063,
+    "com.google.guava:guava-testlib:jar:sources": 1351030420,
+    "com.google.guava:guava:jar:sources": 1246910673,
+    "com.google.guava:listenablefuture": 1079558157,
+    "com.google.inject.extensions:guice-assistedinject": -205980308,
+    "com.google.inject.extensions:guice-assistedinject:jar:sources": 550961491,
+    "com.google.inject.extensions:guice-servlet": -325370690,
+    "com.google.inject.extensions:guice-servlet:jar:sources": -828654042,
+    "com.google.inject:guice:jar:classes": 1808670374,
+    "com.google.inject:guice:jar:sources": -2060215316,
+    "com.google.j2objc:j2objc-annotations": 1702790440,
+    "com.google.j2objc:j2objc-annotations:jar:sources": -1254484583,
+    "com.google.jimfs:jimfs": 375012684,
+    "com.google.jimfs:jimfs:jar:sources": -555304721,
+    "com.google.jsinterop:jsinterop-annotations": 1636460091,
+    "com.google.jsinterop:jsinterop-annotations:jar:sources": 694679492,
+    "com.google.protobuf:protobuf-java": 369447893,
+    "com.google.protobuf:protobuf-java:jar:sources": 1753068951,
+    "com.google.template:soy": 268221187,
+    "com.google.template:soy:jar:sources": 895044971,
+    "com.google.truth.extensions:truth-java8-extension": 766384514,
+    "com.google.truth.extensions:truth-java8-extension:jar:sources": 1257445795,
+    "com.google.truth.extensions:truth-liteproto-extension": -1774954418,
+    "com.google.truth.extensions:truth-liteproto-extension:jar:sources": -754757455,
+    "com.google.truth.extensions:truth-proto-extension": 1498106386,
+    "com.google.truth.extensions:truth-proto-extension:jar:sources": -171856482,
+    "com.google.truth:truth": 2133252626,
+    "com.google.truth:truth:jar:sources": 494258718,
+    "com.googlecode.javaewah:JavaEWAH": 1782327838,
+    "com.googlecode.javaewah:JavaEWAH:jar:sources": -77762967,
+    "com.googlecode.prolog-cafe:prolog-cafeteria": 1676859953,
+    "com.googlecode.prolog-cafe:prolog-cafeteria:jar:sources": -2075023034,
+    "com.googlecode.prolog-cafe:prolog-compiler": 796735585,
+    "com.googlecode.prolog-cafe:prolog-compiler:jar:sources": 2069727160,
+    "com.googlecode.prolog-cafe:prolog-io": 1604599374,
+    "com.googlecode.prolog-cafe:prolog-io:jar:sources": 2063038283,
+    "com.googlecode.prolog-cafe:prolog-runtime": -776672418,
+    "com.googlecode.prolog-cafe:prolog-runtime:jar:sources": 1445213194,
+    "com.h2database:h2": -552414482,
+    "com.h2database:h2:jar:sources": 1071831572,
+    "com.ibm.icu:icu4j": -1725310050,
+    "com.ibm.icu:icu4j:jar:sources": 163265109,
+    "com.icegreen:greenmail": 945965666,
+    "com.icegreen:greenmail:jar:sources": -245613146,
+    "com.ryanharter.auto.value:auto-value-gson-extension": -1071085858,
+    "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources": -670606081,
+    "com.ryanharter.auto.value:auto-value-gson-factory": -586162268,
+    "com.ryanharter.auto.value:auto-value-gson-factory:jar:sources": -543638406,
+    "com.ryanharter.auto.value:auto-value-gson-runtime": 1330169783,
+    "com.ryanharter.auto.value:auto-value-gson-runtime:jar:sources": 706425522,
+    "com.squareup:javapoet": -2135371934,
+    "com.squareup:javapoet:jar:sources": -54658965,
+    "com.sun.mail:javax.mail": -1925012432,
+    "com.sun.mail:javax.mail:jar:sources": 608124446,
+    "com.vladsch.flexmark:flexmark-all:jar:lib": 1454723856,
+    "commons-codec:commons-codec": -28659539,
+    "commons-codec:commons-codec:jar:sources": -499809306,
+    "commons-dbcp:commons-dbcp": 1878088686,
+    "commons-dbcp:commons-dbcp:jar:sources": 699520492,
+    "commons-io:commons-io": 1072619124,
+    "commons-io:commons-io:jar:sources": -238571367,
+    "commons-net:commons-net": 2103840351,
+    "commons-net:commons-net:jar:sources": -729248524,
+    "commons-pool:commons-pool": -212519749,
+    "commons-pool:commons-pool:jar:sources": 1134824914,
+    "commons-validator:commons-validator": 298719241,
+    "commons-validator:commons-validator:jar:sources": 1666065747,
+    "dk.brics:automaton": -509279352,
+    "dk.brics:automaton:jar:sources": 1035320481,
+    "eu.medsea.mimeutil:mime-util": -188093271,
+    "io.dropwizard.metrics:metrics-core": 740461012,
+    "io.dropwizard.metrics:metrics-core:jar:sources": -1988318320,
+    "io.github.java-diff-utils:java-diff-utils": -1180802694,
+    "io.github.java-diff-utils:java-diff-utils:jar:sources": 1551226070,
+    "io.sweers.autotransient:autotransient": 1808939417,
+    "io.sweers.autotransient:autotransient:jar:sources": -703216994,
+    "jakarta.inject:jakarta.inject-api": 188991469,
+    "jakarta.inject:jakarta.inject-api:jar:sources": -1584622703,
+    "jakarta.servlet:jakarta.servlet-api": -1421863120,
+    "jakarta.servlet:jakarta.servlet-api:jar:sources": -2084357823,
+    "javax.activation:activation": 1998212823,
+    "javax.activation:activation:jar:sources": 2062811499,
+    "javax.inject:javax.inject": 698155243,
+    "javax.inject:javax.inject:jar:sources": 1222576539,
+    "javax.servlet:javax.servlet-api": 735808080,
+    "javax.servlet:javax.servlet-api:jar:sources": -2015355058,
+    "junit:junit": -1256429642,
+    "junit:junit:jar:sources": 940567721,
+    "net.bytebuddy:byte-buddy": 570606503,
+    "net.bytebuddy:byte-buddy-agent": -647040865,
+    "net.bytebuddy:byte-buddy-agent:jar:sources": 1035878534,
+    "net.bytebuddy:byte-buddy:jar:sources": -750344117,
+    "net.java.dev.jna:jna": 1622514527,
+    "net.java.dev.jna:jna-platform": 1756885266,
+    "net.java.dev.jna:jna-platform:jar:sources": -507721531,
+    "net.java.dev.jna:jna:jar:sources": -902150228,
+    "net.minidev:json-smart": 1099098947,
+    "net.minidev:json-smart:jar:sources": 1458315043,
+    "net.sf.jopt-simple:jopt-simple": -1677351973,
+    "net.sf.jopt-simple:jopt-simple:jar:sources": 1072431863,
+    "net.sourceforge.nekohtml:nekohtml": 1220479218,
+    "net.sourceforge.nekohtml:nekohtml:jar:sources": -689850578,
+    "org.antlr:ST4": 1197671269,
+    "org.antlr:ST4:jar:sources": 494740756,
+    "org.antlr:antlr": -1604354866,
+    "org.antlr:antlr-runtime": -1636816377,
+    "org.antlr:antlr-runtime:jar:sources": 290715958,
+    "org.antlr:antlr:jar:sources": -1910959184,
+    "org.antlr:stringtemplate": -1632674608,
+    "org.antlr:stringtemplate:jar:sources": -562160043,
+    "org.apache.ant:ant": -431447101,
+    "org.apache.ant:ant-launcher": -333622244,
+    "org.apache.ant:ant-launcher:jar:sources": -77100552,
+    "org.apache.ant:ant:jar:sources": 389240804,
+    "org.apache.commons:commons-compress": -832484004,
+    "org.apache.commons:commons-compress:jar:sources": -1888643111,
+    "org.apache.commons:commons-lang3": 1841935436,
+    "org.apache.commons:commons-lang3:jar:sources": -982252396,
+    "org.apache.commons:commons-math3": -1383243934,
+    "org.apache.commons:commons-math3:jar:sources": -2132756896,
+    "org.apache.commons:commons-text": 869132518,
+    "org.apache.commons:commons-text:jar:sources": 400790036,
+    "org.apache.httpcomponents:fluent-hc": -1791063366,
+    "org.apache.httpcomponents:fluent-hc:jar:sources": -1265691559,
+    "org.apache.httpcomponents:httpclient": -930110091,
+    "org.apache.httpcomponents:httpclient:jar:sources": -779528763,
+    "org.apache.httpcomponents:httpcore": 67453319,
+    "org.apache.httpcomponents:httpcore:jar:sources": 2134002822,
+    "org.apache.james:apache-mime4j-core": 1172767236,
+    "org.apache.james:apache-mime4j-core:jar:sources": 1540423501,
+    "org.apache.james:apache-mime4j-dom": -704995319,
+    "org.apache.james:apache-mime4j-dom:jar:sources": -1311932247,
+    "org.apache.lucene:lucene-analysis-common": 117494556,
+    "org.apache.lucene:lucene-analysis-common:jar:sources": 184261997,
+    "org.apache.lucene:lucene-backward-codecs": 497823300,
+    "org.apache.lucene:lucene-backward-codecs:jar:sources": -1962146763,
+    "org.apache.lucene:lucene-core": -1567138351,
+    "org.apache.lucene:lucene-core:jar:sources": -1408614504,
+    "org.apache.lucene:lucene-misc": -673826350,
+    "org.apache.lucene:lucene-misc:jar:sources": 619654209,
+    "org.apache.lucene:lucene-queryparser": -1551455963,
+    "org.apache.lucene:lucene-queryparser:jar:sources": -1536444117,
+    "org.apache.mina:mina-core": -584591076,
+    "org.apache.mina:mina-core:jar:sources": 71382131,
+    "org.apache.sshd:sshd-mina": -36716477,
+    "org.apache.sshd:sshd-mina:jar:sources": 97528844,
+    "org.apache.sshd:sshd-osgi": 1810958617,
+    "org.apache.sshd:sshd-osgi:jar:sources": 710209222,
+    "org.apache.sshd:sshd-sftp": -320832840,
+    "org.apache.sshd:sshd-sftp:jar:sources": -1268961386,
+    "org.asciidoctor:asciidoctorj": 1685789893,
+    "org.asciidoctor:asciidoctorj:jar:sources": 2091708864,
+    "org.assertj:assertj-core": 1185991420,
+    "org.assertj:assertj-core:jar:sources": -697161745,
+    "org.bouncycastle:bcpg-jdk18on": 1314692470,
+    "org.bouncycastle:bcpg-jdk18on:jar:sources": -1476258101,
+    "org.bouncycastle:bcpkix-jdk18on": -1562281305,
+    "org.bouncycastle:bcpkix-jdk18on:jar:sources": 1945286569,
+    "org.bouncycastle:bcprov-jdk18on": -390275162,
+    "org.bouncycastle:bcprov-jdk18on:jar:sources": -2118706434,
+    "org.bouncycastle:bcutil-jdk18on": -1194874921,
+    "org.bouncycastle:bcutil-jdk18on:jar:sources": -1005159791,
+    "org.checkerframework:checker-compat-qual": -1467964223,
+    "org.checkerframework:checker-compat-qual:jar:sources": 187825033,
+    "org.checkerframework:checker-qual": -739034920,
+    "org.checkerframework:checker-qual:jar:sources": 1223055344,
+    "org.commonmark:commonmark": 965353610,
+    "org.commonmark:commonmark-ext-autolink": -1259954266,
+    "org.commonmark:commonmark-ext-autolink:jar:sources": 2044805555,
+    "org.commonmark:commonmark-ext-gfm-strikethrough": -523313366,
+    "org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources": 992870423,
+    "org.commonmark:commonmark-ext-gfm-tables": -1205584749,
+    "org.commonmark:commonmark-ext-gfm-tables:jar:sources": 1341057091,
+    "org.commonmark:commonmark-ext-yaml-front-matter": 1918089254,
+    "org.commonmark:commonmark-ext-yaml-front-matter:jar:sources": -902462472,
+    "org.commonmark:commonmark:jar:sources": -1511261547,
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": -820847559,
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources": -873060978,
+    "org.eclipse.jetty.ee8:jetty-ee8-nested": -159062536,
+    "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": 466458242,
+    "org.eclipse.jetty.ee8:jetty-ee8-security": -198386270,
+    "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources": 494500175,
+    "org.eclipse.jetty.ee8:jetty-ee8-servlet": 130236687,
+    "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources": -1891333550,
+    "org.eclipse.jetty.toolchain:jetty-servlet-api": 1364182673,
+    "org.eclipse.jetty.toolchain:jetty-servlet-api:jar:sources": 736604807,
+    "org.eclipse.jetty:jetty-http": -328633070,
+    "org.eclipse.jetty:jetty-http:jar:sources": 1289113758,
+    "org.eclipse.jetty:jetty-io": -1748418908,
+    "org.eclipse.jetty:jetty-io:jar:sources": -444551156,
+    "org.eclipse.jetty:jetty-jmx": 856481565,
+    "org.eclipse.jetty:jetty-jmx:jar:sources": -1523078582,
+    "org.eclipse.jetty:jetty-security": -1524832457,
+    "org.eclipse.jetty:jetty-security:jar:sources": -272643394,
+    "org.eclipse.jetty:jetty-server": 92951848,
+    "org.eclipse.jetty:jetty-server:jar:sources": -1602164191,
+    "org.eclipse.jetty:jetty-session": -1408099417,
+    "org.eclipse.jetty:jetty-session:jar:sources": -1187228748,
+    "org.eclipse.jetty:jetty-util": -1243632909,
+    "org.eclipse.jetty:jetty-util-ajax": -818511585,
+    "org.eclipse.jetty:jetty-util-ajax:jar:sources": -884639814,
+    "org.eclipse.jetty:jetty-util:jar:sources": 1776811920,
+    "org.hamcrest:hamcrest": 1282317766,
+    "org.hamcrest:hamcrest-core": 649657847,
+    "org.hamcrest:hamcrest-core:jar:sources": -1646511374,
+    "org.hamcrest:hamcrest:jar:sources": 1394599014,
+    "org.jruby:jruby-complete": -1216064857,
+    "org.jruby:jruby-complete:jar:sources": -308329410,
+    "org.jsoup:jsoup": 885756132,
+    "org.jsoup:jsoup:jar:sources": -2058254438,
+    "org.jspecify:jspecify": 117231129,
+    "org.jspecify:jspecify:jar:sources": -2134060298,
+    "org.mockito:mockito-core": -1701291388,
+    "org.mockito:mockito-core:jar:sources": 1900207417,
+    "org.nibor.autolink:autolink": -443901116,
+    "org.nibor.autolink:autolink:jar:sources": -1863403724,
+    "org.objenesis:objenesis": 1536526812,
+    "org.objenesis:objenesis:jar:sources": 321662415,
+    "org.openid4java:openid4java": -469605553,
+    "org.openid4java:openid4java:jar:sources": -1312563952,
+    "org.openjdk.jmh:jmh-core": 262505124,
+    "org.openjdk.jmh:jmh-core:jar:sources": 814651627,
+    "org.openjdk.jmh:jmh-generator-annprocess": 554503664,
+    "org.openjdk.jmh:jmh-generator-annprocess:jar:sources": -59690191,
+    "org.ow2.asm:asm": 1614653533,
+    "org.ow2.asm:asm-analysis": 865618371,
+    "org.ow2.asm:asm-analysis:jar:sources": 860015432,
+    "org.ow2.asm:asm-commons": 33716409,
+    "org.ow2.asm:asm-commons:jar:sources": 899206392,
+    "org.ow2.asm:asm-tree": -1173653421,
+    "org.ow2.asm:asm-tree:jar:sources": 590990274,
+    "org.ow2.asm:asm-util": 1667785410,
+    "org.ow2.asm:asm-util:jar:sources": 902318962,
+    "org.ow2.asm:asm:jar:sources": 340034775,
+    "org.roaringbitmap:RoaringBitmap": -1389417978,
+    "org.roaringbitmap:RoaringBitmap:jar:sources": -144719403,
+    "org.roaringbitmap:shims": -1535336248,
+    "org.roaringbitmap:shims:jar:sources": -422658237,
+    "org.slf4j:jcl-over-slf4j": 1113513525,
+    "org.slf4j:jcl-over-slf4j:jar:sources": 494484499,
+    "org.slf4j:slf4j-api": 500377983,
+    "org.slf4j:slf4j-api:jar:sources": 1751644771,
+    "org.slf4j:slf4j-ext": -1625091915,
+    "org.slf4j:slf4j-ext:jar:sources": 194861628,
+    "org.slf4j:slf4j-reload4j": -1779685465,
+    "org.slf4j:slf4j-reload4j:jar:sources": -1558574593,
+    "org.slf4j:slf4j-simple": -1055624668,
+    "org.slf4j:slf4j-simple:jar:sources": 751828941,
+    "org.tukaani:xz": -321288404,
+    "org.tukaani:xz:jar:sources": -893332662,
+    "xerces:xercesImpl": -1852819301,
+    "xerces:xercesImpl:jar:sources": -1057334748
   },
   "artifacts": {
     "antlr:antlr": {
@@ -567,31 +563,31 @@
     },
     "com.google.auto.factory:auto-factory": {
       "shasums": {
-        "jar": "d59fb7ada5962a480abf0b81d4d2a14a2952f17c026732359af8b585e531c16c",
-        "sources": "c6098f8976b8833cf40edc36d1e3f7cd5cbb474c018185650b5ca4e24e713e6a"
+        "jar": "9b4505cd7a60574d59386672c1d51d6154b803c892677eb909cf4155ebee771f",
+        "sources": "4f46cc87784c2339d103c3b3d4e19bc03dd3ecca018ea931c61c4b63a4829616"
       },
-      "version": "1.0.1"
+      "version": "1.1.0"
     },
     "com.google.auto.service:auto-service-annotations": {
       "shasums": {
-        "jar": "c7bec54b7b5588b5967e870341091c5691181d954cf2039f1bf0a6eeb837473b",
-        "sources": "b013ca159b0fea3a0041d3d5fbb3b7e49a819da80a172a01fb17dd28fd98e72b"
+        "jar": "cb5667036f75e9682b493b8b47adbe391bcff72b9d3e16c52ae725f514665af2",
+        "sources": "10ca2b6c041ec8a7dc07d391ab792a55a4d6518d8067c8e63b7deda92e1de9e9"
       },
-      "version": "1.0.1"
+      "version": "1.1.0"
     },
     "com.google.auto.value:auto-value": {
       "shasums": {
-        "jar": "aaf8d637bfed3c420436b9facf1b7a88d12c8785374e4202382783005319c2c3",
-        "sources": "4bff06fe077d68f964bd5e05f020ed78fd7870730441e403a2eb306360c4890a"
+        "jar": "0c556370ca3d072ba7dec6b37751ccad0e4804b6f3390cc9acb800d821dd9c0a",
+        "sources": "a9eb12d149bfbcfc7fb670689d67dea3440b0ecaea15829e2953a03a1503b29c"
       },
-      "version": "1.11.0"
+      "version": "1.11.1"
     },
     "com.google.auto.value:auto-value-annotations": {
       "shasums": {
-        "jar": "5a055ce4255333b3346e1a8703da5bf8ff049532286fdcd31712d624abe111dd",
-        "sources": "d7941e5f19bb38afcfa85350d57e5245856c23c98c2bbe32f6d31b5577f2bc33"
+        "jar": "6c61a11420a5cddf3313888e9d335d5eedfbeb9a8da26591470fbfe61f5bf859",
+        "sources": "2bc2b5c2f8b1dbe3c05673750f16ec51db2ef9bf54be942c278e16035e5dca21"
       },
-      "version": "1.11.0"
+      "version": "1.11.1"
     },
     "com.google.auto:auto-common": {
       "shasums": {
@@ -656,20 +652,6 @@
       },
       "version": "0.8"
     },
-    "com.google.gitiles:blame-cache": {
-      "shasums": {
-        "jar": "41dc6fe7d9967d3726cca4ff5e92247cd8c8da5ddc61b730077b8778c4829fcf",
-        "sources": "9e4d550f35331762434ec1bbd448d335971f00c8ee58c91500bc8fecad156a46"
-      },
-      "version": "1.6.0"
-    },
-    "com.google.gitiles:gitiles-servlet": {
-      "shasums": {
-        "jar": "08562ea7d57d881042e0598722e011e1c40bb69317065f2f5a67277f9c946be3",
-        "sources": "1a6556cde47342a29b93c0ebca969c728ec60b97cbee40bdf4a795ef8a0eb2b2"
-      },
-      "version": "1.6.0"
-    },
     "com.google.guava:failureaccess": {
       "shasums": {
         "jar": "cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb",
@@ -713,7 +695,7 @@
     },
     "com.google.inject:guice": {
       "shasums": {
-        "jar": "b4d4f7ec5e8fc17b4f98dee9d3f6cf6ae3ae13e2e5ed4b2f7bbf09bc4bb675d5",
+        "classes": "ff084ab91a2f00745d2535883e4c2e15f40970f93887c2df50504897119cfc3d",
         "sources": "656b82a85535ada22d251fbc4ab3e786e66997510d03325d168bc193c2148c09"
       },
       "version": "6.0.0"
@@ -741,10 +723,10 @@
     },
     "com.google.protobuf:protobuf-java": {
       "shasums": {
-        "jar": "3ca892fd6ea8b37d01bb6917dbc0bf2637548b756753f65a28d4f1d4d982347f",
-        "sources": "ed30fe6a51c7c15a6f123448304c97185f2039f2aeca9d5e3b4f53de3a4c813c"
+        "jar": "26434a93561a1a44bf7157b2630fa73a5b4d8df20e94df167bc03fcd14abd7e4",
+        "sources": "4107ed07313f8e6c0dc34c39690c41bb929470437a71509bd112a2e843b7f3f0"
       },
-      "version": "4.33.4"
+      "version": "4.36.1"
     },
     "com.google.template:soy": {
       "shasums": {
@@ -818,10 +800,10 @@
     },
     "com.h2database:h2": {
       "shasums": {
-        "jar": "29b70e427cc1c40cdc376283adbb0cc62853073797bb5fe5761f81fe73d57ce0",
-        "sources": "a853be74b3f6d63438c53d19e2c368f158ee622499422ba586d1f8bc17b843e2"
+        "jar": "82a80a2ac06901b03cdb233c663d21c4f49c884bf6d0cf85022729e8db9ab86f",
+        "sources": "6c2c426f446a2493213f42f628f5ecdf180685659322e5b4f72a92ba18d5f273"
       },
-      "version": "2.4.240"
+      "version": "2.5.250"
     },
     "com.ibm.icu:icu4j": {
       "shasums": {
@@ -837,20 +819,6 @@
       },
       "version": "1.5.5"
     },
-    "com.jcraft:jsch": {
-      "shasums": {
-        "jar": "d492b15a6d2ea3f1cc39c422c953c40c12289073dbe8360d98c0f6f9ec74fc44",
-        "sources": "e01ff2d282aa1b492bbb6187b3e363cd20a6ef51a6f23ae0ec4be179570a8480"
-      },
-      "version": "0.1.55"
-    },
-    "com.jcraft:jzlib": {
-      "shasums": {
-        "jar": "89b1360f407381bf61fde411019d8cbd009ebb10cff715f3669017a031027560",
-        "sources": "35ebd67941ce7024e6e7d80b60a4252a9687fa0f909a7079ac904bef6c1658cf"
-      },
-      "version": "1.1.3"
-    },
     "com.ryanharter.auto.value:auto-value-gson-extension": {
       "shasums": {
         "jar": "261be84be30a56994e132d718a85efcd579197a2edb9426b84c5722c56955eca",
@@ -1012,31 +980,31 @@
     },
     "net.bytebuddy:byte-buddy": {
       "shasums": {
-        "jar": "227d3e0ad51915809143f6a744ac0f4cb21f03214dab28e52ee7007c5ad7e9d9",
-        "sources": "8529b9ec93e698165dde6fddfd57e0820bb8f6d17f91ada1d8c525563636dc4d"
+        "jar": "2ed11da684a8f5b088e0222baa87461cd757e433a8dc4a03671457229d91d5fa",
+        "sources": "b1d35137942a8bff4199b8a2baa00ed3872923bc6878a6ff2731f9324fe1a2fc"
       },
-      "version": "1.18.8"
+      "version": "1.18.12"
     },
     "net.bytebuddy:byte-buddy-agent": {
       "shasums": {
-        "jar": "e303594d597de090abcb54580aa788478fc5e58f259e6ae914a1115f207c43af",
-        "sources": "881b5f123cefa2afc4ed89f8662da3ef9975e3a6e00f8f563b0470caf34b9511"
+        "jar": "287703bea3473edf89e1b373a097e49bad04ed48b85796228f68b1474dc93cf8",
+        "sources": "d4a1b7ab677479288a48889da66ee6e5a76b331ed3fbfb832568b7fc01c6c094"
       },
-      "version": "1.18.8"
+      "version": "1.18.12"
     },
     "net.java.dev.jna:jna": {
       "shasums": {
-        "jar": "260c4b1e22b1db9e110ee441c4f13ce115f841fa48c41d78750986214b395557",
-        "sources": "0b9224e215b3c6a464959e3f994ddd64c14d46fb4014facd6afa1cc18e469466"
+        "jar": "4fb141dd8ef6b0585ffceea4bc49602fbc6312fa977e2c488794ea3e6aafecae",
+        "sources": "0136f719ddd91d92d2368aa2c59a0c436810ac0b9c29024044383758b0e99786"
       },
-      "version": "5.18.1"
+      "version": "5.19.1"
     },
     "net.java.dev.jna:jna-platform": {
       "shasums": {
-        "jar": "ad14c1b1ec4f43d396231219dfa635ebf828f738eac9f890ea1bc07795892d9a",
-        "sources": "5ffcac4b35114c6539ab9485592a90153ddeefb60e675dd9e8a2ee24e54ec1bc"
+        "jar": "3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82",
+        "sources": "8288e781424a34b0478928be5664b01ace168e80608178d37d02464041528940"
       },
-      "version": "5.18.1"
+      "version": "5.19.1"
     },
     "net.minidev:json-smart": {
       "shasums": {
@@ -1087,6 +1055,20 @@
       },
       "version": "4.0.2"
     },
+    "org.apache.ant:ant": {
+      "shasums": {
+        "jar": "763acda4a69588c9ea8817a952851ff0c2fc4bffa1d081c2565dc407f29d5794",
+        "sources": "817ebf06c0a01d5d59cae996815b154b7b7172d40d75304f39ab107e8133c0d6"
+      },
+      "version": "1.10.15"
+    },
+    "org.apache.ant:ant-launcher": {
+      "shasums": {
+        "jar": "5c8551990307a032336d98ddaed549a39a689f07d4d4c6b950601bf22b3d6a1b",
+        "sources": "ab774b88172f80beb0e11e697adbab13e8436b4c959bef51d5dba44f1d192d72"
+      },
+      "version": "1.10.15"
+    },
     "org.apache.commons:commons-compress": {
       "shasums": {
         "jar": "e1522945218456f3649a39bc4afd70ce4bd466221519dba7d378f2141a4642ca",
@@ -1096,10 +1078,10 @@
     },
     "org.apache.commons:commons-lang3": {
       "shasums": {
-        "jar": "4eeeae8d20c078abb64b015ec158add383ac581571cddc45c68f0c9ae0230720",
-        "sources": "b15732a13e40df7f07c30f2cb8572874798e8dde581f1398943d2ad3765bafaa"
+        "jar": "69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4",
+        "sources": "eec245e820ec2800a1780cf756aefb427c1c6170e06902e67ac15b6910ce6335"
       },
-      "version": "3.18.0"
+      "version": "3.20.0"
     },
     "org.apache.commons:commons-math3": {
       "shasums": {
@@ -1152,45 +1134,45 @@
     },
     "org.apache.lucene:lucene-analysis-common": {
       "shasums": {
-        "jar": "8e768c9b2a3870f1fc2655181516699e719a56b9aaf8664226a11ae7d90cb4e9",
-        "sources": "c14727f25cc1a6c73d90720531309672300ca473f2d1f52e74281bfef4299c63"
+        "jar": "f710afd91a820987a48f3a215e076715b9237b324cf5458281109ef0d2a17b73",
+        "sources": "e3fef65eabb8c8c23d82ce8c8a73b1cff34e595fbfa97cb5fbfc14997a1ff9d0"
       },
-      "version": "10.4.0"
+      "version": "10.5.1"
     },
     "org.apache.lucene:lucene-backward-codecs": {
       "shasums": {
-        "jar": "4e77973982b8e24e4357b18e75f54cffe74b1ab7a354b1b81e1d23d6265de493",
-        "sources": "909b951a578828c40120eb7c12a12a02677b9974b88d47993517eb82089ec059"
+        "jar": "0cfe4d9d2af63cf90ddbc974d49337c6747158413587ee35bf4b8d4e288b6389",
+        "sources": "e0efd21f29345408b557f35fc904cef4a1d7fba44c48c45f2db78307cc55fc10"
       },
-      "version": "10.4.0"
+      "version": "10.5.1"
     },
     "org.apache.lucene:lucene-core": {
       "shasums": {
-        "jar": "8f894d211a8123938ccb9ff6827d136747e0eb6b1782ada6ac9086aa911b52e2",
-        "sources": "2411eab5a52ef845327fae889a7ed14f2d75d0c765bedb30156fe95195e05e16"
+        "jar": "2b4912cc792f462e8e7b350f7c958f538ee7ec42da4cf4b65902fb9d546bba53",
+        "sources": "04941f26f1bb4b6ca95a4738bceeecb30919611f7cb5d844eddb19da94495373"
       },
-      "version": "10.4.0"
+      "version": "10.5.1"
     },
     "org.apache.lucene:lucene-misc": {
       "shasums": {
-        "jar": "399f56e1bc2e08d927505139f9f459f1e33059e177eace080ea2bbcd88505fd5",
-        "sources": "a7a398e89dd21881c3bfa44c5a517ee93678b6260dc40185dd7e65afddaa5ade"
+        "jar": "75065a4afbd5bb3bc9230d9ee772368c769cdf4e1a4c33071bd9b0832c00c0ae",
+        "sources": "b1495220daa818f302d43e8d7548b38a63e214a8be635e1586f0b2bf6ee30373"
       },
-      "version": "10.4.0"
+      "version": "10.5.1"
     },
     "org.apache.lucene:lucene-queryparser": {
       "shasums": {
-        "jar": "4635f2a14e9c01574c4cf9ad60e018ab2b041b7889369107a991ef950648c847",
-        "sources": "70615c5d3f3e1a610716176c599fc79e0687a7879ede2c117ce22efde92ec9b0"
+        "jar": "75eddb74abccaf1f3ea5cb1ca2ceff29afb7f564892b50914404720f03d458c4",
+        "sources": "464ba30008b9ad56c4acda3b17823bdd8a8a2b94e99a0a6d77aebef570e4023e"
       },
-      "version": "10.4.0"
+      "version": "10.5.1"
     },
     "org.apache.mina:mina-core": {
       "shasums": {
-        "jar": "39b2dfc8e84380bf7adab657d3d5e1625cb6592a885ebdb854ec5c6f7a3ec88d",
-        "sources": "6c7823b8ed5a8d3511b8fb7ba6166ab825a45784741cd15c9991a75e54ad0dba"
+        "jar": "09b4b5e416834e5281dd0dfccac1a10413d6f42c89f133b1c43641e34f33e840",
+        "sources": "ea8192924cc0fd86742be74097c53edbce68232e9b27099ced307912e53ef1ea"
       },
-      "version": "2.2.4"
+      "version": "2.2.9"
     },
     "org.apache.sshd:sshd-mina": {
       "shasums": {
@@ -1229,31 +1211,31 @@
     },
     "org.bouncycastle:bcpg-jdk18on": {
       "shasums": {
-        "jar": "c0e6303a0d7589040f400950ecee87a14b81312e84ed15e5390ebb0c4566ddab",
-        "sources": "a8baa033c57614d36c3d2339a8c8e5902a8a2ed8cb7387cdb2b919e5a4b15f30"
+        "jar": "39426367dc247dfaae9b1253457a39298b6b9a6c28a39b908bca829e315323dd",
+        "sources": "605b09bd826d7e4e3e0cb4faf2e9b53a6bcb452be963c91499b79fd8497685be"
       },
-      "version": "1.84"
+      "version": "1.85"
     },
     "org.bouncycastle:bcpkix-jdk18on": {
       "shasums": {
-        "jar": "c87f16ed9e5ec61bc94151e9f3646ac44e50cd448121ce84367fa4b7ec7ec1bb",
-        "sources": "fe00c12243c28ead30ad6c7742be40ff005ab29f493c350b83b637fe4a9b5597"
+        "jar": "c9f82b2d4e99c4bbdfccf684e52cc06ea06a0b567bfd0d08f9c5a3f417055996",
+        "sources": "e5331f467331aba29bda6ddfb0df0da6d568928e29c2b0f20ea2fe123d802d20"
       },
-      "version": "1.84"
+      "version": "1.85"
     },
     "org.bouncycastle:bcprov-jdk18on": {
       "shasums": {
-        "jar": "64d6c5a6121fcd927152dd182cbed39afe0fda641a970d9bcc0c9cb1858b2731",
-        "sources": "e5f04550f7740e588edcbd1654c59277cd7ee8725d8b674e44f7f8f4b9c5674a"
+        "jar": "986b0fb92ec10e0c66b43e036ce0077e6150cfaecd1db9fb92b56672e157afe5",
+        "sources": "b37ac84b1d5435ab7b8d166c16ab9f75e09f68f8ec50479bae433939b241b03f"
       },
-      "version": "1.84"
+      "version": "1.85.2"
     },
     "org.bouncycastle:bcutil-jdk18on": {
       "shasums": {
-        "jar": "b374e16963421fb9cfb01cc20d7ad8fd2f8b8188e3eef0ec0a8965e245f7619a",
-        "sources": "192b719273dc33e8fd6edc3b30b126760b6740cf2e1ac3cc7cf845c7ffec9f2b"
+        "jar": "590f55ed5d68529239898a4a5c4f730b6e37f45d1cfa3fbe51f8485abe32c42d",
+        "sources": "b470a692878f92abf00b9c3af9147a45453250a80930df8f23f1d092c55e2d5e"
       },
-      "version": "1.84"
+      "version": "1.85"
     },
     "org.checkerframework:checker-compat-qual": {
       "shasums": {
@@ -1297,33 +1279,40 @@
       },
       "version": "0.24.0"
     },
-    "org.eclipse.jetty.ee10:jetty-ee10-servlet": {
+    "org.commonmark:commonmark-ext-yaml-front-matter": {
       "shasums": {
-        "jar": "7275d4bde7b0e790929bfba30efb2c13cf4eb3624bf03014f4d123366a1168e7",
-        "sources": "f0d39ce34f5f32d9fb50b47a2f5022e2ac2ba0055626252df355a6fc1ab51104"
+        "jar": "a845baba681ccbf385695fbaa6d58eb40d5ecc68f3edb968c42f074f630e8fec",
+        "sources": "5cee64663842f1e7128af9c3e62ef183ea2cf205436d27a08b7cb5be0b20edb6"
       },
-      "version": "12.1.9"
+      "version": "0.24.0"
+    },
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": {
+      "shasums": {
+        "jar": "af76ee845345f184d96d8f6c926e64f8bda4cf858013598d392ad604d37e6bac",
+        "sources": "0daaa96e80d48f516c580b7f18bcdf2ebdea75654a7fddb1a1a933af36ae2d16"
+      },
+      "version": "12.1.12"
     },
     "org.eclipse.jetty.ee8:jetty-ee8-nested": {
       "shasums": {
-        "jar": "240df6fb5bb28545183cbac87f191c0d21074b17a2c9d4562581a68a8d79c347",
-        "sources": "45b7a070430ced248fab480132c2a02c98b64181b619f11fdc27291fc60cdefc"
+        "jar": "1c44681c868acc128c046e83a7e649197a6aa1d9a78f2d7588a2202d8bc94302",
+        "sources": "c04b2473642ff95ccd12a743112a115c76ce93ba94b4d58e70624c1b094ad6ec"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty.ee8:jetty-ee8-security": {
       "shasums": {
-        "jar": "85351106e71c1036256488bbd2c84879d6316c67cdce3b44dda7835daf82e3c9",
-        "sources": "d5e6227dfbde0e6beb3cf2976cc64049a85b828143bb328f3de3ef06e5a4803f"
+        "jar": "2d2cc4f51a89cd43d2d5cab7603d6ceff3d78947e5cb2f62fb2d7eac5a1d39e9",
+        "sources": "a8b42ba8dfb60ed6805b7c4ef51631726a593b617cf4472724c00b16fa003c49"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty.ee8:jetty-ee8-servlet": {
       "shasums": {
-        "jar": "f593838935e9c15b9cd6bbe8707af6f5e8204f2732c88c84b654962bac660dbe",
-        "sources": "53b2477db964532be722a1ff8f53ab4d7f88b87012f4e2b4e3360c5ecd08aff0"
+        "jar": "feeba82222e0dc9c517124254ecd79d1244cfb13ced1ed8aca59e0656e331e4e",
+        "sources": "3eba19d3ffe606959bd948600bb9e0cc107008b35d632747dd88906de2c4a15b"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty.toolchain:jetty-servlet-api": {
       "shasums": {
@@ -1334,59 +1323,59 @@
     },
     "org.eclipse.jetty:jetty-http": {
       "shasums": {
-        "jar": "090f276739fd9bf8c30511007caec669ea3804b1df1061c37f44467474bee71d",
-        "sources": "4d416e2686881085327d3a28e349029d4ae9fc9b44db831291633f18bbf01625"
+        "jar": "5476e16ef7e28883dba3669ee06491021e4a9aeba9a76f901a3f5c2bb5252a3e",
+        "sources": "ed89dee39bef7a469218ea81ad69675e247ee35debcbb8d5e685cbf127301585"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty:jetty-io": {
       "shasums": {
-        "jar": "448fc0f8f6f5f7251fc46de8e3aae7da14bd7c571a8043dac3dc381d08228fa0",
-        "sources": "bf40abffd40e759b16b642e12dfacb7bca1d0c937f5456b62506cc78169f6b56"
+        "jar": "a809a6a534adccfaa08bca94aa54328e3ac858973ccddbda770f7e9e0f5099fc",
+        "sources": "db0aa06342140ef5f91e1923120376a1abc8ef04911a4350da105f90428f8695"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty:jetty-jmx": {
       "shasums": {
-        "jar": "baaaf76b139335125bfa51109c80d271de73f40ee8b5270674f56e90a66e9d81",
-        "sources": "b45a4c63d7b7ea44ca6eb9b9c5fbf1d100069bec66942c83ee553d05335a1fcb"
+        "jar": "21c9e70b26ec32c3f4c06af36538c17f86d835561de41575741058ded8c73a43",
+        "sources": "439f0045abe56c4a6b4e09e3120d0c0077c253b7f6c0fa5dc6bafb3003ed8d1c"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty:jetty-security": {
       "shasums": {
-        "jar": "2f34b7895cec4e3547a1b52e12e7e92b3f3a110bf3c17637f8743ca3f4e42f0c",
-        "sources": "9e55b7f04431d4cc723a85a18ddc910e71f5b1a284c28b059d297d07509b4ff8"
+        "jar": "fcc0206c1ac66b632ef29a59ccf76c3f3737a62ef54694b1d111eeb2e6efe804",
+        "sources": "e6b477ec23e940f00fc592a68f64118418b039c9f3e0a0f7d6409a4a56c98777"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty:jetty-server": {
       "shasums": {
-        "jar": "4b0108e87abada7027123deca17186249413232dad0a2bd58a4e70a987b5354a",
-        "sources": "5e7693e35285d286dcd0bfdc73d74e6824027eeaaa0a9b4c70c0080475c22f71"
+        "jar": "9ba04bb8d011444a10873a51b1cb8297c04f70db55c8a8d9efcc3a9b97896787",
+        "sources": "4c9f9b9162336d26315bd37d8dfd241c6dbfce2af9676255f11f6036dfd57b56"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty:jetty-session": {
       "shasums": {
-        "jar": "164649123d15a3f2be5c196e82aa652dff05c75362db71b0f4ab6bb35165020a",
-        "sources": "b11e8cfb2db04ac2d1633bccfd47ba9e1b3afca0b75d4354a38140ba3e94c83e"
+        "jar": "73aea4cdd91f2027da1a09cd5953b661625017971d53d8e1ff342f8547752b06",
+        "sources": "12b6ae5b07e9614c81e0cfbb464475ab1e8a638bb35a4c770e5358e60fc64267"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty:jetty-util": {
       "shasums": {
-        "jar": "c52b4ff62cdacca8a399c611231129e6bd1074db7e3892e78cd106725cdd0ef1",
-        "sources": "209630667d8e042f187bed4e754c3f8af7c5247888fc83b8dc97b5bb2134d435"
+        "jar": "f6fbfa61cfcca032ff82ad8ff3b66a14374047f15486521dbc89a6c0df19ccf2",
+        "sources": "04b565fe8b23d70735515d135932bd3c6b283ff29e5944a9165df3043eeba99d"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.eclipse.jetty:jetty-util-ajax": {
       "shasums": {
-        "jar": "73dfbf388b46c9e2afbc78823ea556f8d25cf851902f99873d03df90becc2b97",
-        "sources": "4617309092393ed2f6ed76d915f24b518ccbff8c8731124bf109e346607e86b3"
+        "jar": "ca4d49183b8f92af96c04dab7a40e70cc2ebac554e6de010e1b9a983ae342cb2",
+        "sources": "41bcaeb259a63a898885dd05bfa6b8231051079e38140bbb4f18181a9037b319"
       },
-      "version": "12.1.10"
+      "version": "12.1.12"
     },
     "org.hamcrest:hamcrest": {
       "shasums": {
@@ -1582,8 +1571,7 @@
       "com.google.auto.value:auto-value-annotations",
       "com.google.auto:auto-common",
       "com.google.guava:guava",
-      "com.squareup:javapoet",
-      "javax.inject:javax.inject"
+      "com.squareup:javapoet"
     ],
     "com.google.auto:auto-common": [
       "com.google.guava:guava"
@@ -1628,13 +1616,9 @@
       "org.jspecify:jspecify"
     ],
     "com.google.inject.extensions:guice-assistedinject": [
-      "com.google.errorprone:error_prone_annotations",
-      "com.google.inject:guice"
+      "com.google.errorprone:error_prone_annotations"
     ],
-    "com.google.inject.extensions:guice-servlet": [
-      "com.google.inject:guice"
-    ],
-    "com.google.inject:guice": [
+    "com.google.inject:guice:jar:classes": [
       "aopalliance:aopalliance",
       "com.google.guava:guava",
       "jakarta.inject:jakarta.inject-api",
@@ -1653,7 +1637,6 @@
       "com.google.flogger:flogger-system-backend",
       "com.google.flogger:google-extensions",
       "com.google.guava:guava",
-      "com.google.inject:guice",
       "com.google.protobuf:protobuf-java",
       "com.ibm.icu:icu4j",
       "javax.inject:javax.inject",
@@ -1742,6 +1725,9 @@
     "org.antlr:stringtemplate": [
       "org.antlr:antlr-runtime"
     ],
+    "org.apache.ant:ant": [
+      "org.apache.ant:ant-launcher"
+    ],
     "org.apache.commons:commons-compress": [
       "commons-codec:commons-codec",
       "commons-io:commons-io",
@@ -1815,7 +1801,10 @@
     "org.commonmark:commonmark-ext-gfm-tables": [
       "org.commonmark:commonmark"
     ],
-    "org.eclipse.jetty.ee10:jetty-ee10-servlet": [
+    "org.commonmark:commonmark-ext-yaml-front-matter": [
+      "org.commonmark:commonmark"
+    ],
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": [
       "jakarta.servlet:jakarta.servlet-api",
       "org.eclipse.jetty:jetty-security",
       "org.eclipse.jetty:jetty-server",
@@ -1878,7 +1867,6 @@
       "org.objenesis:objenesis"
     ],
     "org.openid4java:openid4java": [
-      "com.google.inject:guice",
       "net.sourceforge.nekohtml:nekohtml",
       "org.apache.httpcomponents:httpclient",
       "xerces:xercesImpl"
@@ -1992,28 +1980,19 @@
       "autovalue.shaded.com.google.auto.service",
       "autovalue.shaded.com.google.common.annotations",
       "autovalue.shaded.com.google.common.base",
-      "autovalue.shaded.com.google.common.cache",
       "autovalue.shaded.com.google.common.collect",
-      "autovalue.shaded.com.google.common.escape",
-      "autovalue.shaded.com.google.common.eventbus",
-      "autovalue.shaded.com.google.common.graph",
       "autovalue.shaded.com.google.common.hash",
-      "autovalue.shaded.com.google.common.html",
       "autovalue.shaded.com.google.common.io",
       "autovalue.shaded.com.google.common.math",
-      "autovalue.shaded.com.google.common.net",
       "autovalue.shaded.com.google.common.primitives",
       "autovalue.shaded.com.google.common.reflect",
-      "autovalue.shaded.com.google.common.util.concurrent",
-      "autovalue.shaded.com.google.common.xml",
       "autovalue.shaded.com.google.errorprone.annotations",
       "autovalue.shaded.com.google.errorprone.annotations.concurrent",
       "autovalue.shaded.com.google.escapevelocity",
       "autovalue.shaded.com.google.j2objc.annotations",
       "autovalue.shaded.com.squareup.javapoet",
       "autovalue.shaded.net.ltgt.gradle.incap",
-      "autovalue.shaded.org.checkerframework.checker.nullness.qual",
-      "autovalue.shaded.org.checkerframework.framework.qual",
+      "autovalue.shaded.org.jspecify.annotations",
       "autovalue.shaded.org.objectweb.asm",
       "com.google.auto.value.extension",
       "com.google.auto.value.extension.memoized.processor",
@@ -2076,15 +2055,6 @@
     "com.google.flogger:google-extensions": [
       "com.google.common.flogger"
     ],
-    "com.google.gitiles:blame-cache": [
-      "com.google.gitiles.blame.cache"
-    ],
-    "com.google.gitiles:gitiles-servlet": [
-      "com.google.gitiles",
-      "com.google.gitiles.blame",
-      "com.google.gitiles.doc",
-      "com.google.gitiles.doc.html"
-    ],
     "com.google.guava:failureaccess": [
       "com.google.common.util.concurrent.internal"
     ],
@@ -2112,7 +2082,6 @@
       "com.google.common.collect.testing",
       "com.google.common.collect.testing.features",
       "com.google.common.collect.testing.google",
-      "com.google.common.collect.testing.suites",
       "com.google.common.collect.testing.testers",
       "com.google.common.escape.testing",
       "com.google.common.testing",
@@ -2125,7 +2094,7 @@
     "com.google.inject.extensions:guice-servlet": [
       "com.google.inject.servlet"
     ],
-    "com.google.inject:guice": [
+    "com.google.inject:guice:jar:classes": [
       "com.google.inject",
       "com.google.inject.binder",
       "com.google.inject.internal",
@@ -2359,53 +2328,22 @@
       "com.icegreen.greenmail.user",
       "com.icegreen.greenmail.util"
     ],
-    "com.jcraft:jsch": [
-      "com.jcraft.jsch",
-      "com.jcraft.jsch.jce",
-      "com.jcraft.jsch.jcraft",
-      "com.jcraft.jsch.jgss"
-    ],
-    "com.jcraft:jzlib": [
-      "com.jcraft.jzlib"
-    ],
     "com.ryanharter.auto.value:auto-value-gson-extension": [
       "autovaluegson.shaded.com.google.auto.common",
       "autovaluegson.shaded.com.google.common.annotations",
       "autovaluegson.shaded.com.google.common.base",
-      "autovaluegson.shaded.com.google.common.cache",
       "autovaluegson.shaded.com.google.common.collect",
-      "autovaluegson.shaded.com.google.common.escape",
-      "autovaluegson.shaded.com.google.common.eventbus",
-      "autovaluegson.shaded.com.google.common.graph",
-      "autovaluegson.shaded.com.google.common.hash",
-      "autovaluegson.shaded.com.google.common.html",
-      "autovaluegson.shaded.com.google.common.io",
       "autovaluegson.shaded.com.google.common.math",
-      "autovaluegson.shaded.com.google.common.net",
       "autovaluegson.shaded.com.google.common.primitives",
-      "autovaluegson.shaded.com.google.common.reflect",
-      "autovaluegson.shaded.com.google.common.util.concurrent",
-      "autovaluegson.shaded.com.google.common.xml",
       "com.ryanharter.auto.value.gson"
     ],
     "com.ryanharter.auto.value:auto-value-gson-factory": [
       "autovaluegson.factory.shaded.com.google.auto.common",
       "autovaluegson.factory.shaded.com.google.common.annotations",
       "autovaluegson.factory.shaded.com.google.common.base",
-      "autovaluegson.factory.shaded.com.google.common.cache",
       "autovaluegson.factory.shaded.com.google.common.collect",
-      "autovaluegson.factory.shaded.com.google.common.escape",
-      "autovaluegson.factory.shaded.com.google.common.eventbus",
-      "autovaluegson.factory.shaded.com.google.common.graph",
-      "autovaluegson.factory.shaded.com.google.common.hash",
-      "autovaluegson.factory.shaded.com.google.common.html",
-      "autovaluegson.factory.shaded.com.google.common.io",
       "autovaluegson.factory.shaded.com.google.common.math",
-      "autovaluegson.factory.shaded.com.google.common.net",
       "autovaluegson.factory.shaded.com.google.common.primitives",
-      "autovaluegson.factory.shaded.com.google.common.reflect",
-      "autovaluegson.factory.shaded.com.google.common.util.concurrent",
-      "autovaluegson.factory.shaded.com.google.common.xml",
       "com.ryanharter.auto.value.gson.factory"
     ],
     "com.ryanharter.auto.value:auto-value-gson-runtime": [
@@ -2786,6 +2724,73 @@
       "org.stringtemplate.v4.gui",
       "org.stringtemplate.v4.misc"
     ],
+    "org.apache.ant:ant": [
+      "org.apache.tools.ant",
+      "org.apache.tools.ant.attribute",
+      "org.apache.tools.ant.dispatch",
+      "org.apache.tools.ant.filters",
+      "org.apache.tools.ant.filters.util",
+      "org.apache.tools.ant.helper",
+      "org.apache.tools.ant.input",
+      "org.apache.tools.ant.listener",
+      "org.apache.tools.ant.loader",
+      "org.apache.tools.ant.property",
+      "org.apache.tools.ant.taskdefs",
+      "org.apache.tools.ant.taskdefs.compilers",
+      "org.apache.tools.ant.taskdefs.condition",
+      "org.apache.tools.ant.taskdefs.cvslib",
+      "org.apache.tools.ant.taskdefs.email",
+      "org.apache.tools.ant.taskdefs.launcher",
+      "org.apache.tools.ant.taskdefs.modules",
+      "org.apache.tools.ant.taskdefs.optional",
+      "org.apache.tools.ant.taskdefs.optional.ccm",
+      "org.apache.tools.ant.taskdefs.optional.clearcase",
+      "org.apache.tools.ant.taskdefs.optional.depend",
+      "org.apache.tools.ant.taskdefs.optional.depend.constantpool",
+      "org.apache.tools.ant.taskdefs.optional.ejb",
+      "org.apache.tools.ant.taskdefs.optional.extension",
+      "org.apache.tools.ant.taskdefs.optional.extension.resolvers",
+      "org.apache.tools.ant.taskdefs.optional.i18n",
+      "org.apache.tools.ant.taskdefs.optional.j2ee",
+      "org.apache.tools.ant.taskdefs.optional.javacc",
+      "org.apache.tools.ant.taskdefs.optional.javah",
+      "org.apache.tools.ant.taskdefs.optional.jlink",
+      "org.apache.tools.ant.taskdefs.optional.jsp",
+      "org.apache.tools.ant.taskdefs.optional.jsp.compilers",
+      "org.apache.tools.ant.taskdefs.optional.native2ascii",
+      "org.apache.tools.ant.taskdefs.optional.net",
+      "org.apache.tools.ant.taskdefs.optional.pvcs",
+      "org.apache.tools.ant.taskdefs.optional.script",
+      "org.apache.tools.ant.taskdefs.optional.sos",
+      "org.apache.tools.ant.taskdefs.optional.testing",
+      "org.apache.tools.ant.taskdefs.optional.unix",
+      "org.apache.tools.ant.taskdefs.optional.vss",
+      "org.apache.tools.ant.taskdefs.optional.windows",
+      "org.apache.tools.ant.taskdefs.rmic",
+      "org.apache.tools.ant.types",
+      "org.apache.tools.ant.types.mappers",
+      "org.apache.tools.ant.types.optional",
+      "org.apache.tools.ant.types.optional.depend",
+      "org.apache.tools.ant.types.resources",
+      "org.apache.tools.ant.types.resources.comparators",
+      "org.apache.tools.ant.types.resources.selectors",
+      "org.apache.tools.ant.types.selectors",
+      "org.apache.tools.ant.types.selectors.modifiedselector",
+      "org.apache.tools.ant.types.spi",
+      "org.apache.tools.ant.util",
+      "org.apache.tools.ant.util.depend",
+      "org.apache.tools.ant.util.facade",
+      "org.apache.tools.ant.util.java15",
+      "org.apache.tools.ant.util.optional",
+      "org.apache.tools.ant.util.regexp",
+      "org.apache.tools.bzip2",
+      "org.apache.tools.mail",
+      "org.apache.tools.tar",
+      "org.apache.tools.zip"
+    ],
+    "org.apache.ant:ant-launcher": [
+      "org.apache.tools.ant.launch"
+    ],
     "org.apache.commons:commons-compress": [
       "org.apache.commons.compress",
       "org.apache.commons.compress.archivers",
@@ -2814,7 +2819,6 @@
       "org.apache.commons.compress.compressors.xz",
       "org.apache.commons.compress.compressors.z",
       "org.apache.commons.compress.compressors.zstandard",
-      "org.apache.commons.compress.harmony",
       "org.apache.commons.compress.harmony.archive.internal.nls",
       "org.apache.commons.compress.harmony.pack200",
       "org.apache.commons.compress.harmony.unpack200",
@@ -3118,6 +3122,7 @@
       "org.apache.lucene.codecs.lucene99",
       "org.apache.lucene.codecs.perfield",
       "org.apache.lucene.document",
+      "org.apache.lucene.document.column",
       "org.apache.lucene.geo",
       "org.apache.lucene.index",
       "org.apache.lucene.internal.hppc",
@@ -3205,7 +3210,6 @@
       "org.apache.mina.filter.statistic",
       "org.apache.mina.filter.stream",
       "org.apache.mina.filter.util",
-      "org.apache.mina.handler",
       "org.apache.mina.handler.chain",
       "org.apache.mina.handler.demux",
       "org.apache.mina.handler.multiton",
@@ -3416,19 +3420,20 @@
       "org.bouncycastle.openpgp.api.jcajce",
       "org.bouncycastle.openpgp.api.util",
       "org.bouncycastle.openpgp.bc",
-      "org.bouncycastle.openpgp.examples",
       "org.bouncycastle.openpgp.jcajce",
       "org.bouncycastle.openpgp.operator",
       "org.bouncycastle.openpgp.operator.bc",
       "org.bouncycastle.openpgp.operator.jcajce"
     ],
     "org.bouncycastle:bcpkix-jdk18on": [
+      "org.bouncycastle.cades",
       "org.bouncycastle.cert",
       "org.bouncycastle.cert.bc",
       "org.bouncycastle.cert.cmp",
       "org.bouncycastle.cert.crmf",
       "org.bouncycastle.cert.crmf.bc",
       "org.bouncycastle.cert.crmf.jcajce",
+      "org.bouncycastle.cert.ct",
       "org.bouncycastle.cert.dane",
       "org.bouncycastle.cert.dane.fetcher",
       "org.bouncycastle.cert.jcajce",
@@ -3436,6 +3441,9 @@
       "org.bouncycastle.cert.ocsp.jcajce",
       "org.bouncycastle.cert.path",
       "org.bouncycastle.cert.path.validations",
+      "org.bouncycastle.cert.plants",
+      "org.bouncycastle.cert.plants.bc",
+      "org.bouncycastle.cert.plants.jcajce",
       "org.bouncycastle.cert.selector",
       "org.bouncycastle.cert.selector.jcajce",
       "org.bouncycastle.cmc",
@@ -3467,6 +3475,7 @@
       "org.bouncycastle.pkcs",
       "org.bouncycastle.pkcs.bc",
       "org.bouncycastle.pkcs.jcajce",
+      "org.bouncycastle.pkcs.util",
       "org.bouncycastle.pkix",
       "org.bouncycastle.pkix.jcajce",
       "org.bouncycastle.pkix.util",
@@ -3483,9 +3492,11 @@
       "org.bouncycastle.asn1.bc",
       "org.bouncycastle.asn1.cryptopro",
       "org.bouncycastle.asn1.gm",
+      "org.bouncycastle.asn1.iana",
       "org.bouncycastle.asn1.nist",
       "org.bouncycastle.asn1.ocsp",
       "org.bouncycastle.asn1.pkcs",
+      "org.bouncycastle.asn1.plants",
       "org.bouncycastle.asn1.sec",
       "org.bouncycastle.asn1.teletrust",
       "org.bouncycastle.asn1.ua",
@@ -3501,14 +3512,15 @@
       "org.bouncycastle.crypto.agreement.ecjpake",
       "org.bouncycastle.crypto.agreement.jpake",
       "org.bouncycastle.crypto.agreement.kdf",
+      "org.bouncycastle.crypto.agreement.owl",
       "org.bouncycastle.crypto.agreement.srp",
+      "org.bouncycastle.crypto.bls",
       "org.bouncycastle.crypto.commitments",
       "org.bouncycastle.crypto.constraints",
       "org.bouncycastle.crypto.digests",
       "org.bouncycastle.crypto.ec",
       "org.bouncycastle.crypto.encodings",
       "org.bouncycastle.crypto.engines",
-      "org.bouncycastle.crypto.examples",
       "org.bouncycastle.crypto.fpe",
       "org.bouncycastle.crypto.generators",
       "org.bouncycastle.crypto.hash2curve",
@@ -3517,6 +3529,8 @@
       "org.bouncycastle.crypto.hpke",
       "org.bouncycastle.crypto.io",
       "org.bouncycastle.crypto.kems",
+      "org.bouncycastle.crypto.kems.cmce",
+      "org.bouncycastle.crypto.kems.frodo",
       "org.bouncycastle.crypto.kems.mlkem",
       "org.bouncycastle.crypto.macs",
       "org.bouncycastle.crypto.modes",
@@ -3535,14 +3549,12 @@
       "org.bouncycastle.crypto.util",
       "org.bouncycastle.i18n",
       "org.bouncycastle.i18n.filter",
-      "org.bouncycastle.iana",
       "org.bouncycastle.internal.asn1.bsi",
       "org.bouncycastle.internal.asn1.cms",
       "org.bouncycastle.internal.asn1.cryptlib",
       "org.bouncycastle.internal.asn1.eac",
       "org.bouncycastle.internal.asn1.edec",
       "org.bouncycastle.internal.asn1.gnu",
-      "org.bouncycastle.internal.asn1.iana",
       "org.bouncycastle.internal.asn1.isara",
       "org.bouncycastle.internal.asn1.isismtt",
       "org.bouncycastle.internal.asn1.iso",
@@ -3557,6 +3569,8 @@
       "org.bouncycastle.jcajce.interfaces",
       "org.bouncycastle.jcajce.io",
       "org.bouncycastle.jcajce.provider.asymmetric",
+      "org.bouncycastle.jcajce.provider.asymmetric.cmce",
+      "org.bouncycastle.jcajce.provider.asymmetric.compositekem",
       "org.bouncycastle.jcajce.provider.asymmetric.compositesignatures",
       "org.bouncycastle.jcajce.provider.asymmetric.dh",
       "org.bouncycastle.jcajce.provider.asymmetric.dsa",
@@ -3566,6 +3580,7 @@
       "org.bouncycastle.jcajce.provider.asymmetric.ecgost12",
       "org.bouncycastle.jcajce.provider.asymmetric.edec",
       "org.bouncycastle.jcajce.provider.asymmetric.elgamal",
+      "org.bouncycastle.jcajce.provider.asymmetric.frodokem",
       "org.bouncycastle.jcajce.provider.asymmetric.gost",
       "org.bouncycastle.jcajce.provider.asymmetric.ies",
       "org.bouncycastle.jcajce.provider.asymmetric.mldsa",
@@ -3611,46 +3626,64 @@
       "org.bouncycastle.math.raw",
       "org.bouncycastle.pqc.asn1",
       "org.bouncycastle.pqc.crypto",
+      "org.bouncycastle.pqc.crypto.aimer",
       "org.bouncycastle.pqc.crypto.cmce",
       "org.bouncycastle.pqc.crypto.crystals.dilithium",
+      "org.bouncycastle.pqc.crypto.faest",
       "org.bouncycastle.pqc.crypto.falcon",
       "org.bouncycastle.pqc.crypto.frodo",
+      "org.bouncycastle.pqc.crypto.haetae",
+      "org.bouncycastle.pqc.crypto.hawk",
       "org.bouncycastle.pqc.crypto.hqc",
       "org.bouncycastle.pqc.crypto.lms",
       "org.bouncycastle.pqc.crypto.mayo",
       "org.bouncycastle.pqc.crypto.mldsa",
       "org.bouncycastle.pqc.crypto.mlkem",
+      "org.bouncycastle.pqc.crypto.mqom",
       "org.bouncycastle.pqc.crypto.newhope",
       "org.bouncycastle.pqc.crypto.ntru",
       "org.bouncycastle.pqc.crypto.ntruplus",
       "org.bouncycastle.pqc.crypto.ntruprime",
+      "org.bouncycastle.pqc.crypto.qruov",
       "org.bouncycastle.pqc.crypto.saber",
+      "org.bouncycastle.pqc.crypto.sdith",
       "org.bouncycastle.pqc.crypto.slhdsa",
       "org.bouncycastle.pqc.crypto.snova",
       "org.bouncycastle.pqc.crypto.sphincs",
+      "org.bouncycastle.pqc.crypto.sqisign",
+      "org.bouncycastle.pqc.crypto.uov",
       "org.bouncycastle.pqc.crypto.util",
       "org.bouncycastle.pqc.crypto.xmss",
       "org.bouncycastle.pqc.crypto.xwing",
       "org.bouncycastle.pqc.jcajce.interfaces",
       "org.bouncycastle.pqc.jcajce.provider",
+      "org.bouncycastle.pqc.jcajce.provider.aimer",
       "org.bouncycastle.pqc.jcajce.provider.bike",
       "org.bouncycastle.pqc.jcajce.provider.cmce",
       "org.bouncycastle.pqc.jcajce.provider.dilithium",
+      "org.bouncycastle.pqc.jcajce.provider.faest",
       "org.bouncycastle.pqc.jcajce.provider.falcon",
       "org.bouncycastle.pqc.jcajce.provider.frodo",
+      "org.bouncycastle.pqc.jcajce.provider.haetae",
+      "org.bouncycastle.pqc.jcajce.provider.hawk",
       "org.bouncycastle.pqc.jcajce.provider.hqc",
       "org.bouncycastle.pqc.jcajce.provider.kyber",
       "org.bouncycastle.pqc.jcajce.provider.lms",
       "org.bouncycastle.pqc.jcajce.provider.mayo",
+      "org.bouncycastle.pqc.jcajce.provider.mqom",
       "org.bouncycastle.pqc.jcajce.provider.newhope",
       "org.bouncycastle.pqc.jcajce.provider.ntru",
       "org.bouncycastle.pqc.jcajce.provider.ntruplus",
       "org.bouncycastle.pqc.jcajce.provider.ntruprime",
       "org.bouncycastle.pqc.jcajce.provider.picnic",
+      "org.bouncycastle.pqc.jcajce.provider.qruov",
       "org.bouncycastle.pqc.jcajce.provider.saber",
+      "org.bouncycastle.pqc.jcajce.provider.sdith",
       "org.bouncycastle.pqc.jcajce.provider.snova",
       "org.bouncycastle.pqc.jcajce.provider.sphincs",
       "org.bouncycastle.pqc.jcajce.provider.sphincsplus",
+      "org.bouncycastle.pqc.jcajce.provider.sqisign",
+      "org.bouncycastle.pqc.jcajce.provider.uov",
       "org.bouncycastle.pqc.jcajce.provider.util",
       "org.bouncycastle.pqc.jcajce.provider.xmss",
       "org.bouncycastle.pqc.jcajce.spec",
@@ -3684,7 +3717,6 @@
       "org.bouncycastle.asn1.ess",
       "org.bouncycastle.asn1.est",
       "org.bouncycastle.asn1.gnu",
-      "org.bouncycastle.asn1.iana",
       "org.bouncycastle.asn1.icao",
       "org.bouncycastle.asn1.isara",
       "org.bouncycastle.asn1.isismtt",
@@ -3783,14 +3815,18 @@
       "org.commonmark.ext.gfm.tables",
       "org.commonmark.ext.gfm.tables.internal"
     ],
-    "org.eclipse.jetty.ee10:jetty-ee10-servlet": [
-      "org.eclipse.jetty.ee10.servlet",
-      "org.eclipse.jetty.ee10.servlet.internal",
-      "org.eclipse.jetty.ee10.servlet.jmx",
-      "org.eclipse.jetty.ee10.servlet.listener",
-      "org.eclipse.jetty.ee10.servlet.security",
-      "org.eclipse.jetty.ee10.servlet.security.authentication",
-      "org.eclipse.jetty.ee10.servlet.util"
+    "org.commonmark:commonmark-ext-yaml-front-matter": [
+      "org.commonmark.ext.front.matter",
+      "org.commonmark.ext.front.matter.internal"
+    ],
+    "org.eclipse.jetty.ee11:jetty-ee11-servlet": [
+      "org.eclipse.jetty.ee11.servlet",
+      "org.eclipse.jetty.ee11.servlet.internal",
+      "org.eclipse.jetty.ee11.servlet.jmx",
+      "org.eclipse.jetty.ee11.servlet.listener",
+      "org.eclipse.jetty.ee11.servlet.security",
+      "org.eclipse.jetty.ee11.servlet.security.authentication",
+      "org.eclipse.jetty.ee11.servlet.util"
     ],
     "org.eclipse.jetty.ee8:jetty-ee8-nested": [
       "org.eclipse.jetty.ee8.nested",
@@ -4333,7 +4369,7 @@
     ]
   },
   "repositories": {
-    "https://repo1.maven.org/maven2/": [
+    "https://gerrit-maven.storage.googleapis.com/": [
       "antlr:antlr",
       "aopalliance:aopalliance",
       "aopalliance:aopalliance:jar:sources",
@@ -4375,10 +4411,6 @@
       "com.google.flogger:flogger:jar:sources",
       "com.google.flogger:google-extensions",
       "com.google.flogger:google-extensions:jar:sources",
-      "com.google.gitiles:blame-cache",
-      "com.google.gitiles:blame-cache:jar:sources",
-      "com.google.gitiles:gitiles-servlet",
-      "com.google.gitiles:gitiles-servlet:jar:sources",
       "com.google.guava:failureaccess",
       "com.google.guava:failureaccess:jar:sources",
       "com.google.guava:guava",
@@ -4390,7 +4422,7 @@
       "com.google.inject.extensions:guice-assistedinject:jar:sources",
       "com.google.inject.extensions:guice-servlet",
       "com.google.inject.extensions:guice-servlet:jar:sources",
-      "com.google.inject:guice",
+      "com.google.inject:guice:jar:classes",
       "com.google.inject:guice:jar:sources",
       "com.google.j2objc:j2objc-annotations",
       "com.google.j2objc:j2objc-annotations:jar:sources",
@@ -4426,10 +4458,6 @@
       "com.ibm.icu:icu4j:jar:sources",
       "com.icegreen:greenmail",
       "com.icegreen:greenmail:jar:sources",
-      "com.jcraft:jsch",
-      "com.jcraft:jsch:jar:sources",
-      "com.jcraft:jzlib",
-      "com.jcraft:jzlib:jar:sources",
       "com.ryanharter.auto.value:auto-value-gson-extension",
       "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources",
       "com.ryanharter.auto.value:auto-value-gson-factory",
@@ -4496,6 +4524,10 @@
       "org.antlr:antlr:jar:sources",
       "org.antlr:stringtemplate",
       "org.antlr:stringtemplate:jar:sources",
+      "org.apache.ant:ant",
+      "org.apache.ant:ant-launcher",
+      "org.apache.ant:ant-launcher:jar:sources",
+      "org.apache.ant:ant:jar:sources",
       "org.apache.commons:commons-compress",
       "org.apache.commons:commons-compress:jar:sources",
       "org.apache.commons:commons-lang3",
@@ -4555,9 +4587,11 @@
       "org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources",
       "org.commonmark:commonmark-ext-gfm-tables",
       "org.commonmark:commonmark-ext-gfm-tables:jar:sources",
+      "org.commonmark:commonmark-ext-yaml-front-matter",
+      "org.commonmark:commonmark-ext-yaml-front-matter:jar:sources",
       "org.commonmark:commonmark:jar:sources",
-      "org.eclipse.jetty.ee10:jetty-ee10-servlet",
-      "org.eclipse.jetty.ee10:jetty-ee10-servlet:jar:sources",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-nested",
       "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-security",
@@ -4633,7 +4667,7 @@
       "xerces:xercesImpl",
       "xerces:xercesImpl:jar:sources"
     ],
-    "https://gerrit-maven.storage.googleapis.com/": [
+    "https://repo1.maven.org/maven2/": [
       "antlr:antlr",
       "aopalliance:aopalliance",
       "aopalliance:aopalliance:jar:sources",
@@ -4675,10 +4709,6 @@
       "com.google.flogger:flogger:jar:sources",
       "com.google.flogger:google-extensions",
       "com.google.flogger:google-extensions:jar:sources",
-      "com.google.gitiles:blame-cache",
-      "com.google.gitiles:blame-cache:jar:sources",
-      "com.google.gitiles:gitiles-servlet",
-      "com.google.gitiles:gitiles-servlet:jar:sources",
       "com.google.guava:failureaccess",
       "com.google.guava:failureaccess:jar:sources",
       "com.google.guava:guava",
@@ -4690,7 +4720,7 @@
       "com.google.inject.extensions:guice-assistedinject:jar:sources",
       "com.google.inject.extensions:guice-servlet",
       "com.google.inject.extensions:guice-servlet:jar:sources",
-      "com.google.inject:guice",
+      "com.google.inject:guice:jar:classes",
       "com.google.inject:guice:jar:sources",
       "com.google.j2objc:j2objc-annotations",
       "com.google.j2objc:j2objc-annotations:jar:sources",
@@ -4726,10 +4756,6 @@
       "com.ibm.icu:icu4j:jar:sources",
       "com.icegreen:greenmail",
       "com.icegreen:greenmail:jar:sources",
-      "com.jcraft:jsch",
-      "com.jcraft:jsch:jar:sources",
-      "com.jcraft:jzlib",
-      "com.jcraft:jzlib:jar:sources",
       "com.ryanharter.auto.value:auto-value-gson-extension",
       "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources",
       "com.ryanharter.auto.value:auto-value-gson-factory",
@@ -4796,6 +4822,10 @@
       "org.antlr:antlr:jar:sources",
       "org.antlr:stringtemplate",
       "org.antlr:stringtemplate:jar:sources",
+      "org.apache.ant:ant",
+      "org.apache.ant:ant-launcher",
+      "org.apache.ant:ant-launcher:jar:sources",
+      "org.apache.ant:ant:jar:sources",
       "org.apache.commons:commons-compress",
       "org.apache.commons:commons-compress:jar:sources",
       "org.apache.commons:commons-lang3",
@@ -4855,9 +4885,11 @@
       "org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources",
       "org.commonmark:commonmark-ext-gfm-tables",
       "org.commonmark:commonmark-ext-gfm-tables:jar:sources",
+      "org.commonmark:commonmark-ext-yaml-front-matter",
+      "org.commonmark:commonmark-ext-yaml-front-matter:jar:sources",
       "org.commonmark:commonmark:jar:sources",
-      "org.eclipse.jetty.ee10:jetty-ee10-servlet",
-      "org.eclipse.jetty.ee10:jetty-ee10-servlet:jar:sources",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet",
+      "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-nested",
       "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources",
       "org.eclipse.jetty.ee8:jetty-ee8-security",
@@ -5204,6 +5236,7 @@
         "org.apache.lucene.codecs.lucene104.Lucene104PostingsFormat"
       ],
       "org.apache.lucene.index.SortFieldProvider": [
+        "org.apache.lucene.search.BinarySortField$Provider",
         "org.apache.lucene.search.SortField$Provider",
         "org.apache.lucene.search.SortedNumericSortField$Provider",
         "org.apache.lucene.search.SortedSetSortField$Provider"
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index a224e60..b28f94c 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -455,19 +455,6 @@
     // SystemReader must be overridden before creating any repos, since they read the user/system
     // configs at initialization time, and are then stored in the RepositoryCache forever.
 
-    if (enableExperimentsRejectImplicitMergesOnMerge()) {
-      // When changes are merged/submitted - reject the operation if there is an implicit merge (
-      // even if rejectImplicitMerges is disabled in the project config).
-      baseConfig.setStringList(
-          "experiments",
-          null,
-          "enabled",
-          ImmutableList.of(
-              "GerritBackendFeature__check_implicit_merges_on_merge",
-              "GerritBackendFeature__reject_implicit_merges_on_merge",
-              "GerritBackendFeature__always_reject_implicit_merges_on_merge"));
-    }
-
     server.initServer();
     server.getTestInjector().injectMembers(this);
 
@@ -499,12 +486,6 @@
     }
   }
 
-  protected boolean enableExperimentsRejectImplicitMergesOnMerge() {
-    // By default any attempt to make an explicit merge is rejected. This allows to check
-    // that existing workflows continue to work even if gerrit rejects implicit merges on merge.
-    return true;
-  }
-
   protected void setUpDatabase() throws Exception {
     admin = accountCreator.admin();
     user = accountCreator.user1();
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 7681734..cd66870 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -493,17 +493,28 @@
 
     StagedPreChange(String ref, @Nullable PushOptionGenerator pushOptionGenerator)
         throws Exception {
+      this(ref, pushOptionGenerator, null);
+    }
+
+    StagedPreChange(
+        String ref,
+        @Nullable PushOptionGenerator magicBranchOptionGenerator,
+        @Nullable PushOptionGenerator pushOptionGenerator)
+        throws Exception {
       super();
-      List<String> pushOptions = null;
-      if (pushOptionGenerator != null) {
-        pushOptions = pushOptionGenerator.pushOptions(this);
+      List<String> magicBranchOptions = null;
+      if (magicBranchOptionGenerator != null) {
+        magicBranchOptions = magicBranchOptionGenerator.pushOptions(this);
       }
-      if (pushOptions != null) {
-        ref = ref + '%' + Joiner.on(',').join(pushOptions);
+      if (magicBranchOptions != null) {
+        ref = ref + '%' + Joiner.on(',').join(magicBranchOptions);
       }
       requestScopeOperations.setApiUser(owner.id());
       repo = cloneProject(project, owner);
       PushOneCommit push = pushFactory.create(owner.newIdent(), repo);
+      if (pushOptionGenerator != null) {
+        push.setPushOptions(pushOptionGenerator.pushOptions(this));
+      }
       result = push.to(ref);
       result.assertOkStatus();
       changeId = result.getChangeId();
@@ -521,6 +532,12 @@
     return new StagedPreChange(ref, pushOptionGenerator);
   }
 
+  @CanIgnoreReturnValue
+  protected StagedPreChange stagePreChangeWithPushOptions(
+      String ref, PushOptionGenerator pushOptionGenerator) throws Exception {
+    return new StagedPreChange(ref, null, pushOptionGenerator);
+  }
+
   protected class StagedChange extends StagedPreChange {
     StagedChange(String ref) throws Exception {
       super(ref);
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index e4caa1a..f9698dc 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -30,7 +30,7 @@
   }
 
   @Override
-  public void onChangeDeleted(int id) {
+  public void onChangeDeleted(String projectName, int id) {
     countsByChange.incrementAndGet(id);
     deletionsByChange.incrementAndGet(id);
   }
diff --git a/java/com/google/gerrit/acceptance/GerritServerRestSession.java b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
index f244c2d..92409c3 100644
--- a/java/com/google/gerrit/acceptance/GerritServerRestSession.java
+++ b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
@@ -123,6 +123,17 @@
     return execute(post);
   }
 
+  @Override
+  public RestResponse postRaw(String endPoint, RawInput stream) throws IOException {
+    requireNonNull(stream);
+    Request post = Request.Post(getUrl(endPoint));
+    post.addHeader(new BasicHeader(CONTENT_TYPE, stream.getContentType()));
+    post.body(
+        new BufferedHttpEntity(
+            new InputStreamEntity(stream.getInputStream(), stream.getContentLength())));
+    return execute(post);
+  }
+
   private static void addContentToRequest(Request request, Object content) {
     request.addHeader(new BasicHeader(CONTENT_TYPE, "application/json"));
     request.body(new StringEntity(JSON_COMPACT.newGson().toJson(content), UTF_8));
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 3fefd5b..3240472 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -49,6 +49,8 @@
 
   RestResponse postWithHeaders(String endPoint, Object content, Header... headers) throws Exception;
 
+  RestResponse postRaw(String endPoint, RawInput stream) throws Exception;
+
   RestResponse delete(String endPoint) throws Exception;
 
   RestResponse deleteWithHeaders(String endPoint, Header... headers) throws Exception;
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index bac4ed6..21c816c 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -98,10 +98,10 @@
     InputStream in = process.getInputStream();
     InputStream err = process.getErrorStream();
 
-    Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+    Scanner s = new Scanner(err, UTF_8).useDelimiter("\\A");
     error = s.hasNext() ? s.next() : null;
 
-    s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
+    s = new Scanner(in, UTF_8).useDelimiter("\\A");
     return s.hasNext() ? s.next() : "";
   }
 
@@ -111,7 +111,7 @@
     Process process = getMinaSession().exec(command, 0);
     InputStream err = process.getErrorStream();
 
-    Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+    Scanner s = new Scanner(err, UTF_8).useDelimiter("\\A");
     error = s.hasNext() ? s.next() : null;
 
     try {
diff --git a/java/com/google/gerrit/acceptance/ssh/InterruptedCommand.java b/java/com/google/gerrit/acceptance/ssh/InterruptedCommand.java
new file mode 100644
index 0000000..bd27ee9
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/InterruptedCommand.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2026 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.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+
+/**
+ * Test command that reproduces the exception shape seen when an SSH client disconnects while the
+ * worker thread is blocked in a library call.
+ */
+@CommandMetaData(
+    name = "interrupted",
+    description = "Test command that wraps an interrupt in an unchecked exception",
+    runsAt = MASTER_OR_SLAVE)
+public class InterruptedCommand extends SshCommand {
+  /** Tripped once the command is running and ready to be interrupted. */
+  public static final CyclicBarrier syncPoint = new CyclicBarrier(2);
+
+  /** Counted down immediately before the wrapped exception is thrown. */
+  public static final CountDownLatch threwWrapped = new CountDownLatch(1);
+
+  @Override
+  protected void run() throws Exception {
+    syncPoint.await();
+    try {
+      Thread.sleep(Long.MAX_VALUE);
+    } catch (InterruptedException e) {
+      threwWrapped.countDown();
+      throw new RuntimeException("thread waiting for the response was interrupted", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
index f20851c..785fdf6 100644
--- a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
+++ b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
@@ -25,5 +25,6 @@
   protected void configure() {
     command("graceful").to(GracefulCommand.class);
     command("non-graceful").to(NonGracefulCommand.class);
+    command("interrupted").to(InterruptedCommand.class);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
index e510ba3..2c2a122 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
@@ -97,7 +97,7 @@
 
   public static String publicKey(KeyPair sshKey, @Nullable String comment)
       throws IOException, GeneralSecurityException {
-    return preparePublicKey(sshKey, comment).toString(US_ASCII.name()).trim();
+    return preparePublicKey(sshKey, comment).toString(US_ASCII).trim();
   }
 
   public static byte[] publicKeyBlob(KeyPair sshKey) throws IOException, GeneralSecurityException {
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index ce9b44d..28bfd7a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -309,7 +309,7 @@
         setConfig(projectConfig);
         try {
           projectCache.evictAndReindex(nameKey);
-        } catch (Exception e) {
+        } catch (RuntimeException e) {
           // Evicting the project from the cache, also triggers a reindex of the project.
           // The reindex step fails if the project config is invalid. That's fine, since it was our
           // intention to make the project config invalid. Hence we ignore exceptions that are cause
@@ -327,7 +327,7 @@
         setConfig(projectConfig);
         try {
           projectCache.evictAndReindex(nameKey);
-        } catch (Exception e) {
+        } catch (RuntimeException e) {
           // Evicting the project from the cache, also triggers a reindex of the project.
           // The reindex step fails if the project config is invalid. That's fine, since it was our
           // intention to make the project config invalid. Hence we ignore exceptions that are cause
diff --git a/java/com/google/gerrit/auth/AuthModule.java b/java/com/google/gerrit/auth/AuthModule.java
index 1eabe3f..03af0ef 100644
--- a/java/com/google/gerrit/auth/AuthModule.java
+++ b/java/com/google/gerrit/auth/AuthModule.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.auth;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.ldap.LdapModule;
 import com.google.gerrit.auth.oauth.OAuthRealm;
+import com.google.gerrit.auth.oauth.OAuthTokenAesGcmEncrypter;
 import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.auth.openid.OpenIdRealm;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.account.DefaultRealm;
 import com.google.gerrit.server.account.Realm;
@@ -26,17 +30,24 @@
 import com.google.gerrit.server.auth.InternalAuthBackend;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.AbstractModule;
+import com.google.inject.ProvisionException;
+import java.util.Base64;
 
 public class AuthModule extends AbstractModule {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final AuthType loginType;
+  private final String oauthTokenEncryptionKey;
 
   public AuthModule(AuthConfig authConfig) {
     loginType = authConfig.getAuthType();
+    oauthTokenEncryptionKey = authConfig.getOAuthTokenEncryptionKey();
   }
 
   @Override
   protected void configure() {
     install(OAuthTokenCache.module());
+    bindOAuthTokenEncrypter();
 
     switch (loginType) {
       case HTTP_LDAP:
@@ -67,4 +78,33 @@
         break;
     }
   }
+
+  /**
+   * Binds {@link OAuthTokenEncrypter} to encrypt stored OAuth tokens when {@code
+   * auth.tokenEncryptionKey} holds a base64 AES key. Absent (and OAUTH auth), tokens are stored in
+   * cleartext and a warning is logged.
+   */
+  private void bindOAuthTokenEncrypter() {
+    if (oauthTokenEncryptionKey == null || oauthTokenEncryptionKey.isBlank()) {
+      if (loginType == AuthType.OAUTH) {
+        logger.atWarning().log(
+            "auth.tokenEncryptionKey is not set; OAuth tokens are persisted in cleartext in the"
+                + " oauth_tokens cache. Set a base64 AES key to encrypt the stored tokens.");
+      }
+      return;
+    }
+    byte[] keyBytes;
+    try {
+      keyBytes = Base64.getDecoder().decode(oauthTokenEncryptionKey.trim());
+    } catch (IllegalArgumentException e) {
+      throw new ProvisionException("auth.tokenEncryptionKey is not valid base64", e);
+    }
+    if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) {
+      throw new ProvisionException(
+          "auth.tokenEncryptionKey must be a base64-encoded 128/192/256-bit AES key (16, 24 or 32"
+              + " bytes)");
+    }
+    DynamicItem.bind(binder(), OAuthTokenEncrypter.class)
+        .toInstance(new OAuthTokenAesGcmEncrypter(keyBytes));
+  }
 }
diff --git a/java/com/google/gerrit/auth/BUILD b/java/com/google/gerrit/auth/BUILD
index 19977db..9a88676 100644
--- a/java/com/google/gerrit/auth/BUILD
+++ b/java/com/google/gerrit/auth/BUILD
@@ -22,6 +22,8 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/util/crypto",
         "//java/com/google/gerrit/util/ssl",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenAesGcmEncrypter.java b/java/com/google/gerrit/auth/oauth/OAuthTokenAesGcmEncrypter.java
new file mode 100644
index 0000000..19a050d
--- /dev/null
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenAesGcmEncrypter.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.util.crypto.AesGcmCipher;
+import java.nio.ByteBuffer;
+
+/**
+ * Encrypts the sensitive fields ({@code token}, {@code secret}, {@code raw}) of an {@link
+ * OAuthToken} before they are persisted in the {@code oauth_tokens} cache, and decrypts them on
+ * read. {@code expiresAt} and {@code providerId} are left in cleartext but authenticated as
+ * additional data (AAD), together with the field name, to detect metadata tampering and encrypted
+ * field swaps.
+ *
+ * <p>The AES-GCM/HKDF mechanics live in {@link AesGcmCipher}; this class supplies only the
+ * OAuth-specific policy: which fields are encrypted, how {@code expiresAt}/{@code providerId} are
+ * bound as AAD, and the legacy cleartext passthrough for entries written before a key was set.
+ *
+ * <p>Bound (see {@code AuthModule}) when {@code auth.tokenEncryptionKey} is set. A value without
+ * the {@code gcm:v1:} prefix is returned unchanged, so a cleartext entry written before the key was
+ * set still reads back.
+ */
+public final class OAuthTokenAesGcmEncrypter implements OAuthTokenEncrypter {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final byte[] HKDF_INFO = "gerrit-oauth-token-encryption-v1".getBytes(UTF_8);
+  private static final byte[] AAD_CONTEXT = "gerrit-oauth-token-encryption-aad-v1".getBytes(UTF_8);
+
+  private final AesGcmCipher cipher;
+
+  /**
+   * @param masterKeyBytes raw key material (at least 128 bits); zeroed after key derivation
+   */
+  public OAuthTokenAesGcmEncrypter(byte[] masterKeyBytes) {
+    this.cipher = new AesGcmCipher(masterKeyBytes, HKDF_INFO);
+  }
+
+  @Override
+  @Nullable
+  public OAuthToken encrypt(OAuthToken t) {
+    if (t == null) {
+      return null;
+    }
+    OAuthToken out =
+        new OAuthToken(
+            enc("token", t.getToken(), t.getExpiresAt(), t.getProviderId()),
+            enc("secret", t.getSecret(), t.getExpiresAt(), t.getProviderId()),
+            enc("raw", t.getRaw(), t.getExpiresAt(), t.getProviderId()),
+            t.getExpiresAt(),
+            t.getProviderId());
+    logger.atFine().log(
+        "AES-GCM (HKDF+AAD) encrypt provider=%s expiresAt=%d: token %dB->%dB",
+        t.getProviderId(), t.getExpiresAt(), len(t.getToken()), len(out.getToken()));
+    return out;
+  }
+
+  @Override
+  @Nullable
+  public OAuthToken decrypt(OAuthToken t) {
+    if (t == null) {
+      return null;
+    }
+    OAuthToken out =
+        new OAuthToken(
+            dec("token", t.getToken(), t.getExpiresAt(), t.getProviderId()),
+            dec("secret", t.getSecret(), t.getExpiresAt(), t.getProviderId()),
+            dec("raw", t.getRaw(), t.getExpiresAt(), t.getProviderId()),
+            t.getExpiresAt(),
+            t.getProviderId());
+    logger.atFine().log(
+        "AES-GCM (HKDF+AAD) decrypt provider=%s expiresAt=%d: stored=%s -> token %dB",
+        t.getProviderId(),
+        t.getExpiresAt(),
+        cipher.isSealed(t.getToken()) ? "gcm:v1" : "cleartext",
+        len(out.getToken()));
+    return out;
+  }
+
+  private String enc(String fieldName, String plain, long expiresAt, String providerId) {
+    if (plain == null || plain.isEmpty()) {
+      return plain;
+    }
+    return cipher.seal(aad(fieldName, expiresAt, providerId), plain);
+  }
+
+  private String dec(String fieldName, String stored, long expiresAt, String providerId) {
+    if (!cipher.isSealed(stored)) {
+      return stored; // Cleartext entry written before the key was set.
+    }
+    return cipher.open(aad(fieldName, expiresAt, providerId), stored);
+  }
+
+  private static byte[] aad(String fieldName, long expiresAt, String providerId) {
+    byte[] fieldNameBytes = fieldName.getBytes(UTF_8);
+    byte[] providerIdBytes = providerId == null ? new byte[0] : providerId.getBytes(UTF_8);
+    int totalSize =
+        AAD_CONTEXT.length
+            + Integer.BYTES
+            + fieldNameBytes.length
+            + Long.BYTES
+            + 1
+            + Integer.BYTES
+            + providerIdBytes.length;
+    return ByteBuffer.allocate(totalSize)
+        .put(AAD_CONTEXT)
+        .putInt(fieldNameBytes.length)
+        .put(fieldNameBytes)
+        .putLong(expiresAt)
+        .put((byte) (providerId == null ? 0 : 1))
+        .putInt(providerIdBytes.length)
+        .put(providerIdBytes)
+        .array();
+  }
+
+  private static int len(String s) {
+    return s == null ? 0 : s.length();
+  }
+}
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index ab53cde..03446c1 100644
--- a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Converter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -30,14 +31,18 @@
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.IntegerCacheSerializer;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class OAuthTokenCache {
   public static final String OAUTH_TOKENS = "oauth_tokens";
+  private static final long DEFAULT_MEMORY_LIMIT = 1024;
 
   private final DynamicItem<OAuthTokenEncrypter> encrypter;
 
@@ -101,37 +106,73 @@
   }
 
   private final Cache<Account.Id, OAuthToken> cache;
+  private final boolean disabled;
 
   @Inject
   OAuthTokenCache(
       @Named(OAUTH_TOKENS) Cache<Account.Id, OAuthToken> cache,
-      DynamicItem<OAuthTokenEncrypter> encrypter) {
+      DynamicItem<OAuthTokenEncrypter> encrypter,
+      @GerritServerConfig Config cfg) {
     this.cache = cache;
     this.encrypter = encrypter;
+    this.disabled = cfg.getLong("cache", OAUTH_TOKENS, "memoryLimit", DEFAULT_MEMORY_LIMIT) == 0;
   }
 
+  /**
+   * Returns the decrypted token even if it has expired, without evicting it, so an expired token's
+   * {@code raw} (which may carry a refresh token) stays reachable for the refresh-on-read path.
+   */
   @Nullable
-  public OAuthToken get(Account.Id id) {
+  public OAuthToken getEvenIfExpired(Account.Id id) {
+    if (disabled) {
+      return null;
+    }
     OAuthToken accessToken = cache.getIfPresent(id);
     if (accessToken == null) {
       return null;
     }
-    accessToken = decrypt(accessToken);
-    if (accessToken.isExpired()) {
-      cache.invalidate(id);
-      return null;
-    }
-    return accessToken;
+    return decrypt(accessToken);
+  }
+
+  /**
+   * True if a token is cached for the account and expired (checks cleartext {@code expiresAt}, no
+   * decrypt).
+   */
+  public boolean hasExpiredToken(Account.Id id) {
+    OAuthToken accessToken = cache.getIfPresent(id);
+    return accessToken != null && accessToken.isExpired();
   }
 
   public void put(Account.Id id, OAuthToken accessToken) {
-    cache.put(id, encrypt(requireNonNull(accessToken)));
+    requireNonNull(accessToken);
+    if (disabled) {
+      return;
+    }
+    cache.put(id, encrypt(accessToken));
   }
 
   public void remove(Account.Id id) {
     cache.invalidate(id);
   }
 
+  public boolean isDisabled() {
+    return disabled;
+  }
+
+  /** Purges every cached OAuth token (e.g. after a suspected site compromise or key theft). */
+  public void removeAll() {
+    cache.invalidateAll();
+  }
+
+  /**
+   * Account ids with a token in the in-memory cache; used by bulk revoke. Disk-only entries are not
+   * listed. {@link #removeAll()} still purges them, but they cannot be individually revoked
+   * upstream (their token is not loaded).
+   */
+  public Set<Account.Id> accountsWithCachedToken() {
+    return ImmutableSet.copyOf(cache.asMap().keySet());
+  }
+
   private OAuthToken encrypt(OAuthToken token) {
     OAuthTokenEncrypter enc = encrypter.get();
     if (enc == null) {
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenRefresher.java b/java/com/google/gerrit/auth/oauth/OAuthTokenRefresher.java
new file mode 100644
index 0000000..cf5baa8
--- /dev/null
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenRefresher.java
@@ -0,0 +1,194 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.Striped;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.auth.oauth.OAuthRevokedException;
+import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.Lock;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Renews an expired OAuth access token from its refresh token, on read.
+ *
+ * <p>{@link #refreshIfExpired} renews an expired token in place (RFC 6749 &sect;6); a revoked grant
+ * surfaces as {@link OAuthRevokedException} after the cached token is dropped, other failures are
+ * swallowed. A still-valid or absent token is detected from the cleartext {@code expiresAt} without
+ * decrypting.
+ *
+ * <p>A sys-level singleton: {@link DynamicMap}{@code <OAuthServiceProvider>} is declared in {@code
+ * GerritGlobalModule} (sys), so the web read path ({@code GetOAuthToken}) and the ssh {@code
+ * oauth-token} commands share one refresher. Renewal is serialized per account with a non-blocking
+ * {@link Lock#tryLock()}. Transient failures use in-memory, per-account exponential backoff up to
+ * {@code auth.oauthTokenRefreshInterval}.
+ */
+@Singleton
+public class OAuthTokenRefresher {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final long INITIAL_BACKOFF_MILLIS = TimeUnit.SECONDS.toMillis(1);
+
+  private final OAuthTokenCache tokenCache;
+  private final DynamicMap<OAuthServiceProvider> providers;
+  private final DynamicItem<OAuthTokenEncrypter> encrypter;
+  // 16 stripes bound the lock count instead of one lock per account; an account id hashes to a
+  // stripe, so two accounts may share one. Acquired with tryLock(): a busy stripe means skip this
+  // refresh (single-flight), never block.
+  private final Striped<Lock> refreshLocks = Striped.lock(16);
+  private final AtomicBoolean warnedNoEncrypter = new AtomicBoolean();
+  private final long maxBackoffMillis;
+  private final Cache<Account.Id, Backoff> failedRefreshes;
+
+  @Inject
+  public OAuthTokenRefresher(
+      OAuthTokenCache tokenCache,
+      DynamicMap<OAuthServiceProvider> providers,
+      DynamicItem<OAuthTokenEncrypter> encrypter,
+      @GerritServerConfig Config config) {
+    this.tokenCache = tokenCache;
+    this.providers = providers;
+    this.encrypter = encrypter;
+    maxBackoffMillis = getRefreshIntervalMillis(config);
+    long failureStateTtlMillis = Math.max(maxBackoffMillis, INITIAL_BACKOFF_MILLIS);
+    this.failedRefreshes =
+        CacheBuilder.newBuilder()
+            .expireAfterWrite(failureStateTtlMillis, TimeUnit.MILLISECONDS)
+            .build();
+  }
+
+  /**
+   * Renews the account's cached token in place if it has expired and its provider supports refresh;
+   * otherwise does nothing.
+   *
+   * @throws OAuthRevokedException if the refresh token was rejected ({@code invalid_grant}); the
+   *     cached token has been removed.
+   */
+  public void refreshIfExpired(Account.Id accountId) throws OAuthRevokedException {
+    if (!tokenCache.hasExpiredToken(accountId)) {
+      return;
+    }
+    if (isBackedOff(accountId)) {
+      logger.atFine().log("backing off after a failed refresh for account %s", accountId);
+      return;
+    }
+    Lock lock = refreshLocks.get(accountId);
+    if (!lock.tryLock()) {
+      return; // another thread is already refreshing this account
+    }
+    try {
+      // Decrypt once, under the lock: earlier gates use only cleartext metadata.
+      OAuthToken token = tokenCache.getEvenIfExpired(accountId);
+      if (token == null || !token.isExpired()) {
+        return; // refreshed or evicted while waiting
+      }
+      OAuthServiceProvider provider = resolveProvider(token);
+      if (provider == null || !provider.supportsRefresh()) {
+        logger.atFine().log(
+            "expired token for account %s cannot be refreshed (provider '%s')",
+            accountId, token.getProviderId());
+        return;
+      }
+      warnIfStoringRefreshTokenUnencrypted();
+      long oldExpiresAt = token.getExpiresAt();
+      OAuthToken refreshed = provider.refresh(token);
+      failedRefreshes.invalidate(accountId);
+      tokenCache.put(accountId, refreshed);
+      logger.atInfo().log(
+          "Refreshed OAuth access token for account %s (provider %s): expiresAt %d -> %d",
+          accountId, token.getProviderId(), oldExpiresAt, refreshed.getExpiresAt());
+    } catch (OAuthRevokedException e) {
+      tokenCache.remove(accountId);
+      logger.atInfo().log(
+          "OAuth grant revoked for account %s (invalid_grant); dropped token", accountId);
+      throw e;
+    } catch (IOException e) {
+      recordFailure(accountId);
+      logger.atWarning().withCause(e).log(
+          "OAuth access-token refresh failed for account %s", accountId);
+    } catch (RuntimeException e) {
+      recordFailure(accountId);
+      logger.atWarning().withCause(e).log(
+          "Unexpected error refreshing OAuth access token for account %s", accountId);
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  private boolean isBackedOff(Account.Id accountId) {
+    Backoff backoff = failedRefreshes.getIfPresent(accountId);
+    return backoff != null && TimeUtil.nowMs() < backoff.retryAtMillis();
+  }
+
+  private void recordFailure(Account.Id accountId) {
+    Backoff previous = failedRefreshes.getIfPresent(accountId);
+    long delayMillis =
+        previous == null
+            ? Math.min(INITIAL_BACKOFF_MILLIS, maxBackoffMillis)
+            : Math.min(previous.delayMillis() * 2, maxBackoffMillis);
+    failedRefreshes.put(accountId, new Backoff(delayMillis, TimeUtil.nowMs() + delayMillis));
+  }
+
+  static long getRefreshIntervalMillis(Config config) {
+    String value = config.getString("auth", null, "oauthTokenRefreshInterval");
+    if (value != null && value.trim().matches("[0-9]+")) {
+      value = value + " minutes";
+    }
+    return ConfigUtil.getTimeUnit(value, TimeUnit.MINUTES.toMillis(5), TimeUnit.MILLISECONDS);
+  }
+
+  private void warnIfStoringRefreshTokenUnencrypted() {
+    if (encrypter.get() == null && warnedNoEncrypter.compareAndSet(false, true)) {
+      logger.atWarning().log(
+          "OAuth token refresh is persisting long-lived refresh tokens in the oauth_tokens cache in"
+              + " cleartext: no OAuthTokenEncrypter is bound. Restrict filesystem access to the"
+              + " site's cache directory, or do not enable token refresh.");
+    }
+  }
+
+  @Nullable
+  private OAuthServiceProvider resolveProvider(OAuthToken token) {
+    String providerId = token.getProviderId();
+    if (providerId == null) {
+      return null;
+    }
+    int colon = providerId.indexOf(':');
+    if (colon <= 0 || colon == providerId.length() - 1) {
+      return null;
+    }
+    try {
+      return providers.get(providerId.substring(0, colon), providerId.substring(colon + 1));
+    } catch (RuntimeException e) {
+      return null;
+    }
+  }
+
+  private record Backoff(long delayMillis, long retryAtMillis) {}
+}
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenRevoker.java b/java/com/google/gerrit/auth/oauth/OAuthTokenRevoker.java
new file mode 100644
index 0000000..a614237
--- /dev/null
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenRevoker.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import com.google.common.base.Splitter;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.auth.oauth.OAuthTokenRevokedListener;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Revokes an account's OAuth token at the IdP (RFC 7009), evicts it from the {@code oauth_tokens}
+ * cache, and fires {@link OAuthTokenRevokedListener}. Unlike a plain evict (local purge only),
+ * revocation invalidates the token upstream. Always evicts and fires, even when the provider cannot
+ * revoke upstream.
+ */
+@Singleton
+public class OAuthTokenRevoker {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final Splitter PROVIDER_ID_SPLITTER = Splitter.on(':').limit(2).omitEmptyStrings();
+
+  /** Outcome of revoking one account's token. */
+  public enum Result {
+    /** Revoked at the IdP and evicted locally. */
+    REVOKED,
+    /** Evicted locally only: the provider does not support revoke, or the IdP call failed. */
+    EVICTED_ONLY,
+    /** No cached token for the account; nothing to do. */
+    NO_TOKEN,
+  }
+
+  private final OAuthTokenCache tokenCache;
+  private final DynamicMap<OAuthServiceProvider> providers;
+  private final PluginSetContext<OAuthTokenRevokedListener> revokedListeners;
+
+  @Inject
+  public OAuthTokenRevoker(
+      OAuthTokenCache tokenCache,
+      DynamicMap<OAuthServiceProvider> providers,
+      PluginSetContext<OAuthTokenRevokedListener> revokedListeners) {
+    this.tokenCache = tokenCache;
+    this.providers = providers;
+    this.revokedListeners = revokedListeners;
+  }
+
+  /**
+   * Revokes the account's token at the IdP (if its provider supports revocation), evicts it
+   * locally, and fires {@link OAuthTokenRevokedListener}.
+   *
+   * @return {@link Result#NO_TOKEN} if nothing was cached; otherwise {@link Result#REVOKED} or
+   *     {@link Result#EVICTED_ONLY}.
+   */
+  public Result revoke(Account.Id accountId) {
+    return revoke(accountId, /* notifySingle= */ true);
+  }
+
+  private Result revoke(Account.Id accountId, boolean notifySingle) {
+    OAuthToken token;
+    try {
+      token = tokenCache.getEvenIfExpired(accountId);
+    } catch (RuntimeException e) {
+      // Entry present but undecryptable (wrong or rotated auth.tokenEncryptionKey, or a corrupt or
+      // tampered value): the token cannot be loaded to revoke upstream, but a compromise response
+      // must still purge it locally and fire the listener.
+      logger.atWarning().withCause(e).log(
+          "could not decrypt cached OAuth token for account %s; evicting locally anyway",
+          accountId);
+      return evictAndNotify(accountId, notifySingle, Result.EVICTED_ONLY);
+    }
+    if (token == null) {
+      return Result.NO_TOKEN;
+    }
+    Result result = Result.EVICTED_ONLY;
+    OAuthServiceProvider provider = resolveProvider(token);
+    if (provider != null) {
+      // supportsRevoke() and revoke() are both provider-supplied; a throw from either must not stop
+      // the local eviction below.
+      try {
+        if (provider.supportsRevoke()) {
+          provider.revoke(token);
+          result = Result.REVOKED;
+          logger.atInfo().log(
+              "Revoked OAuth token at the IdP for account %s (provider %s)",
+              accountId, token.getProviderId());
+        } else {
+          logger.atFine().log(
+              "provider for account %s (%s) does not support revoke; evicting locally only",
+              accountId, token.getProviderId());
+        }
+      } catch (IOException | RuntimeException e) {
+        logger.atWarning().withCause(e).log(
+            "IdP revocation failed for account %s (provider %s); evicting locally anyway",
+            accountId, token.getProviderId());
+      }
+    } else {
+      logger.atFine().log(
+          "no provider resolved for account %s (%s); evicting locally only",
+          accountId, token.getProviderId());
+    }
+    return evictAndNotify(accountId, notifySingle, result);
+  }
+
+  private Result evictAndNotify(Account.Id accountId, boolean notifySingle, Result result) {
+    tokenCache.remove(accountId);
+    if (notifySingle) {
+      revokedListeners.runEach(l -> l.onTokenRevoked(accountId));
+    }
+    return result;
+  }
+
+  /**
+   * Revokes and evicts every cached token (compromise-wide response), then purges the persistent
+   * store to catch entries that were only on disk and thus not individually revocable.
+   *
+   * @return the number of accounts whose cached token was processed individually
+   */
+  public int revokeAll() {
+    // Notify before and after the loop: before drops current entries, after catches any created
+    // while the (possibly long) IdP revoke loop ran.
+    revokedListeners.runEach(OAuthTokenRevokedListener::onAllTokensRevoked);
+    int processed = 0;
+    for (Account.Id id : tokenCache.accountsWithCachedToken()) {
+      var unused = revoke(id, /* notifySingle= */ false);
+      processed++;
+    }
+    // Disk-only entries are not enumerated above; purge them locally (they cannot be revoked
+    // upstream because their token is not loaded).
+    tokenCache.removeAll();
+    revokedListeners.runEach(OAuthTokenRevokedListener::onAllTokensRevoked);
+    logger.atInfo().log("Revoked and evicted %d cached OAuth token(s)", processed);
+    return processed;
+  }
+
+  @Nullable
+  private OAuthServiceProvider resolveProvider(OAuthToken token) {
+    String providerId = token.getProviderId();
+    if (providerId == null) {
+      return null;
+    }
+    List<String> parts = PROVIDER_ID_SPLITTER.splitToList(providerId);
+    if (parts.size() < 2) {
+      return null;
+    }
+    try {
+      return providers.get(parts.get(0), parts.get(1));
+    } catch (RuntimeException e) {
+      return null;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/common/CharsetUtil.java b/java/com/google/gerrit/common/CharsetUtil.java
new file mode 100644
index 0000000..cc6beba
--- /dev/null
+++ b/java/com/google/gerrit/common/CharsetUtil.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.IllegalCharsetNameException;
+import java.nio.charset.UnsupportedCharsetException;
+
+/** Helpers for resolving a {@link Charset} from a possibly-null, possibly-invalid charset name. */
+public final class CharsetUtil {
+  /**
+   * Resolves a charset by name, falling back to UTF-8 when {@code name} is null.
+   *
+   * <p>An invalid or unsupported name is reported as a checked {@link IOException} so that callers
+   * decoding untrusted input (e.g. a request charset) do not leak an unchecked charset exception.
+   *
+   * @throws IOException if {@code name} is not a valid or supported charset
+   */
+  public static Charset forNameOrUtf8(@Nullable String name) throws IOException {
+    if (name == null) {
+      return UTF_8;
+    }
+    try {
+      return Charset.forName(name);
+    } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
+      throw new IOException("Unsupported charset: " + name, e);
+    }
+  }
+
+  private CharsetUtil() {}
+}
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 288eb2c..3a331c3 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -230,6 +230,7 @@
   public String parentUuid;
   public Range range;
   public String tag;
+  public Boolean isAi;
 
   @Nullable public List<FixSuggestion> fixSuggestions;
 
@@ -258,6 +259,7 @@
         c.realAuthor == null ? null : c.realAuthor.getId());
     this.lineNbr = c.lineNbr;
     this.range = c.range != null ? new Range(c.range) : null;
+    this.isAi = c.isAi;
   }
 
   public Comment(
@@ -379,7 +381,8 @@
         && Objects.equals(tag, c.tag)
         && Objects.equals(revId, c.revId)
         && Objects.equals(serverId, c.serverId)
-        && Objects.equals(fixSuggestions, c.fixSuggestions);
+        && Objects.equals(fixSuggestions, c.fixSuggestions)
+        && Objects.equals(isAi, c.isAi);
   }
 
   @Override
@@ -397,7 +400,8 @@
         tag,
         revId,
         serverId,
-        fixSuggestions);
+        fixSuggestions,
+        isAi);
   }
 
   @Override
@@ -418,6 +422,7 @@
         .add("range", Objects.toString(range, ""))
         .add("revId", Objects.toString(revId, ""))
         .add("tag", Objects.toString(tag, ""))
+        .add("isAi", Objects.toString(isAi, ""))
         .add("fixSuggestions", Objects.toString(fixSuggestions, ""));
   }
 }
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index fa7b741..5807190 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -14,24 +14,33 @@
 
 package com.google.gerrit.entities;
 
-import java.util.ArrayList;
-import java.util.Collections;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
 import java.util.Comparator;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 
 public class LabelTypes {
-  protected List<LabelType> labelTypes;
-  private transient volatile Map<String, LabelType> byLabel;
-  private transient volatile Map<String, Integer> positions;
-
-  protected LabelTypes() {}
+  private final ImmutableList<LabelType> labelTypes;
+  private final ImmutableMap<String, LabelType> byLabel;
+  private final ImmutableMap<String, Integer> positions;
+  private final Comparator<String> nameComparator;
 
   public LabelTypes(List<? extends LabelType> approvals) {
-    labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
+    this.labelTypes = ImmutableList.copyOf(approvals);
+    this.byLabel = byLabel(labelTypes);
+    this.positions = positions(labelTypes);
+    this.nameComparator = nameComparator(positions);
+  }
+
+  public LabelTypes(Map<String, LabelType> byLabel) {
+    this.labelTypes = ImmutableList.copyOf(byLabel.values());
+    this.byLabel = ImmutableMap.copyOf(byLabel);
+    this.positions = positions(labelTypes);
+    this.nameComparator = nameComparator(positions);
   }
 
   public List<LabelType> getLabelTypes() {
@@ -39,28 +48,19 @@
   }
 
   public Optional<LabelType> byLabel(LabelId labelId) {
-    return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase(Locale.US)));
+    return byLabel(labelId.get());
   }
 
   public Optional<LabelType> byLabel(String labelName) {
-    return Optional.ofNullable(byLabel().get(labelName.toLowerCase(Locale.US)));
+    return Optional.ofNullable(byLabel.get(labelName.toLowerCase(Locale.US)));
   }
 
-  private Map<String, LabelType> byLabel() {
-    if (byLabel == null) {
-      synchronized (this) {
-        if (byLabel == null) {
-          Map<String, LabelType> l = new HashMap<>();
-          if (labelTypes != null) {
-            for (LabelType t : labelTypes) {
-              l.put(t.getName().toLowerCase(Locale.US), t);
-            }
-          }
-          byLabel = l;
-        }
-      }
+  private static ImmutableMap<String, LabelType> byLabel(ImmutableList<LabelType> labelTypes) {
+    Map<String, LabelType> l = Maps.newHashMapWithExpectedSize(labelTypes.size());
+    for (LabelType t : labelTypes) {
+      l.put(t.getName().toLowerCase(Locale.US), t);
     }
-    return byLabel;
+    return ImmutableMap.copyOf(l);
   }
 
   @Override
@@ -69,41 +69,44 @@
   }
 
   public Comparator<String> nameComparator() {
-    final Map<String, Integer> positions = positions();
-    return new Comparator<>() {
-      @Override
-      public int compare(String left, String right) {
-        int lp = position(left);
-        int rp = position(right);
-        int cmp = lp - rp;
-        if (cmp == 0) {
-          cmp = left.compareTo(right);
-        }
-        return cmp;
-      }
+    return nameComparator;
+  }
 
-      private int position(String name) {
-        Integer p = positions.get(name);
-        return p != null ? p : positions.size();
+  private static Comparator<String> nameComparator(ImmutableMap<String, Integer> positions) {
+    return (left, right) -> {
+      int lp = positions.getOrDefault(left, positions.size());
+      int rp = positions.getOrDefault(right, positions.size());
+      int cmp = lp - rp;
+      if (cmp == 0) {
+        cmp = left.compareTo(right);
       }
+      return cmp;
     };
   }
 
-  private Map<String, Integer> positions() {
-    if (positions == null) {
-      synchronized (this) {
-        if (positions == null) {
-          Map<String, Integer> p = new HashMap<>();
-          if (labelTypes != null) {
-            int i = 0;
-            for (LabelType t : labelTypes) {
-              p.put(t.getName(), i++);
-            }
-          }
-          positions = p;
-        }
-      }
+  private static ImmutableMap<String, Integer> positions(ImmutableList<LabelType> labelTypes) {
+    Map<String, Integer> p = Maps.newHashMapWithExpectedSize(labelTypes.size());
+    int i = 0;
+    for (LabelType t : labelTypes) {
+      p.put(t.getName(), i++);
     }
-    return positions;
+    return ImmutableMap.copyOf(p);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o instanceof LabelTypes) {
+      LabelTypes other = (LabelTypes) o;
+      return labelTypes.equals(other.labelTypes);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return labelTypes.hashCode();
   }
 }
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 8ea4561..fb69ece 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -154,7 +154,7 @@
 
     @Override
     public int compareTo(Id other) {
-      return Ints.compare(get(), other.get());
+      return Integer.compare(get(), other.get());
     }
   }
 
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 885edc6..c212f0c 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -60,6 +60,7 @@
   public static final String SUBMIT = "submit";
   public static final String SUBMIT_AS = "submitAs";
   public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
+  public static final String POST_REVIEW_COMMENT = "postReviewComment";
   public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
   public static final String AI_REVIEW = "aiReview";
 
@@ -99,6 +100,7 @@
     NAMES_LC.add(SUBMIT.toLowerCase(Locale.US));
     NAMES_LC.add(SUBMIT_AS.toLowerCase(Locale.US));
     NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase(Locale.US));
+    NAMES_LC.add(POST_REVIEW_COMMENT.toLowerCase(Locale.US));
     NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase(Locale.US));
     NAMES_LC.add(AI_REVIEW.toLowerCase(Locale.US));
 
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 45c3bc0..a1ccc18 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -98,6 +98,20 @@
     default int compareTo(NameKey o) {
       return name().compareTo(o.get());
     }
+
+    /**
+     * Indicates whether some other object is "equal to" this one. Subclasses must override this
+     * method to delegate to {@link #projectNameEquals}.
+     */
+    @Override
+    boolean equals(Object o);
+
+    /**
+     * Indicates whether some other object is "equal to" this one. Subclasses must override this
+     * method to delegate to {@link #projectNameHashCode}.
+     */
+    @Override
+    int hashCode();
   }
 
   public abstract NameKey getNameKey();
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index 8bf50f8..111b4fb 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -159,6 +159,9 @@
     ERROR,
 
     /** Submit requirement expression was not evaluated. */
-    NOT_EVALUATED
+    NOT_EVALUATED,
+
+    /** Submit requirement expression was timeout as maintained in gerrit.config. */
+    TIMEOUT
   }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index d9bb162..a0928cd 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -108,6 +108,11 @@
 
   @Memoized
   public Status status() {
+    if (isTimeout(submittabilityExpressionResult())
+        || isTimeout(applicabilityExpressionResult())
+        || isTimeout(overrideExpressionResult())) {
+      return Status.TIMEOUT;
+    }
     if (forced().orElse(false)) {
       return Status.FORCED;
     } else if (assertError(submittabilityExpressionResult())
@@ -125,6 +130,11 @@
     }
   }
 
+  private static boolean isTimeout(Optional<SubmitRequirementExpressionResult> r) {
+    return r.filter(v -> v.status() == SubmitRequirementExpressionResult.Status.TIMEOUT)
+        .isPresent();
+  }
+
   /** Returns true if the submit requirement is fulfilled and can allow change submission. */
   @Memoized
   public boolean fulfilled() {
@@ -177,7 +187,10 @@
      * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
      * %submit option.
      */
-    FORCED
+    FORCED,
+
+    /** The submit requirement is TIMEOUT during evaluation. */
+    TIMEOUT
   }
 
   @AutoValue.Builder
diff --git a/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java
index 7270af8..053c077 100644
--- a/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java
@@ -98,7 +98,9 @@
     if (changeInput.author != null) {
       builder.setAuthor(accountInputConverter.toProto(changeInput.author));
     }
-    builder.setNotify(Entities.NotifyHandling.forNumber(changeInput.notify.getValue()));
+    if (changeInput.notify != null) {
+      builder.setNotify(Entities.NotifyHandling.forNumber(changeInput.notify.getValue()));
+    }
 
     List<ListChangesOption> responseFormatOptions = changeInput.responseFormatOptions;
     if (responseFormatOptions != null) {
@@ -166,7 +168,9 @@
       }
     }
 
-    changeInput.notify = NotifyHandling.valueOf(proto.getNotify().name());
+    if (proto.hasNotify()) {
+      changeInput.notify = NotifyHandling.valueOf(proto.getNotify().name());
+    }
 
     if (proto.getNotifyDetailsCount() > 0) {
       changeInput.notifyDetails = new HashMap<>();
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
index 7cb78fa..c2d0d77 100644
--- a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -61,6 +61,9 @@
             .setUnresolved(val.unresolved)
             .setWrittenOnMillis(val.writtenOn.toInstant().toEpochMilli())
             .setServerId(val.serverId);
+    if (val.isAi != null) {
+      res.setIsAi(val.isAi);
+    }
     if (!val.key.filename.equals(PATCHSET_LEVEL)) {
       InFilePosition.Builder inFilePos =
           InFilePosition.newBuilder()
@@ -151,6 +154,9 @@
       // set it even if it's the same as the `author`.
       res.realAuthor = new Comment.Identity(accountIdConverter.fromProto(proto.getRealAuthor()));
     }
+    if (proto.hasIsAi()) {
+      res.isAi = proto.getIsAi();
+    }
 
     optInFilePosition.ifPresent(
         inFilePosition -> {
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index 5e82bdb..3c776bb 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -16,6 +16,7 @@
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.api.changes.ChangeApi.SuggestedReviewersRequest;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.ValidationOptionInfos;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -32,6 +33,12 @@
   /** Returns the content of a file from the HEAD revision. */
   BinaryResult file(String path) throws RestApiException;
 
+  /**
+   * Commits a set of file operations (write/create, delete, rename) to the branch as one commit and
+   * returns the new commit.
+   */
+  CommitInfo createCommit(CreateCommitInput input) throws RestApiException;
+
   List<ReflogEntryInfo> reflog() throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
index 18ba0c0..16e8228 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommitApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -31,4 +31,14 @@
 
   /** List files in a specific commit against the parent commit. */
   Map<String, FileInfo> files(int parentNum) throws RestApiException;
+
+  /**
+   * Lists files that differ between this commit and a base commit.
+   *
+   * @param base the base commit SHA1 (40 characters)
+   * @param nameOnly whether to return only the list of files without diff info
+   * @return map of file paths to FileInfo
+   * @throws RestApiException if commits are not in ancestor/descendant relationship or not visible
+   */
+  Map<String, FileInfo> diffFiles(String base, boolean nameOnly) throws RestApiException;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CreateCommitInput.java b/java/com/google/gerrit/extensions/api/projects/CreateCommitInput.java
new file mode 100644
index 0000000..91d2335
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CreateCommitInput.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2026 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 java.util.Map;
+
+/**
+ * Input for creating a commit that applies a set of file operations to a branch in a single call.
+ */
+public class CreateCommitInput {
+  /** Commit message. */
+  public String commitMessage;
+
+  /**
+   * Optional base commit (SHA-1).
+   *
+   * <p>This is the expected current commit of the target branch: the request is rejected if the
+   * branch no longer points at it (optimistic concurrency / lost-update protection). When unset,
+   * the current branch tip is used.
+   */
+  public String baseRevision;
+
+  /**
+   * File operations to apply, keyed by file path. Each entry either writes/creates content, deletes
+   * the file, or renames another file to this path. Applied together as one commit.
+   */
+  public Map<String, FileChange> files;
+
+  /**
+   * Validation options as key-value pairs that are forwarded as options to the ref-operation and
+   * commit validation listeners (e.g. to skip certain validations). Which options are supported
+   * depends on the installed validation listeners; Gerrit core supports none. Unknown options are
+   * silently ignored.
+   */
+  public Map<String, String> validationOptions;
+
+  /** A single file operation within a {@link CreateCommitInput}. */
+  public static class FileChange {
+    /**
+     * New file content, base64-encoded, for a write or create. For a {@code 120000} (symlink)
+     * entry, the decoded content is the symlink target path. Mutually exclusive with {@link
+     * #delete} and {@link #renameFrom}.
+     */
+    public String content;
+
+    /**
+     * File mode in octal format. Supported values are {@code 100644} (regular file), {@code 100755}
+     * (executable file) and {@code 120000} (symlink). If unset, new files are created as {@code
+     * 100644} and existing files keep their mode.
+     */
+    public Integer fileMode;
+
+    /**
+     * When {@code true}, deletes the file at this path. Mutually exclusive with the other fields.
+     */
+    public boolean delete;
+
+    /**
+     * Source path to rename from. The file at {@code renameFrom} is moved to this entry's path.
+     * Mutually exclusive with {@link #content} and {@link #delete}.
+     */
+    public String renameFrom;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index f96755a..a8407d1 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -22,6 +22,8 @@
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -66,6 +68,29 @@
   Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
       throws RestApiException;
 
+  /**
+   * Lists files that differ between two commits.
+   *
+   * @param oldCommit the old commit SHA1 (40 characters)
+   * @param newCommit the new commit SHA1 (40 characters)
+   * @param nameOnly whether to return only the list of files without diff info
+   * @return map of file paths to FileInfo
+   * @throws RestApiException if commits are not in ancestor/descendant relationship or not visible
+   */
+  Map<String, FileInfo> diffFiles(String oldCommit, String newCommit, boolean nameOnly)
+      throws RestApiException;
+
+  /**
+   * Gets the diff for a specific file between two commits.
+   *
+   * @param oldCommit the old commit SHA1 (40 characters)
+   * @param newCommit the new commit SHA1 (40 characters)
+   * @param path the file path
+   * @return DiffInfo for the file
+   * @throws RestApiException if commits are not in ancestor/descendant relationship or not visible
+   */
+  DiffInfo diffFile(String oldCommit, String newCommit, String path) throws RestApiException;
+
   ListRefsRequest<BranchInfo> branches();
 
   ListRefsRequest<TagInfo> tags();
diff --git a/java/com/google/gerrit/extensions/auth/oauth/OAuthAuthorizationInfo.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthAuthorizationInfo.java
new file mode 100644
index 0000000..6c7d14b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/auth/oauth/OAuthAuthorizationInfo.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import com.google.gerrit.common.Nullable;
+
+public class OAuthAuthorizationInfo {
+  private final String authorizationUrl;
+  private final String pkceVerifier;
+
+  public OAuthAuthorizationInfo(String url, @Nullable String verifier) {
+    this.authorizationUrl = url;
+    this.pkceVerifier = verifier;
+  }
+
+  public String getAuthorizationUrl() {
+    return authorizationUrl;
+  }
+
+  @Nullable
+  public String getPkceVerifier() {
+    return pkceVerifier;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/auth/oauth/OAuthRevokedException.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthRevokedException.java
new file mode 100644
index 0000000..be41e17
--- /dev/null
+++ b/java/com/google/gerrit/extensions/auth/oauth/OAuthRevokedException.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import java.io.IOException;
+
+/**
+ * Signals that the identity provider reported the grant as gone -- the OAuth 2.0 {@code
+ * invalid_grant} error on a {@link OAuthServiceProvider#refresh refresh} (consent withdrawn,
+ * account disabled, "sign out everywhere").
+ *
+ * <p>Extends {@link IOException} so it flows through the {@code refresh} signature, and so a caller
+ * can distinguish revocation (end the session) from a transient IdP/network failure (a plain {@link
+ * IOException}, handled by policy).
+ */
+public class OAuthRevokedException extends IOException {
+  private static final long serialVersionUID = 1L;
+
+  public OAuthRevokedException(String message) {
+    super(message);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
index c3d760b..9ed64fb 100644
--- a/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
+++ b/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.auth.oauth;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import java.io.IOException;
 
@@ -26,7 +27,19 @@
    *
    * @return the OAuth service URL to redirect your users for authentication
    */
-  String getAuthorizationUrl();
+  default String getAuthorizationUrl() {
+    return getAuthorizationInfo().getAuthorizationUrl();
+  }
+
+  /**
+   * Returns the URL where you should redirect your users to authenticate your application.
+   *
+   * @return the OAuth service URL to redirect your users for authentication and verifier string
+   *     generated during initial authorization phase
+   */
+  default OAuthAuthorizationInfo getAuthorizationInfo() {
+    return new OAuthAuthorizationInfo(getAuthorizationUrl(), null);
+  }
 
   /**
    * Retrieve the access token
@@ -34,17 +47,78 @@
    * @param verifier verifier code
    * @return access token
    */
-  OAuthToken getAccessToken(OAuthVerifier verifier);
+  default OAuthToken getAccessToken(OAuthVerifier verifier) {
+    return getAccessToken(verifier, null);
+  }
 
   /**
-   * After establishing of secure communication channel, this method supossed to access the
-   * protected resoure and retrieve the username.
+   * Retrieve the access token
+   *
+   * @param verifier verifier code
+   * @param codeVerifier verifier string generated during initial authorization phase
+   * @return access token
+   */
+  default OAuthToken getAccessToken(OAuthVerifier verifier, @Nullable String codeVerifier) {
+    return getAccessToken(verifier);
+  }
+
+  /**
+   * After establishing of secure communication channel, this method supposed to access the
+   * protected resource and retrieve the user name.
    *
    * @return OAuth user information
    */
   OAuthUserInfo getUserInfo(OAuthToken token) throws IOException;
 
   /**
+   * Whether this provider can refresh an expired access token (OAuth 2.0 refresh grant, RFC 6749
+   * section 6). Defaults to {@code false}; a provider that requests and accepts refresh tokens
+   * overrides this together with {@link #refresh}.
+   *
+   * @return whether {@link #refresh} is supported for this provider
+   */
+  default boolean supportsRefresh() {
+    return false;
+  }
+
+  /**
+   * Exchanges the refresh token carried in {@code expiredToken.getRaw()} for a fresh access token.
+   *
+   * @param expiredToken the previously issued token whose access token has expired
+   * @return a new token, with {@code expiresAt} populated
+   * @throws OAuthRevokedException when the IdP reports {@code invalid_grant} (the grant is gone;
+   *     revoke the session); an {@link IOException} subtype
+   * @throws IOException on a transient IdP/network failure (handle by policy, do not revoke)
+   */
+  default OAuthToken refresh(OAuthToken expiredToken) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  /**
+   * Whether this provider can revoke a token at the identity provider (OAuth 2.0 token revocation,
+   * RFC 7009). Defaults to {@code false}; a provider whose IdP exposes a revocation endpoint
+   * overrides this together with {@link #revoke}.
+   *
+   * @return whether {@link #revoke} is supported for this provider
+   */
+  default boolean supportsRevoke() {
+    return false;
+  }
+
+  /**
+   * Revokes {@code token} at the identity provider so it can no longer be used there (RFC 7009):
+   * POST the token to the provider's revocation endpoint (for Google, {@code
+   * https://oauth2.googleapis.com/revoke}). An already-invalid token is a no-op. Unlike {@link
+   * #refresh}, this does not throw {@link OAuthRevokedException}.
+   *
+   * @param token the token to revoke at the IdP
+   * @throws IOException on a transient IdP/network failure
+   */
+  default void revoke(OAuthToken token) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  /**
    * Returns the OAuth version of the service.
    *
    * @return oauth version as string
@@ -52,7 +126,7 @@
   String getVersion();
 
   /**
-   * Returns the name of this service. This name is resented the user to choose between multiple
+   * Returns the name of this service. This name is presented to the user to choose between multiple
    * service providers
    *
    * @return name of the service
diff --git a/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
index b2f4262..b9fa708 100644
--- a/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
+++ b/java/com/google/gerrit/extensions/auth/oauth/OAuthTokenEncrypter.java
@@ -16,6 +16,15 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 
+/**
+ * Encrypts and decrypts an {@link OAuthToken} for storage at rest.
+ *
+ * <p>Implementations must encrypt only the secret fields ({@code token}, {@code secret}, {@code
+ * raw}) and leave {@code expiresAt} and {@code providerId} in cleartext: they are non-secret
+ * metadata (an expiry timestamp and a provider routing id) that Gerrit reads <em>without</em>
+ * decrypting -- for instance to decide, on the refresh-on-read path, whether a cached token has
+ * expired.
+ */
 @ExtensionPoint
 public interface OAuthTokenEncrypter {
 
@@ -29,7 +38,7 @@
   /**
    * Decrypts the secret parts of the given OAuth access token.
    *
-   * @param encrypted an encryppted OAuth access token.
+   * @param encrypted an encrypted OAuth access token.
    */
   OAuthToken decrypt(OAuthToken encrypted);
 }
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 4bfa566..3f33713 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -54,6 +54,8 @@
 
   public List<FixSuggestionInfo> fixSuggestions;
 
+  public Boolean isAi;
+
   // TODO(issue-40014498): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
   @SuppressWarnings("JdkObsolete")
@@ -157,7 +159,8 @@
           && Objects.equals(updated, c.updated)
           && Objects.equals(message, c.message)
           && Objects.equals(commitId, c.commitId)
-          && Objects.equals(fixSuggestions, c.fixSuggestions);
+          && Objects.equals(fixSuggestions, c.fixSuggestions)
+          && Objects.equals(isAi, c.isAi);
     }
     return false;
   }
@@ -165,6 +168,17 @@
   @Override
   public int hashCode() {
     return Objects.hash(
-        patchSet, id, path, side, parent, line, range, inReplyTo, updated, message, fixSuggestions);
+        patchSet,
+        id,
+        path,
+        side,
+        parent,
+        line,
+        range,
+        inReplyTo,
+        updated,
+        message,
+        fixSuggestions,
+        isAi);
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 8de9826..c6f0dc3 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -42,6 +42,12 @@
     IGNORE_ALL
   }
 
+  public enum ResponsiveMode {
+    NONE,
+    SHRINK_ONLY,
+    FULL_RESPONSIVE
+  }
+
   public Integer context;
   public Integer tabSize;
   public Integer fontSize;
@@ -60,7 +66,8 @@
   public Boolean renderEntireFile;
   public Boolean hideEmptyPane;
   public Boolean matchBrackets;
-  public Boolean lineWrapping;
+  @Deprecated public Boolean lineWrapping;
+  public ResponsiveMode responsiveMode;
   public Whitespace ignoreWhitespace;
   public Boolean retainHeader;
   public Boolean skipDeleted;
@@ -93,6 +100,7 @@
         && equalBooleanPreferencesFields(this.hideEmptyPane, other.hideEmptyPane)
         && equalBooleanPreferencesFields(this.matchBrackets, other.matchBrackets)
         && equalBooleanPreferencesFields(this.lineWrapping, other.lineWrapping)
+        && Objects.equals(this.responsiveMode, other.responsiveMode)
         && Objects.equals(this.ignoreWhitespace, other.ignoreWhitespace)
         && equalBooleanPreferencesFields(this.retainHeader, other.retainHeader)
         && equalBooleanPreferencesFields(this.skipDeleted, other.skipDeleted)
@@ -122,6 +130,7 @@
         hideEmptyPane,
         matchBrackets,
         lineWrapping,
+        responsiveMode,
         ignoreWhitespace,
         retainHeader,
         skipDeleted,
@@ -151,6 +160,7 @@
         .add("hideEmptyPane", hideEmptyPane)
         .add("matchBrackets", matchBrackets)
         .add("lineWrapping", lineWrapping)
+        .add("responsiveMode", responsiveMode)
         .add("ignoreWhitespace", ignoreWhitespace)
         .add("retainHeader", retainHeader)
         .add("skipDeleted", skipDeleted)
@@ -180,6 +190,7 @@
     i.hideEmptyPane = false;
     i.matchBrackets = false;
     i.lineWrapping = false;
+    i.responsiveMode = ResponsiveMode.NONE;
     i.ignoreWhitespace = Whitespace.IGNORE_NONE;
     i.retainHeader = false;
     i.skipDeleted = false;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index b93f752..9f5202d 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -150,6 +150,7 @@
   public Boolean allowSuggestCodeWhileCommenting;
   public Boolean allowAutocompletingComments;
   public String aiChatSelectedModel;
+  public String labelFilter;
 
   /**
    * The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
@@ -232,7 +233,8 @@
         && equalBooleanPreferencesFields(
             this.allowAutocompletingComments, other.allowAutocompletingComments)
         && Objects.equals(this.diffPageSidebar, other.diffPageSidebar)
-        && Objects.equals(this.aiChatSelectedModel, other.aiChatSelectedModel);
+        && Objects.equals(this.aiChatSelectedModel, other.aiChatSelectedModel)
+        && Objects.equals(this.labelFilter, other.labelFilter);
   }
 
   @Override
@@ -263,7 +265,8 @@
         allowSuggestCodeWhileCommenting,
         allowAutocompletingComments,
         diffPageSidebar,
-        aiChatSelectedModel);
+        aiChatSelectedModel,
+        labelFilter);
   }
 
   @Override
@@ -295,6 +298,7 @@
         .add("allowAutocompletingComments", allowAutocompletingComments)
         .add("diffPageSidebar", diffPageSidebar)
         .add("aiChatSelectedModel", aiChatSelectedModel)
+        .add("labelFilter", labelFilter)
         .toString();
   }
 
@@ -324,6 +328,7 @@
     p.allowAutocompletingComments = true;
     p.diffPageSidebar = "NONE";
     p.aiChatSelectedModel = null;
+    p.labelFilter = null;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 2e2b9ca..07289a2 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -62,8 +62,12 @@
     this.subject = subject;
   }
 
-  /** Who to send email notifications to after change is created. */
-  public NotifyHandling notify = NotifyHandling.ALL;
+  /**
+   * Who to send email notifications to after change is created. If not specified, defaults to
+   * {@link NotifyHandling#OWNER} if the change is created as work-in-progress, or {@link
+   * NotifyHandling#ALL} otherwise.
+   */
+  public NotifyHandling notify;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
 }
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index 29a6d4f..db85ac8 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -29,6 +29,12 @@
   public long sizeDelta;
   public long size;
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff, i.e. for diffs
+   * taking a very long time to compute.
+   */
+  public Boolean diffsTooExpensiveToCompute;
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof FileInfo) {
@@ -43,14 +49,23 @@
           && Objects.equals(linesInserted, fileInfo.linesInserted)
           && Objects.equals(linesDeleted, fileInfo.linesDeleted)
           && sizeDelta == fileInfo.sizeDelta
-          && size == fileInfo.size;
+          && size == fileInfo.size
+          && Objects.equals(diffsTooExpensiveToCompute, fileInfo.diffsTooExpensiveToCompute);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(status, binary, oldPath, linesInserted, linesDeleted, sizeDelta, size);
+    return Objects.hash(
+        status,
+        binary,
+        oldPath,
+        linesInserted,
+        linesDeleted,
+        sizeDelta,
+        size,
+        diffsTooExpensiveToCompute);
   }
 
   @Override
@@ -78,6 +93,8 @@
         + sizeDelta
         + ", size="
         + size
+        + ", diffsTooExpensiveToCompute="
+        + diffsTooExpensiveToCompute
         + "}";
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index fd682c1..37a7138 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -26,4 +26,5 @@
   public String instanceId;
   public String defaultBranch;
   public Boolean projectStatePredicateEnabled;
+  public String submitCommitUrl;
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index 5ae8500..20ea4be 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -76,7 +76,10 @@
     ERROR,
 
     /** Expression was not evaluated. */
-    NOT_EVALUATED
+    NOT_EVALUATED,
+
+    /** Submit requirement expression was timeout as maintained in gerrit.config. */
+    TIMEOUT
   }
 
   @Override
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
index cf0d53c..07ab976 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
@@ -50,7 +50,10 @@
      * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
      * %submit option.
      */
-    FORCED
+    FORCED,
+
+    /** The submit requirement is TIMEOUT during evaluation. */
+    TIMEOUT
   }
 
   /** Submit requirement name. */
diff --git a/java/com/google/gerrit/extensions/conditions/BooleanCondition.java b/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
index b1c1e93..1bc3702 100644
--- a/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
+++ b/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
@@ -16,6 +16,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import java.util.Objects;
+import java.util.function.BooleanSupplier;
 
 /** Delayed evaluation of a boolean condition. */
 public abstract class BooleanCondition {
@@ -26,6 +28,14 @@
     return a ? TRUE : FALSE;
   }
 
+  /**
+   * Returns a condition whose value is computed lazily via {@code supplier} only when non-trivial
+   * evaluation is required.
+   */
+  public static BooleanCondition lazy(BooleanSupplier supplier) {
+    return new Lazy(Objects.requireNonNull(supplier));
+  }
+
   public static BooleanCondition and(BooleanCondition a, BooleanCondition b) {
     return a == FALSE || b == FALSE ? FALSE : new And(a, b);
   }
@@ -300,6 +310,49 @@
     }
   }
 
+  private static final class Lazy extends BooleanCondition {
+    private final BooleanSupplier supplier;
+
+    Lazy(BooleanSupplier supplier) {
+      this.supplier = supplier;
+    }
+
+    @Override
+    public boolean value() {
+      return supplier.getAsBoolean();
+    }
+
+    @Override
+    public <T> ImmutableList<T> children(Class<T> type) {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public BooleanCondition reduce() {
+      return this;
+    }
+
+    @Override
+    public int hashCode() {
+      return supplier.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      return other instanceof Lazy && supplier.equals(((Lazy) other).supplier);
+    }
+
+    @Override
+    public String toString() {
+      return "lazy(" + supplier + ")";
+    }
+
+    @Override
+    protected boolean evaluatesTrivially() {
+      return false;
+    }
+  }
+
   /**
    * Helper for use in toString methods. Remove leading '(' and trailing ')' if the type is the same
    * as the parent.
diff --git a/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java b/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
index 82d0a53..64b62a6 100644
--- a/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
+++ b/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
@@ -43,7 +43,7 @@
   void onChangeIndexed(String projectName, int id);
 
   /** Invoked when a change is deleted from the index. */
-  void onChangeDeleted(int id);
+  void onChangeDeleted(String projectName, int id);
 
   /**
    * Invoked when all change indexes are deleted for a given project
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 0c2691a..4ed614e 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -206,9 +206,16 @@
   /**
    * Returns {@code true} if this set contains the given item.
    *
+   * <p>NOTE: This method iterates sequentially through the internal list of items to find a match,
+   * rather than performing a direct lookup via hash-code. Consequently, its performance is O(N)
+   * based on the number of registered items, making it significantly slower than a standard {@link
+   * java.util.Set#contains(Object)}.
+   *
    * @param item item to check whether or not it is contained.
    * @return {@code true} if this set contains the given item.
+   * @deprecated Avoid because of O(N) execution time
    */
+  @Deprecated(forRemoval = true, since = "3.15.0")
   public boolean contains(T item) {
     Iterator<T> iterator = iterator();
     while (iterator.hasNext()) {
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index cc0c134..851ec96 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.extensions.restapi;
 
 import static com.google.common.base.Preconditions.checkState;
-import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.common.collect.ImmutableMultimap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
@@ -23,6 +22,10 @@
 
 /** Special return value to mean specific HTTP status codes in a REST API. */
 public abstract class Response<T> {
+  // Intentionally keep this constant local to avoid introducing an HTTP library
+  // dependency into the public gerrit-extension-api.
+  private static final int HTTP_INTERNAL_SERVER_ERROR = 500;
+
   @SuppressWarnings({"rawtypes"})
   private static final Response NONE = new None();
 
@@ -74,7 +77,7 @@
   /** Arbitrary status code with wrapped result. */
   public static <T> Response<T> withStatusCode(int statusCode, T value) {
     checkState(
-        statusCode < SC_INTERNAL_SERVER_ERROR,
+        statusCode < HTTP_INTERNAL_SERVER_ERROR,
         "Status code must be < 500. To return an internal server error REST endpoint"
             + " implementations should throw an exception");
     return new Impl<>(statusCode, value);
diff --git a/java/com/google/gerrit/extensions/restapi/Url.java b/java/com/google/gerrit/extensions/restapi/Url.java
index 09def84..bbb4be9 100644
--- a/java/com/google/gerrit/extensions/restapi/Url.java
+++ b/java/com/google/gerrit/extensions/restapi/Url.java
@@ -17,7 +17,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.Nullable;
-import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
 
@@ -44,11 +43,7 @@
   @Nullable
   public static String encode(String component) {
     if (component != null) {
-      try {
-        return URLEncoder.encode(component, UTF_8.name());
-      } catch (UnsupportedEncodingException e) {
-        throw new RuntimeException("JVM must support UTF-8", e);
-      }
+      return URLEncoder.encode(component, UTF_8);
     }
     return null;
   }
@@ -57,11 +52,7 @@
   @Nullable
   public static String decode(String str) {
     if (str != null) {
-      try {
-        return URLDecoder.decode(str, UTF_8.name());
-      } catch (UnsupportedEncodingException e) {
-        throw new RuntimeException("JVM must support UTF-8", e);
-      }
+      return URLDecoder.decode(str, UTF_8);
     }
     return null;
   }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index fb24f13..6718dac 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -14,6 +14,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/util/crypto",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-factory",
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
index 7040f2d..7a95311 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -60,7 +60,7 @@
   public boolean hasInitializedPublicKeyStore() {
     try {
       return storeProvider.get() != null;
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       return false;
     }
   }
diff --git a/java/com/google/gerrit/gpg/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
index 98487ca..45bb085 100644
--- a/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.util.crypto.SecureRandomUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,11 +36,8 @@
 import com.google.inject.Singleton;
 import com.google.inject.multibindings.OptionalBinder;
 import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Random;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PreReceiveHook;
@@ -82,7 +80,8 @@
       if (enableSignedPush) {
         String seed = cfg.getString("receive", null, "certNonceSeed");
         if (Strings.isNullOrEmpty(seed)) {
-          seed = randomString(64);
+          // Signed-push cert-nonce HMAC seed: 48 random bytes (384 bits) as base64url.
+          seed = SecureRandomUtil.newRandomString(48);
         }
         signedPushConfig = new SignedPushConfig();
         signedPushConfig.setCertNonceSeed(seed);
@@ -151,18 +150,4 @@
       };
     }
   }
-
-  private static String randomString(int len) {
-    Random random;
-    try {
-      random = SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException(e);
-    }
-    StringBuilder sb = new StringBuilder(len);
-    for (int i = 0; i < len; i++) {
-      sb.append((char) random.nextInt());
-    }
-    return sb.toString();
-  }
 }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 57fda5b..455016f 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -70,7 +70,7 @@
       throw new GpgException(e);
     } catch (RestApiException e) {
       throw e;
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw RestApiException.wrap("Cannot list GPG keys", e);
     }
   }
@@ -88,7 +88,7 @@
       throw new GpgException(e);
     } catch (RestApiException e) {
       throw e;
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw RestApiException.wrap("Cannot put GPG keys", e);
     }
   }
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index 0422655..c347034 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.StopPluginListener;
 import com.google.inject.Inject;
@@ -25,6 +26,10 @@
 import com.google.inject.servlet.ServletModule;
 import java.io.IOException;
 import java.util.Iterator;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+import java.util.stream.StreamSupport;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -34,6 +39,8 @@
 
 /** Filters all HTTP requests passing through the server. */
 public abstract class AllRequestFilter implements Filter {
+  public static final Function<Extension<AllRequestFilter>, Boolean> SELECT_ALL = _ -> true;
+
   public static Module module() {
     return new ServletModule() {
       @Override
@@ -56,13 +63,13 @@
   static class FilterProxy implements Filter, StopPluginListener {
     private final DynamicSet<AllRequestFilter> filters;
 
-    private DynamicSet<AllRequestFilter> initializedFilters;
+    private Set<AllRequestFilter> initializedFilters;
     private FilterConfig filterConfig;
 
     @Inject
     FilterProxy(DynamicSet<AllRequestFilter> filters) {
       this.filters = filters;
-      this.initializedFilters = new DynamicSet<>();
+      this.initializedFilters = ConcurrentHashMap.newKeySet();
       this.filterConfig = null;
     }
 
@@ -70,36 +77,29 @@
      * Initializes a filter if needed
      *
      * @param filter The filter that should get initialized
-     * @return {@code true} if filter is now initialized
      * @throws ServletException if filter itself fails to init
      */
-    private synchronized boolean initFilterIfNeeded(AllRequestFilter filter)
-        throws ServletException {
-      boolean ret = true;
-      if (filters.contains(filter)) {
-        // Regardless of whether or not the caller checked filter's
-        // containment in initializedFilters, we better re-check as we're now
-        // synchronized.
-        if (!initializedFilters.contains(filter)) {
-          filter.init(filterConfig);
-          initializedFilters.add("gerrit", filter);
-        }
-      } else {
-        ret = false;
+    private synchronized void initFilterIfNeeded(AllRequestFilter filter) throws ServletException {
+      // Regardless of whether or not the caller checked filter's
+      // containment in initializedFilters, we better re-check as we're now
+      // synchronized.
+      if (!isInitializedFilter(filter)) {
+        filter.init(filterConfig);
+        initializedFilters.add(filter);
       }
-      return ret;
     }
 
-    private synchronized void cleanUpInitializedFilters() {
-      Iterable<AllRequestFilter> filtersToCleanUp = initializedFilters;
-      initializedFilters = new DynamicSet<>();
-      for (AllRequestFilter filter : filtersToCleanUp) {
-        if (filters.contains(filter)) {
-          initializedFilters.add("gerrit", filter);
-        } else {
-          filter.destroy();
-        }
-      }
+    private synchronized void cleanUpInitializedFilters(
+        Function<Extension<AllRequestFilter>, Boolean> filterFunc) {
+      StreamSupport.stream(filters.entries().spliterator(), false)
+          .filter(filterFunc::apply)
+          .map(Extension::get)
+          .filter(this::isInitializedFilter)
+          .forEach(
+              f -> {
+                f.destroy();
+                removeFromInitializedFilters(f);
+              });
     }
 
     @Override
@@ -110,7 +110,7 @@
         @Override
         public void doFilter(ServletRequest req, ServletResponse res)
             throws IOException, ServletException {
-          while (itr.hasNext()) {
+          if (itr.hasNext()) {
             AllRequestFilter filter = itr.next();
             // To avoid {@code synchronized} on the whole filtering (and
             // thereby killing concurrency), we start the below disjunction
@@ -131,12 +131,13 @@
             // it, given that this is really both really improbable and also
             // the "proper" fix for it would basically kill concurrency of
             // webrequests.
-            if (initializedFilters.contains(filter) || initFilterIfNeeded(filter)) {
-              filter.doFilter(req, res, this);
-              return;
+            if (!isInitializedFilter(filter)) {
+              initFilterIfNeeded(filter);
             }
+            filter.doFilter(req, res, this);
+          } else {
+            last.doFilter(req, res);
           }
-          last.doFilter(req, res);
         }
       }.doFilter(req, res);
     }
@@ -150,26 +151,35 @@
       filterConfig = config;
 
       for (AllRequestFilter f : filters) {
-        @SuppressWarnings("unused")
-        var unused = initFilterIfNeeded(f);
+        initFilterIfNeeded(f);
       }
     }
 
     @Override
     public synchronized void destroy() {
-      Iterable<AllRequestFilter> filtersToDestroy = initializedFilters;
-      initializedFilters = new DynamicSet<>();
-      for (AllRequestFilter filter : filtersToDestroy) {
-        filter.destroy();
-      }
+      cleanUpInitializedFilters(SELECT_ALL);
+      initializedFilters = ConcurrentHashMap.newKeySet();
     }
 
     @Override
-    public void onStopPlugin(Plugin plugin) {
+    public void beforeStopPlugin(Plugin plugin) {
       // In order to allow properly garbage collection, we need to scrub
-      // initializedFilters clean of filters stemming from plugins as they
-      // get unloaded.
-      cleanUpInitializedFilters();
+      // initializedFilters clean of filters stemming from the plugins that
+      // will be unloaded
+      cleanUpInitializedFilters(selectExtentionForPlugin(plugin));
+    }
+
+    private static Function<Extension<AllRequestFilter>, Boolean> selectExtentionForPlugin(
+        Plugin plugin) {
+      return ext -> ext.getPluginName().equals(plugin.getName());
+    }
+
+    private boolean isInitializedFilter(AllRequestFilter filter) {
+      return initializedFilters.contains(filter);
+    }
+
+    private void removeFromInitializedFilters(AllRequestFilter filter) {
+      initializedFilters.remove(filter);
     }
   }
 
diff --git a/java/com/google/gerrit/httpd/BasicAuthHeader.java b/java/com/google/gerrit/httpd/BasicAuthHeader.java
new file mode 100644
index 0000000..7d8f993
--- /dev/null
+++ b/java/com/google/gerrit/httpd/BasicAuthHeader.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static com.google.gerrit.common.CharsetUtil.forNameOrUtf8;
+
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+import java.util.Optional;
+
+final class BasicAuthHeader {
+  static final String PREFIX = "Basic ";
+
+  private BasicAuthHeader() {}
+
+  static Optional<Credentials> parse(@Nullable String header, @Nullable String encoding)
+      throws IOException {
+    if (header == null || !header.startsWith(PREFIX)) {
+      return Optional.empty();
+    }
+
+    byte[] decoded = BaseEncoding.base64().decode(header.substring(PREFIX.length()));
+
+    String usernamePassword = new String(decoded, forNameOrUtf8(encoding));
+    int splitPos = usernamePassword.indexOf(':');
+    if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
+      return Optional.empty();
+    }
+    return Optional.of(
+        new Credentials(
+            usernamePassword.substring(0, splitPos), usernamePassword.substring(splitPos + 1)));
+  }
+
+  record Credentials(String username, String password) {}
+}
diff --git a/java/com/google/gerrit/httpd/CanonicalWebUrl.java b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
index 3b04884..288ca87 100644
--- a/java/com/google/gerrit/httpd/CanonicalWebUrl.java
+++ b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import javax.servlet.http.HttpServletRequest;
 
@@ -37,18 +36,12 @@
     return url != null ? url : computeFromRequest(req);
   }
 
-  @SuppressWarnings("JdkObsolete")
   static String computeFromRequest(HttpServletRequest req) {
-    StringBuffer url = req.getRequestURL();
-    try {
-      url = new StringBuffer(URLDecoder.decode(url.toString(), UTF_8.name()));
-      url.setLength(url.length() - req.getServletPath().length());
-      if (url.charAt(url.length() - 1) != '/') {
-        url.append('/');
-      }
-      return url.toString();
-    } catch (UnsupportedEncodingException e) {
-      throw new IllegalStateException("Unsupported encoding for request URL " + url, e);
+    StringBuilder url = new StringBuilder(URLDecoder.decode(req.getRequestURL().toString(), UTF_8));
+    url.setLength(url.length() - req.getServletPath().length());
+    if (url.charAt(url.length() - 1) != '/') {
+      url.append('/');
     }
+    return url.toString();
   }
 }
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index e520fa2..8a43e7a 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.httpd;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
@@ -70,7 +67,6 @@
 
   public static final String REALM_NAME = "Gerrit Code Review";
   private static final String AUTHORIZATION = "Authorization";
-  private static final String LIT_BASIC = "Basic ";
 
   private final DynamicItem<WebSession> session;
   private final AccountCache accountCache;
@@ -124,22 +120,21 @@
 
   private boolean verify(HttpServletRequest req, Response rsp) throws IOException {
     final String hdr = req.getHeader(AUTHORIZATION);
-    if (hdr == null || !hdr.startsWith(LIT_BASIC)) {
+    if (hdr == null || !hdr.startsWith(BasicAuthHeader.PREFIX)) {
       // Allow an anonymous connection through, or it might be using a
       // session cookie instead of basic authentication.
       return true;
     }
 
-    final byte[] decoded = BaseEncoding.base64().decode(hdr.substring(LIT_BASIC.length()));
-    String usernamePassword = new String(decoded, encoding(req));
-    int splitPos = usernamePassword.indexOf(':');
-    if (splitPos < 1) {
+    Optional<BasicAuthHeader.Credentials> credentials =
+        BasicAuthHeader.parse(hdr, req.getCharacterEncoding());
+    if (credentials.isEmpty()) {
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
 
-    String username = usernamePassword.substring(0, splitPos);
-    String password = usernamePassword.substring(splitPos + 1);
+    String username = credentials.get().username();
+    String password = credentials.get().password();
     if (Strings.isNullOrEmpty(password)) {
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
@@ -200,7 +195,7 @@
     } catch (AuthenticationFailedException e) {
       // This exception is thrown if the user provided wrong credentials, we don't need to log a
       // stacktrace for it.
-      logger.atWarning().log(authenticationFailedMsg(username, req) + ": %s", e.getMessage());
+      logger.atWarning().log("%s: %s", authenticationFailedMsg(username, req), e.getMessage());
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationUnavailableException e) {
@@ -243,10 +238,6 @@
     }
   }
 
-  private String encoding(HttpServletRequest req) {
-    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
-  }
-
   static class Response extends HttpServletResponseWrapper {
     private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
 
@@ -257,7 +248,7 @@
     private void status(int sc) {
       if (sc == SC_UNAUTHORIZED) {
         StringBuilder v = new StringBuilder();
-        v.append(LIT_BASIC);
+        v.append(BasicAuthHeader.PREFIX);
         v.append("realm=\"").append(REALM_NAME).append("\"");
         setHeader(WWW_AUTHENTICATE, v.toString());
       } else if (containsHeader(WWW_AUTHENTICATE)) {
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index 8645f9e..53efa77 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -18,11 +18,9 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
@@ -41,7 +39,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.util.Locale;
 import java.util.NoSuchElementException;
@@ -69,7 +66,6 @@
 
   private static final String REALM_NAME = "Gerrit Code Review";
   private static final String AUTHORIZATION = "Authorization";
-  private static final String BASIC = "Basic ";
   private static final String BEARER = "Bearer ";
   private static final String GIT_COOKIE_PREFIX = "git-";
 
@@ -134,8 +130,8 @@
       authRequest = authRequestFactory.createForBearerToken(authInfo.tokenOrSecret);
       // or if there is a BASIC authentication header
     } else {
-      if (hdr != null && hdr.startsWith(BASIC)) {
-        authInfo = extractAuthInfo(hdr, encoding(req));
+      if (hdr != null && hdr.startsWith(BasicAuthHeader.PREFIX)) {
+        authInfo = extractAuthInfo(hdr, req.getCharacterEncoding());
         if (authInfo == null) {
           rsp.sendError(SC_UNAUTHORIZED);
           return false;
@@ -246,17 +242,14 @@
   }
 
   @Nullable
-  private AuthInfo extractAuthInfo(String hdr, String encoding)
-      throws UnsupportedEncodingException {
-    byte[] decoded = BaseEncoding.base64().decode(hdr.substring(BASIC.length()));
-    String usernamePassword = new String(decoded, encoding);
-    int splitPos = usernamePassword.indexOf(':');
-    if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
+  private AuthInfo extractAuthInfo(String hdr, String encoding) throws IOException {
+    Optional<BasicAuthHeader.Credentials> credentials = BasicAuthHeader.parse(hdr, encoding);
+    if (credentials.isEmpty()) {
       return null;
     }
     return new AuthInfo(
-        usernamePassword.substring(0, splitPos),
-        usernamePassword.substring(splitPos + 1),
+        credentials.get().username(),
+        credentials.get().password(),
         defaultAuthPlugin,
         defaultAuthProvider);
   }
@@ -268,9 +261,9 @@
   }
 
   @Nullable
-  private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
+  private AuthInfo extractAuthInfo(Cookie cookie) {
     String username =
-        URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
+        URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8);
     String value = cookie.getValue();
     int splitPos = value.lastIndexOf('@');
     if (splitPos < 1 || splitPos == value.length() - 1) {
@@ -295,10 +288,6 @@
     return new AuthInfo(username, token, pluginName, exportName);
   }
 
-  private static String encoding(HttpServletRequest req) {
-    return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
-  }
-
   @Nullable
   private static Cookie findGitCookie(HttpServletRequest req) {
     Cookie[] cookies = req.getCookies();
@@ -340,7 +329,7 @@
     private void status(int sc) {
       if (sc == SC_UNAUTHORIZED) {
         StringBuilder v = new StringBuilder();
-        v.append(BASIC);
+        v.append(BasicAuthHeader.PREFIX);
         v.append("realm=\"").append(REALM_NAME).append("\"");
         setHeader(WWW_AUTHENTICATE, v.toString());
       } else if (containsHeader(WWW_AUTHENTICATE)) {
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 8856f91..3d3e54b 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -23,13 +23,13 @@
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import javax.servlet.http.HttpServletRequest;
 import org.apache.commons.net.util.SubnetUtils;
 
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index 3ced4ab..02ef00f 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -13,6 +13,7 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/util/crypto",
         "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java
index f74e005..027daa7 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthModule.java
@@ -14,18 +14,14 @@
 
 package com.google.gerrit.httpd.auth.oauth;
 
-import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.inject.servlet.ServletModule;
 
 /** Servlets and support related to OAuth authentication. */
 public class OAuthModule extends ServletModule {
-
   @Override
   protected void configureServlets() {
     filter("/login", "/login/*", "/oauth").through(OAuthWebFilter.class);
     // This is needed to invalidate OAuth session during logout
     serve("/logout").with(OAuthLogoutServlet.class);
-    DynamicMap.mapOf(binder(), OAuthServiceProvider.class);
   }
 }
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index 297505a..d16869c 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -19,9 +19,9 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.auth.oauth.OAuthAuthorizationInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -36,12 +36,11 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.util.crypto.SecureRandomUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
 import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
 import java.util.Optional;
 import javax.servlet.ServletRequest;
 import javax.servlet.http.HttpServletRequest;
@@ -53,20 +52,20 @@
 class OAuthSession {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final SecureRandom randomState = newRandomGenerator();
   private final String state;
   private final DynamicItem<WebSession> webSession;
   private final Provider<IdentifiedUser> identifiedUser;
   private final AccountManager accountManager;
   private final CanonicalWebUrl urlProvider;
   private final OAuthTokenCache tokenCache;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final AuthRequest.Factory authRequestFactory;
   private OAuthServiceProvider serviceProvider;
   private OAuthUserInfo user;
   private Account.Id accountId;
   private String redirectToken;
   private boolean linkMode;
-  private final ExternalIdKeyFactory externalIdKeyFactory;
-  private final AuthRequest.Factory authRequestFactory;
+  private String pkceVerifier;
 
   @Inject
   OAuthSession(
@@ -77,7 +76,7 @@
       OAuthTokenCache tokenCache,
       ExternalIdKeyFactory externalIdKeyFactory,
       AuthRequest.Factory authRequestFactory) {
-    this.state = generateRandomState();
+    this.state = SecureRandomUtil.newRandomString32();
     this.identifiedUser = identifiedUser;
     this.webSession = webSession;
     this.accountManager = accountManager;
@@ -107,7 +106,8 @@
       }
 
       logger.atFine().log("Login-Retrieve-User %s", this);
-      OAuthToken token = oauth.getAccessToken(new OAuthVerifier(request.getParameter("code")));
+      OAuthToken token =
+          oauth.getAccessToken(new OAuthVerifier(request.getParameter("code")), this.pkceVerifier);
       user = oauth.getUserInfo(token);
 
       if (isLoggedIn()) {
@@ -126,7 +126,12 @@
     // we cannot use LoginUrlToken.getToken() method,
     // because it relies on getPathInfo() and it is always null here.
     redirectToken = redirectToken.substring(request.getContextPath().length());
-    response.sendRedirect(oauth.getAuthorizationUrl() + "&state=" + state);
+
+    OAuthAuthorizationInfo authInfo = oauth.getAuthorizationInfo();
+    // capture the verifier in the session
+    this.pkceVerifier = authInfo.getPkceVerifier();
+
+    response.sendRedirect(authInfo.getAuthorizationUrl() + "&state=" + state);
     return false;
   }
 
@@ -228,6 +233,7 @@
     user = null;
     redirectToken = null;
     serviceProvider = null;
+    pkceVerifier = null;
   }
 
   private boolean checkState(ServletRequest request) {
@@ -239,23 +245,11 @@
     return true;
   }
 
-  private static SecureRandom newRandomGenerator() {
-    try {
-      return SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException("No SecureRandom available for GitHub authentication", e);
-    }
-  }
-
-  private static String generateRandomState() {
-    byte[] state = new byte[32];
-    randomState.nextBytes(state);
-    return BaseEncoding.base64Url().encode(state);
-  }
-
   @Override
   public String toString() {
-    return "OAuthSession [token=" + tokenCache.get(accountId) + ", user=" + user + "]";
+    // A non-evicting read: toString must not have the side effect of evicting an expired token
+    // (which would drop a still-usable refresh token before the refresh filter can use it).
+    return "OAuthSession [token=" + tokenCache.getEvenIfExpired(accountId) + ", user=" + user + "]";
   }
 
   public void setServiceProvider(OAuthServiceProvider provider) {
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index 7afb8ac..5505044 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -14,6 +14,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/util/http",
+        "//java/com/google/gerrit/util/crypto",
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:servlet-api",
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index df0062c..203e716 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -35,12 +34,11 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.util.crypto.SecureRandomUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
 import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
 import java.util.Optional;
 import javax.servlet.ServletRequest;
 import javax.servlet.http.HttpServletRequest;
@@ -53,7 +51,6 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final String GERRIT_LOGIN = "/login";
-  private static final SecureRandom randomState = newRandomGenerator();
   private final String state;
   private final DynamicItem<WebSession> webSession;
   private final Provider<IdentifiedUser> identifiedUser;
@@ -75,7 +72,7 @@
       CanonicalWebUrl urlProvider,
       ExternalIdKeyFactory externalIdKeyFactory,
       AuthRequest.Factory authRequestFactory) {
-    this.state = generateRandomState();
+    this.state = SecureRandomUtil.newRandomString32();
     this.webSession = webSession;
     this.identifiedUser = identifiedUser;
     this.accountManager = accountManager;
@@ -224,20 +221,6 @@
     return true;
   }
 
-  private static SecureRandom newRandomGenerator() {
-    try {
-      return SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException("No SecureRandom available for GitHub authentication", e);
-    }
-  }
-
-  private static String generateRandomState() {
-    byte[] state = new byte[32];
-    randomState.nextBytes(state);
-    return BaseEncoding.base64Url().encode(state);
-  }
-
   @Override
   public String toString() {
     return "OAuthSession [token=" + token + ", user=" + user + "]";
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java
index ace0c53..9292dadd 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdModule.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.httpd.auth.openid;
 
-import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.inject.servlet.ServletModule;
 
 /** Servlets related to OpenID authentication. */
@@ -29,6 +27,5 @@
     serve("/" + XrdsServlet.LOCATION).with(XrdsServlet.class);
     filter("/").through(XrdsFilter.class);
     bind(OpenIdServiceImpl.class);
-    DynamicMap.mapOf(binder(), OAuthServiceProvider.class);
   }
 }
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
index a85fd5e..1ab51e4 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/BUILD
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 2eee415..36c4952 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -16,9 +16,13 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.auth.oauth.OAuthTokenRefresher;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.auth.oauth.OAuthRevokedException;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 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.RestReadView;
@@ -39,26 +43,43 @@
 
   private final Provider<CurrentUser> self;
   private final OAuthTokenCache tokenCache;
+  private final OAuthTokenRefresher refresher;
   private final Provider<String> canonicalWebUrlProvider;
 
   @Inject
   GetOAuthToken(
       Provider<CurrentUser> self,
       OAuthTokenCache tokenCache,
+      OAuthTokenRefresher refresher,
       @CanonicalWebUrl Provider<String> urlProvider) {
     this.self = self;
     this.tokenCache = tokenCache;
+    this.refresher = refresher;
     this.canonicalWebUrlProvider = urlProvider;
   }
 
   @Override
   public Response<OAuthTokenInfo> apply(AccountResource rsrc)
-      throws AuthException, ResourceNotFoundException {
+      throws AuthException, ResourceNotFoundException, ResourceConflictException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       throw new AuthException("not allowed to get access token");
     }
-    OAuthToken accessToken = tokenCache.get(rsrc.getUser().getAccountId());
-    if (accessToken == null) {
+    if (tokenCache.isDisabled()) {
+      throw new ResourceConflictException(
+          "OAuth token cache is disabled by cache.oauth_tokens.memoryLimit = 0");
+    }
+    Account.Id id = rsrc.getUser().getAccountId();
+    try {
+      // Refresh on read: renew an expired token in place (RFC 6749 section 6) before returning it.
+      refresher.refreshIfExpired(id);
+    } catch (OAuthRevokedException e) {
+      logger.atFine().withCause(e).log("OAuth grant revoked for account %s", id);
+      throw new ResourceNotFoundException();
+    }
+    // Read without evicting (getEvenIfExpired) so a token that could not be refreshed keeps its
+    // refresh_token; treat a still-expired token as absent.
+    OAuthToken accessToken = tokenCache.getEvenIfExpired(id);
+    if (accessToken == null || accessToken.isExpired()) {
       throw new ResourceNotFoundException();
     }
     OAuthTokenInfo accessTokenInfo = new OAuthTokenInfo();
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 18d26c0..5063a81 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -714,8 +714,7 @@
   private void copyStderrToLog(InputStream in) {
     new Thread(
             () -> {
-              try (BufferedReader br =
-                  new BufferedReader(new InputStreamReader(in, ISO_8859_1.name()))) {
+              try (BufferedReader br = new BufferedReader(new InputStreamReader(in, ISO_8859_1))) {
                 String err =
                     br.lines()
                         .filter(s -> !s.isEmpty())
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index bef75b0..71aea95 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -321,6 +321,7 @@
     modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheBindingModule.class));
 
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
+    modules.add(new AuthModule(authConfig));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
     modules.add(new DefaultRefLogIdentityProvider.Module());
@@ -460,7 +461,6 @@
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
     }
-    modules.add(new AuthModule(authConfig));
 
     modules.add(sysInjector.getInstance(GetUserFilter.GetUserFilterModule.class));
 
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index e17a534..72566d0 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -64,7 +64,6 @@
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.OutputStream;
-import java.io.UnsupportedEncodingException;
 import java.nio.charset.Charset;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -509,7 +508,7 @@
       PluginResourceKey cacheKey,
       HttpServletResponse res,
       long lastModifiedTime)
-      throws UnsupportedEncodingException, IOException {
+      throws IOException {
     Map<String, String> macros = new HashMap<>();
     macros.put("PLUGIN", pluginName);
     macros.put("SSH_HOST", sshHost);
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 0671172..f7eb673 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -237,9 +237,6 @@
         "manifestPath",
         urlInScriptTagOrdainer.apply(Strings.nullToEmpty(canonicalPath) + "/manifest.webmanifest"));
 
-    if (urlParameterMap.containsKey("ce")) {
-      data.put("polyfillCE", "true");
-    }
     if (urlParameterMap.containsKey("gf")) {
       data.put("useGoogleFonts", "true");
     }
diff --git a/java/com/google/gerrit/index/IndexCollection.java b/java/com/google/gerrit/index/IndexCollection.java
index 66a9fba..f5dc3da 100644
--- a/java/com/google/gerrit/index/IndexCollection.java
+++ b/java/com/google/gerrit/index/IndexCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.index;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Lists;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import java.util.Collection;
@@ -29,7 +28,7 @@
   private final AtomicReference<I> searchIndex;
 
   protected IndexCollection() {
-    this.writeIndexes = Lists.newCopyOnWriteArrayList();
+    this.writeIndexes = new CopyOnWriteArrayList<>();
     this.searchIndex = new AtomicReference<>();
   }
 
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index fda961d..70ab09c 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -18,17 +18,14 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Comparator;
-import java.util.List;
 
 /** Requires all predicates to be true. */
 public class AndPredicate<T> extends Predicate<T>
     implements Matchable<T>, Comparator<Predicate<T>> {
-  private final List<Predicate<T>> children;
+  private final ImmutableList<Predicate<T>> children;
   private final int cost;
 
   @SafeVarargs
@@ -37,7 +34,7 @@
   }
 
   protected AndPredicate(Collection<? extends Predicate<T>> that) {
-    List<Predicate<T>> t = new ArrayList<>(that.size());
+    ImmutableList.Builder<Predicate<T>> t = ImmutableList.builderWithExpectedSize(that.size());
     int c = 0;
     for (Predicate<T> p : sort(that)) {
       if (getClass() == p.getClass()) {
@@ -50,13 +47,13 @@
         c += p.estimateCost();
       }
     }
-    children = t;
+    children = t.build();
     cost = c;
   }
 
   @Override
-  public final List<Predicate<T>> getChildren() {
-    return Collections.unmodifiableList(children);
+  public final ImmutableList<Predicate<T>> getChildren() {
+    return children;
   }
 
   @Override
@@ -106,7 +103,7 @@
 
   @Override
   public int hashCode() {
-    return getChild(0).hashCode() * 31 + getChild(1).hashCode();
+    return getClass().hashCode() * 31 + children.hashCode();
   }
 
   // Suppress the EqualsGetClass warning as this is legacy code.
@@ -116,8 +113,7 @@
     if (other == null) {
       return false;
     }
-    return getClass() == other.getClass()
-        && getChildren().equals(((Predicate<?>) other).getChildren());
+    return getClass() == other.getClass() && children.equals(((Predicate<?>) other).getChildren());
   }
 
   private ImmutableList<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 6de0712..b51ad90 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -38,14 +38,12 @@
     this.start = start;
     this.indexConfig = indexConfig;
 
-    int c = Integer.MAX_VALUE;
     Predicate<T> selectedSource = null;
     int minCardinality = Integer.MAX_VALUE;
     for (Predicate<T> p : getChildren()) {
       if (p instanceof DataSource) {
         DataSource<?> source = (DataSource<?>) p;
         int cardinality = source.getCardinality();
-        c = Math.min(c, source.getCardinality());
 
         if (selectedSource == null
             || cardinality < minCardinality
@@ -60,7 +58,7 @@
       throw new IllegalArgumentException("No DataSource Found");
     }
     this.filteredSource = toDataSource(selectedSource);
-    this.cardinality = c;
+    this.cardinality = minCardinality;
   }
 
   @Override
@@ -75,11 +73,7 @@
 
   @Override
   public boolean match(T object) {
-    if (super.isMatchable() && !super.match(object)) {
-      return false;
-    }
-
-    return true;
+    return !super.isMatchable() || super.match(object);
   }
 
   protected List<T> transformBuffer(List<T> buffer) {
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 551de92..36f7888 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -21,7 +21,6 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.index.IndexedField;
-import com.google.gerrit.index.IndexedField.SearchSpec;
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
@@ -39,7 +38,7 @@
    * contain a map from {@link IndexedField#name()} to a stored value.
    *
    * <p>In case #2 {@link #storesIndexedFields} is set to {@code false} and the {@link #fields}
-   * contain a map from {@link SearchSpec#name()} to a stored value.
+   * contain a map from {@code SearchSpec#name()} to a stored value.
    */
   private final boolean storesIndexedFields;
 
diff --git a/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
index 1c31af3..e0af1ec 100644
--- a/java/com/google/gerrit/index/query/OrPredicate.java
+++ b/java/com/google/gerrit/index/query/OrPredicate.java
@@ -16,15 +16,13 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import java.util.ArrayList;
+import com.google.common.collect.ImmutableList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
 
 /** Requires one predicate to be true. */
 public class OrPredicate<T> extends Predicate<T> implements Matchable<T> {
-  private final List<Predicate<T>> children;
+  private final ImmutableList<Predicate<T>> children;
   private final int cost;
 
   @SafeVarargs
@@ -33,7 +31,7 @@
   }
 
   protected OrPredicate(Collection<? extends Predicate<T>> that) {
-    List<Predicate<T>> t = new ArrayList<>(that.size());
+    ImmutableList.Builder<Predicate<T>> t = ImmutableList.builderWithExpectedSize(that.size());
     int c = 0;
     for (Predicate<T> p : that) {
       if (getClass() == p.getClass()) {
@@ -46,13 +44,13 @@
         c += p.estimateCost();
       }
     }
-    children = t;
+    children = t.build();
     cost = c;
   }
 
   @Override
-  public final List<Predicate<T>> getChildren() {
-    return Collections.unmodifiableList(children);
+  public final ImmutableList<Predicate<T>> getChildren() {
+    return children;
   }
 
   @Override
@@ -102,7 +100,7 @@
 
   @Override
   public int hashCode() {
-    return getChild(0).hashCode() * 31 + getChild(1).hashCode();
+    return getClass().hashCode() * 31 + children.hashCode();
   }
 
   // Suppress the EqualsGetClass warning as this is legacy code.
@@ -112,8 +110,7 @@
     if (other == null) {
       return false;
     }
-    return getClass() == other.getClass()
-        && getChildren().equals(((Predicate<?>) other).getChildren());
+    return getClass() == other.getClass() && children.equals(((Predicate<?>) other).getChildren());
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index 19251ca..3bde44d 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -62,7 +62,7 @@
             int pageSizeMultiplier = opts.pageSizeMultiplier();
             Object searchAfter = resultSet.searchAfter();
             int nextStart = pageResultSize;
-            while (pageResultSize == pageSize && r.size() <= limit) { // get 1 more than the limit
+            while (pageResultSize == pageSize && r.size() < limit) {
               pageSize = getNextPageSize(pageSize, pageSizeMultiplier);
               ResultSet<T> next =
                   indexConfig.paginationType().equals(PaginationType.SEARCH_AFTER)
@@ -74,7 +74,7 @@
                   r.add(data);
                 }
                 pageResultSize++;
-                if (r.size() > limit) {
+                if (r.size() >= limit) {
                   break;
                 }
               }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index d48a876..32399d3 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -44,7 +44,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.nio.file.Path;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Comparator;
@@ -75,10 +76,10 @@
   private final Schema<V> schema;
 
   /**
-   * SitePaths (config files) are used to signal that an index is ready. This implementation is
-   * consistent with other index backends.
+   * The index directory (config files) is used to signal that an index is ready. This
+   * implementation is consistent with other index backends.
    */
-  private final SitePaths sitePaths;
+  private final Path indexDir;
 
   private final String indexName;
   private final Map<K, D> indexedDocuments;
@@ -86,9 +87,9 @@
   private int flushAndCommitCount;
   private List<Integer> resultsSizes;
 
-  AbstractFakeIndex(Schema<V> schema, SitePaths sitePaths, String indexName) {
+  AbstractFakeIndex(Schema<V> schema, Path indexDir, String indexName) {
     this.schema = schema;
-    this.sitePaths = sitePaths;
+    this.indexDir = indexDir;
     this.indexName = indexName;
     this.indexedDocuments = new HashMap<>();
     this.queryCount = 0;
@@ -238,7 +239,7 @@
 
   @Override
   public void markReady(boolean ready) {
-    IndexUtils.setReady(sitePaths, indexName, schema.getVersion(), ready);
+    IndexUtils.setReady(indexDir, indexName, schema.getVersion(), ready);
   }
 
   /** Method to get a key from a document. */
@@ -270,12 +271,12 @@
     @Inject
     @VisibleForTesting
     protected FakeChangeIndex(
-        SitePaths sitePaths,
+        @IndexDir Path indexDir,
         ChangeData.Factory changeDataFactory,
         @Assisted Schema<ChangeData> schema,
         @GerritServerConfig Config cfg,
         IndexConfig indexConfig) {
-      super(schema, sitePaths, "changes");
+      super(schema, indexDir, "changes");
       this.changeDataFactory = changeDataFactory;
       this.skipMergable = !MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
       this.indexConfig = indexConfig;
@@ -356,8 +357,8 @@
   public static class FakeAccountIndex
       extends AbstractFakeIndex<Account.Id, AccountState, AccountState> implements AccountIndex {
     @Inject
-    FakeAccountIndex(SitePaths sitePaths, @Assisted Schema<AccountState> schema) {
-      super(schema, sitePaths, "accounts");
+    FakeAccountIndex(@IndexDir Path indexDir, @Assisted Schema<AccountState> schema) {
+      super(schema, indexDir, "accounts");
     }
 
     @Override
@@ -394,8 +395,8 @@
       extends AbstractFakeIndex<AccountGroup.UUID, InternalGroup, InternalGroup>
       implements GroupIndex {
     @Inject
-    FakeGroupIndex(SitePaths sitePaths, @Assisted Schema<InternalGroup> schema) {
-      super(schema, sitePaths, "groups");
+    FakeGroupIndex(@IndexDir Path indexDir, @Assisted Schema<InternalGroup> schema) {
+      super(schema, indexDir, "groups");
     }
 
     @Override
@@ -431,8 +432,8 @@
   public static class FakeProjectIndex
       extends AbstractFakeIndex<Project.NameKey, ProjectData, ProjectData> implements ProjectIndex {
     @Inject
-    FakeProjectIndex(SitePaths sitePaths, @Assisted Schema<ProjectData> schema) {
-      super(schema, sitePaths, "projects");
+    FakeProjectIndex(@IndexDir Path indexDir, @Assisted Schema<ProjectData> schema) {
+      super(schema, indexDir, "projects");
     }
 
     @Override
diff --git a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
index 6adb007..1e8effd 100644
--- a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
+++ b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
@@ -21,11 +21,13 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.OnlineUpgradeListener;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.nio.file.Path;
 import java.util.Collection;
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.Config;
@@ -38,10 +40,12 @@
   FakeIndexVersionManager(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
+      @IndexDir Path indexDir,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
     super(
         sitePaths,
+        indexDir,
         listeners,
         defs,
         VersionManager.shouldPerformOnlineUpgrade(cfg),
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 53f4af9..79205ed 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -325,6 +325,7 @@
     List<URL> extapi = new ArrayList<>();
     move(jars, "gerrit-extension-api-", extapi);
     move(jars, "guice-", extapi);
+    move(jars, "jakarta.inject-api-2.0.1.jar", extapi);
     move(jars, "javax.inject-1.jar", extapi);
     move(jars, "aopalliance-1.0.jar", extapi);
     move(jars, "guice-servlet-", extapi);
@@ -728,9 +729,9 @@
         throw new FileNotFoundException("Cannot extract path from " + u);
       }
 
-      // Pop up to the top-level source folder by looking for WORKSPACE.
+      // Pop up to the top-level source folder by looking for MODULE.bazel.
       dir = Path.of(u.getPath());
-      while (!Files.isRegularFile(dir.resolve("WORKSPACE"))) {
+      while (!Files.isRegularFile(dir.resolve("MODULE.bazel"))) {
         Path parent = dir.getParent();
         if (parent == null) {
           throw new FileNotFoundException("Cannot find source root from " + u);
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 9489181..56e0793 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.index.query.ListResultSet;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
@@ -105,7 +104,7 @@
   }
 
   private final Schema<V> schema;
-  private final SitePaths sitePaths;
+  private final Path indexDir;
   private final Directory dir;
   private final String name;
   private final ImmutableSet<String> skipFields;
@@ -121,7 +120,7 @@
   @SuppressWarnings("ThreadPriorityCheck")
   AbstractLuceneIndex(
       Schema<V> schema,
-      SitePaths sitePaths,
+      Path indexDir,
       Directory dir,
       String name,
       ImmutableSet<String> skipFields,
@@ -132,7 +131,7 @@
       Function<V, K> valueToKeyFunction)
       throws IOException {
     this.schema = schema;
-    this.sitePaths = sitePaths;
+    this.indexDir = indexDir;
     this.dir = dir;
     this.name = name;
     this.skipFields = skipFields;
@@ -247,7 +246,7 @@
 
   @Override
   public void markReady(boolean ready) {
-    IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready);
+    IndexUtils.setReady(indexDir, name, schema.getVersion(), ready);
   }
 
   @Override
@@ -555,9 +554,12 @@
     IndexCommit commit = snapshooter.snapshot();
     try {
       Path sourceDir = canonical(((FSDirectory) commit.getDirectory()).getDirectory());
-      Path indexDir = canonical(sitePaths.index_dir);
+      Path canonicalIndexDir = canonical(indexDir);
       Path targetDir =
-          indexDir.resolve("snapshots").resolve(id).resolve(indexDir.relativize(sourceDir));
+          canonicalIndexDir
+              .resolve("snapshots")
+              .resolve(id)
+              .resolve(canonicalIndexDir.relativize(sourceDir));
       if (targetDir.toFile().exists()) {
         throw new FileAlreadyExistsException(targetDir.toString());
       }
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 2bc29d9..8c0b8b8 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.options.AutoFlush;
@@ -43,13 +42,14 @@
 import org.apache.lucene.document.NumericDocValuesField;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.store.Directory;
-import org.apache.lucene.store.FSDirectory;
+import org.eclipse.jgit.lib.Config;
 
 public class ChangeSubIndex extends AbstractLuceneIndex<Change.Id, ChangeData>
     implements ChangeIndex {
   ChangeSubIndex(
       Schema<ChangeData> schema,
-      SitePaths sitePaths,
+      Path indexDir,
+      Config cfg,
       Path path,
       ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
@@ -58,8 +58,8 @@
       throws IOException {
     this(
         schema,
-        sitePaths,
-        FSDirectory.open(path),
+        indexDir,
+        LuceneDirectory.open(cfg, path),
         path.getFileName().toString(),
         skipFields,
         writerConfig,
@@ -69,7 +69,7 @@
 
   ChangeSubIndex(
       Schema<ChangeData> schema,
-      SitePaths sitePaths,
+      Path indexDir,
       Directory dir,
       String subIndex,
       ImmutableSet<String> skipFields,
@@ -79,7 +79,7 @@
       throws IOException {
     super(
         schema,
-        sitePaths,
+        indexDir,
         dir,
         NAME,
         skipFields,
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 436f403..7970b33 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.options.AutoFlush;
@@ -52,7 +53,6 @@
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
-import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -82,27 +82,28 @@
   private final QueryBuilder<AccountState> queryBuilder;
   private final Provider<AccountCache> accountCache;
 
-  private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
+  private static Directory dir(Schema<AccountState> schema, Config cfg, Path indexDir)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new ByteBuffersDirectory();
     }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
-    return FSDirectory.open(indexDir);
+    Path dir = LuceneVersionManager.getDir(indexDir, ACCOUNTS, schema);
+    return LuceneDirectory.open(cfg, dir);
   }
 
   @Inject
   LuceneAccountIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
+      @IndexDir Path indexDir,
       Provider<AccountCache> accountCache,
       @Assisted Schema<AccountState> schema,
       AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
-        sitePaths,
-        dir(schema, cfg, sitePaths),
+        indexDir,
+        dir(schema, cfg, indexDir),
         ACCOUNTS,
         ImmutableSet.of(),
         null,
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index f0b056d..fd59370 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -53,6 +53,7 @@
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -134,6 +135,7 @@
   LuceneChangeIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
+      @IndexDir Path indexDir,
       @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
       ChangeData.Factory changeDataFactory,
       @Assisted Schema<ChangeData> schema,
@@ -159,7 +161,7 @@
       openIndex =
           new ChangeSubIndex(
               schema,
-              sitePaths,
+              indexDir,
               new ByteBuffersDirectory(),
               "ramOpen",
               skipFields,
@@ -169,7 +171,7 @@
       closedIndex =
           new ChangeSubIndex(
               schema,
-              sitePaths,
+              indexDir,
               new ByteBuffersDirectory(),
               "ramClosed",
               skipFields,
@@ -177,11 +179,12 @@
               searcherFactory,
               autoFlush);
     } else {
-      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
+      Path dir = LuceneVersionManager.getDir(indexDir, CHANGES, schema);
       openIndex =
           new ChangeSubIndex(
               schema,
-              sitePaths,
+              indexDir,
+              cfg,
               dir.resolve(CHANGES_OPEN),
               skipFields,
               openConfig,
@@ -190,7 +193,8 @@
       closedIndex =
           new ChangeSubIndex(
               schema,
-              sitePaths,
+              indexDir,
+              cfg,
               dir.resolve(CHANGES_CLOSED),
               skipFields,
               closedConfig,
diff --git a/java/com/google/gerrit/lucene/LuceneDirectory.java b/java/com/google/gerrit/lucene/LuceneDirectory.java
new file mode 100644
index 0000000..1117401
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneDirectory.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2026 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.lucene;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.LockFactory;
+import org.apache.lucene.store.NativeFSLockFactory;
+import org.apache.lucene.store.SimpleFSLockFactory;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Opens the on-disk {@link Directory} of a Lucene index, using the lock implementation configured
+ * for this site.
+ */
+final class LuceneDirectory {
+  /** Value of the {@code index.lockFactory} setting. */
+  enum LockFactoryType {
+    /** Use {@link NativeFSLockFactory}. */
+    NATIVE,
+
+    /** Use {@link SimpleFSLockFactory}. */
+    SIMPLE
+  }
+
+  static Directory open(Config cfg, Path path) throws IOException {
+    return FSDirectory.open(path, lockFactory(cfg));
+  }
+
+  private static LockFactory lockFactory(Config cfg) {
+    return switch (cfg.getEnum(
+        LockFactoryType.values(), "index", null, "lockFactory", LockFactoryType.NATIVE)) {
+      case NATIVE -> NativeFSLockFactory.INSTANCE;
+      case SIMPLE -> SimpleFSLockFactory.INSTANCE;
+    };
+  }
+
+  private LuceneDirectory() {}
+}
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 46d8825..392e5b2 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.options.AutoFlush;
@@ -49,7 +50,6 @@
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
-import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -72,27 +72,27 @@
   private final QueryBuilder<InternalGroup> queryBuilder;
   private final Provider<GroupCache> groupCache;
 
-  private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
-      throws IOException {
+  private static Directory dir(Schema<?> schema, Config cfg, Path indexDir) throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new ByteBuffersDirectory();
     }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
-    return FSDirectory.open(indexDir);
+    Path dir = LuceneVersionManager.getDir(indexDir, GROUPS, schema);
+    return LuceneDirectory.open(cfg, dir);
   }
 
   @Inject
   LuceneGroupIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
+      @IndexDir Path indexDir,
       Provider<GroupCache> groupCache,
       @Assisted Schema<InternalGroup> schema,
       AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
-        sitePaths,
-        dir(schema, cfg, sitePaths),
+        indexDir,
+        dir(schema, cfg, indexDir),
         GROUPS,
         ImmutableSet.of(),
         null,
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index f263c8e..8ebc77b 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.project.ProjectCache;
@@ -50,7 +51,6 @@
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
-import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -72,27 +72,28 @@
   private final QueryBuilder<ProjectData> queryBuilder;
   private final Provider<ProjectCache> projectCache;
 
-  private static Directory dir(Schema<ProjectData> schema, Config cfg, SitePaths sitePaths)
+  private static Directory dir(Schema<ProjectData> schema, Config cfg, Path indexDir)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new ByteBuffersDirectory();
     }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, PROJECTS, schema);
-    return FSDirectory.open(indexDir);
+    Path dir = LuceneVersionManager.getDir(indexDir, PROJECTS, schema);
+    return LuceneDirectory.open(cfg, dir);
   }
 
   @Inject
   LuceneProjectIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
+      @IndexDir Path indexDir,
       Provider<ProjectCache> projectCache,
       @Assisted Schema<ProjectData> schema,
       AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
-        sitePaths,
-        dir(schema, cfg, sitePaths),
+        indexDir,
+        dir(schema, cfg, indexDir),
         PROJECTS,
         ImmutableSet.of(),
         null,
diff --git a/java/com/google/gerrit/lucene/LuceneVersionManager.java b/java/com/google/gerrit/lucene/LuceneVersionManager.java
index f900d75..7461ebe 100644
--- a/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.OnlineUpgradeListener;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -39,18 +40,20 @@
 public class LuceneVersionManager extends VersionManager {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static Path getDir(SitePaths sitePaths, String name, Schema<?> schema) {
-    return sitePaths.index_dir.resolve(String.format("%s_%04d", name, schema.getVersion()));
+  static Path getDir(Path indexDir, String name, Schema<?> schema) {
+    return indexDir.resolve(String.format("%s_%04d", name, schema.getVersion()));
   }
 
   @Inject
   LuceneVersionManager(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
+      @IndexDir Path indexDir,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
     super(
         sitePaths,
+        indexDir,
         listeners,
         defs,
         VersionManager.shouldPerformOnlineUpgrade(cfg),
@@ -63,7 +66,7 @@
     TreeMap<Integer, VersionManager.Version<V>> versions = new TreeMap<>();
     for (Schema<V> schema : def.getSchemas().values()) {
       // This part is Lucene-specific.
-      Path p = getDir(sitePaths, def.getName(), schema);
+      Path p = getDir(indexDir, def.getName(), schema);
       boolean isDir = Files.isDirectory(p);
       if (Files.exists(p) && !isDir) {
         logger.atWarning().log("Not a directory: %s", p.toAbsolutePath());
@@ -73,7 +76,7 @@
     }
 
     String prefix = def.getName() + "_";
-    try (DirectoryStream<Path> paths = Files.newDirectoryStream(sitePaths.index_dir)) {
+    try (DirectoryStream<Path> paths = Files.newDirectoryStream(indexDir)) {
       for (Path p : paths) {
         String n = p.getFileName().toString();
         if (!n.startsWith(prefix)) {
@@ -90,7 +93,7 @@
         }
       }
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Error scanning index directory: %s", sitePaths.index_dir);
+      logger.atSevere().withCause(e).log("Error scanning index directory: %s", indexDir);
     }
     return versions;
   }
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 79d1cb8f..39c40ab 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.entities.Address;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.io.InputStreamReader;
 import java.time.Instant;
 import java.util.Locale;
 import org.apache.james.mime4j.MimeException;
@@ -150,8 +149,7 @@
       Entity part, StringBuilder textBuilder, StringBuilder htmlBuilder) throws IOException {
     if (isPlainOrHtml(part.getMimeType()) && !isAttachment(part.getDispositionType())) {
       TextBody tb = (TextBody) part.getBody();
-      String result =
-          CharStreams.toString(new InputStreamReader(tb.getInputStream(), tb.getMimeCharset()));
+      String result = CharStreams.toString(tb.getReader());
       if (part.getMimeType().equals("text/plain")) {
         textBuilder.append(result);
       } else if (part.getMimeType().equals("text/html")) {
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index fc5a2c7..9c3e8b5 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
@@ -115,7 +116,7 @@
 
   @Override
   protected boolean beforeInit(SiteInit init) throws Exception {
-    indexStatus = new GerritIndexStatus(init.site);
+    indexStatus = new GerritIndexStatus(IndexModule.indexDirectory(init.site));
     ErrorLogFile.errorOnlyConsole();
 
     if (!skipPlugins) {
diff --git a/java/com/google/gerrit/pgm/MigratePasswordsToTokens.java b/java/com/google/gerrit/pgm/MigratePasswordsToTokens.java
index 8c6e352..afc7bdf 100644
--- a/java/com/google/gerrit/pgm/MigratePasswordsToTokens.java
+++ b/java/com/google/gerrit/pgm/MigratePasswordsToTokens.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -95,7 +96,8 @@
                 bind(new TypeLiteral<List<String>>() {})
                     .annotatedWith(InstallPlugins.class)
                     .toInstance(new ArrayList<>());
-                bind(LockManager.class).toInstance(new DefaultLockManager());
+                DynamicItem.itemOf(binder(), LockManager.class);
+                DynamicItem.bind(binder(), LockManager.class).to(DefaultLockManager.class);
 
                 factory(PasswordMigrator.Factory.class);
                 factory(MetaDataUpdate.InternalFactory.class);
diff --git a/java/com/google/gerrit/pgm/ReduceMaxTokenLifetime.java b/java/com/google/gerrit/pgm/ReduceMaxTokenLifetime.java
index 835f557..889965f 100644
--- a/java/com/google/gerrit/pgm/ReduceMaxTokenLifetime.java
+++ b/java/com/google/gerrit/pgm/ReduceMaxTokenLifetime.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -91,7 +92,8 @@
                     .annotatedWith(InstallPlugins.class)
                     .toInstance(new ArrayList<>());
                 bind(IdentifiedUser.GenericFactory.class);
-                bind(LockManager.class).toInstance(new DefaultLockManager());
+                DynamicItem.itemOf(binder(), LockManager.class);
+                DynamicItem.bind(binder(), LockManager.class).to(DefaultLockManager.class);
 
                 factory(MetaDataUpdate.InternalFactory.class);
                 factory(VersionedAuthTokens.Factory.class);
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 7c0f258..243626a 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -158,7 +158,7 @@
         printCacheStats();
       }
       return ok ? 0 : 1;
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw die(e.getMessage(), e);
     } finally {
       sysManager.stop();
@@ -315,7 +315,7 @@
       new CacheDisplay(
               sw,
               StreamSupport.stream(cacheMap.spliterator(), false)
-                  .map(e -> CacheInfoFactory.create(e.getExportName(), e.get()))
+                  .map(e -> CacheInfoFactory.create(e.getExportName(), e.get(), true))
                   .collect(Collectors.toList()))
           .displayCaches();
       System.out.print(sw.toString());
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 20f5bb3..6c4299d 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -333,7 +333,8 @@
     final int requestHeaderSize = cfg.getInt("httpd", "requestheadersize", 16386);
     final URI[] listenUrls = listenURLs(cfg);
     final boolean reuseAddress = cfg.getBoolean("httpd", "reuseaddress", true);
-    final int acceptors = cfg.getInt("httpd", "acceptorThreads", 2);
+    final int acceptors = cfg.getInt("httpd", "acceptorThreads", 0);
+    final int selectors = cfg.getInt("httpd", "selectorThreads", 2);
     final AuthType authType = cfg.getEnum("auth", null, "type", AuthType.OPENID);
 
     reverseProxy = isReverseProxied(listenUrls);
@@ -353,14 +354,23 @@
       //     decodes to a reserved one (e.g. %25 decoding to '%'),
       //     hit by /changes/%3C%25%3DFOO%25%3E~1/detail where the
       //     decoded identifier '<%=FOO%>' contains a literal '%'.
-      // Allow exactly these two violations; broader presets like LEGACY
+      //   - SUSPICIOUS_PATH_CHARACTERS: Allow encoded path characters
+      //     not allowed by the Servlet spec rules. This is needed to support
+      //     backslashes in change queries which UI encodes in the path.
+      //     Example:
+      //       branch:^a\.b
+      //     UI code create this URI: http://host/q/branch:%5Ea%5C.b
+      //     When opening that encoded URI again Jetty rejects it because of
+      //     the (encoded) backslash
+      // Allow exactly these three violations; broader presets like LEGACY
       // also permit suspicious characters, USER_INFO, FRAGMENT etc. that
       // Gerrit's REST surface does not need.
       config.setUriCompliance(
           UriCompliance.from(
               EnumSet.of(
                   UriCompliance.Violation.AMBIGUOUS_PATH_SEPARATOR,
-                  UriCompliance.Violation.AMBIGUOUS_PATH_ENCODING)));
+                  UriCompliance.Violation.AMBIGUOUS_PATH_ENCODING,
+                  UriCompliance.Violation.SUSPICIOUS_PATH_CHARACTERS)));
 
       if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType) && !"https".equals(u.getScheme())) {
         throw new IllegalArgumentException(
@@ -376,7 +386,7 @@
 
       if ("http".equals(u.getScheme())) {
         defaultPort = 80;
-        c = newServerConnector(server, acceptors, config);
+        c = newServerConnector(server, acceptors, selectors, config);
 
       } else if ("https".equals(u.getScheme())) {
         SslContextFactory.Server ssl = new SslContextFactory.Server();
@@ -409,15 +419,15 @@
                 null,
                 null,
                 null,
-                0,
                 acceptors,
+                selectors,
                 new SslConnectionFactory(ssl, "http/1.1"),
                 new HttpConnectionFactory(config));
 
       } else if ("proxy-http".equals(u.getScheme())) {
         defaultPort = 8080;
         config.addCustomizer(FORWARDED_REQUEST_CUSTOMIZER);
-        c = newServerConnector(server, acceptors, config);
+        c = newServerConnector(server, acceptors, selectors, config);
 
       } else if ("proxy-https".equals(u.getScheme())) {
         defaultPort = 8080;
@@ -443,7 +453,7 @@
                     return true;
                   }
                 });
-        c = newServerConnector(server, acceptors, config);
+        c = newServerConnector(server, acceptors, selectors, config);
 
       } else {
         throw new IllegalArgumentException(
@@ -485,9 +495,9 @@
   }
 
   private static ServerConnector newServerConnector(
-      Server server, int acceptors, HttpConfiguration config) {
+      Server server, int acceptors, int selectors, HttpConfiguration config) {
     return new ServerConnector(
-        server, null, null, null, 0, acceptors, new HttpConnectionFactory(config));
+        server, null, null, null, acceptors, selectors, new HttpConnectionFactory(config));
   }
 
   private HttpConfiguration defaultConfig(int requestHeaderSize) {
diff --git a/java/com/google/gerrit/pgm/init/InitAuth.java b/java/com/google/gerrit/pgm/init/InitAuth.java
index 0c6515e..cd705ae 100644
--- a/java/com/google/gerrit/pgm/init/InitAuth.java
+++ b/java/com/google/gerrit/pgm/init/InitAuth.java
@@ -30,6 +30,8 @@
 import com.google.gerrit.server.mail.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.security.SecureRandom;
+import java.util.Base64;
 import java.util.EnumSet;
 
 /** Initialize the {@code auth} configuration section. */
@@ -59,10 +61,13 @@
   public void run() {
     ui.header("User Authentication");
 
-    initAuthType();
+    AuthType authType = initAuthType();
     if (auth.getSecure("registerEmailPrivateKey") == null) {
       auth.setSecure("registerEmailPrivateKey", SignedToken.generateRandomKey());
     }
+    if (authType == AuthType.OAUTH && auth.getSecure("tokenEncryptionKey") == null) {
+      auth.setSecure("tokenEncryptionKey", generateTokenEncryptionKey());
+    }
 
     initSignedPush();
 
@@ -71,7 +76,7 @@
     }
   }
 
-  private void initAuthType() {
+  private AuthType initAuthType() {
     AuthType authType =
         auth.select(
             "Authentication method",
@@ -140,6 +145,7 @@
           OPENID,
           OPENID_SSO -> {}
     }
+    return authType;
   }
 
   private void initSignedPush() {
@@ -152,4 +158,10 @@
     boolean enableCaseInsensitivity = ui.yesno(true, "Use case insensitive usernames");
     auth.set("userNameCaseInsensitive", Boolean.toString(enableCaseInsensitivity));
   }
+
+  private static String generateTokenEncryptionKey() {
+    byte[] key = new byte[32]; // 256-bit AES key; base64-encoded, HKDF-expanded at runtime
+    new SecureRandom().nextBytes(key);
+    return Base64.getEncoder().encodeToString(key);
+  }
 }
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index a6254fd..53a4aca 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.pgm.init;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.FileUtil;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
@@ -34,6 +36,7 @@
 /** Initialize the {@code index} configuration section. */
 @Singleton
 class InitIndex implements InitStep {
+  private static final String DIRECTORY = "directory";
   private final ConsoleUI ui;
   private final Section index;
   private final SitePaths site;
@@ -56,9 +59,15 @@
         new IndexType(
             index.select("Type", "type", IndexType.getDefault(), IndexType.getKnownTypes()));
 
+    if (Strings.isNullOrEmpty(index.get(DIRECTORY))) {
+      index.set(DIRECTORY, IndexModule.INDEX);
+    }
+    Path loc = IndexModule.indexDirectory(initFlags.cfg, site);
+    FileUtil.mkdirsOrDie(loc, "Cannot create index.directory");
+
     if ((site.isNew || isEmptySite()) && type.isLucene()) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
-        IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true);
+        IndexUtils.setReady(loc, def.getName(), def.getLatest().getVersion(), true);
       }
     } else {
       String message =
diff --git a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index 865f7d7..aaa236c 100644
--- a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -242,7 +242,8 @@
     }
 
     @Override
-    public void message(String fmt, Object... args) {
+    @FormatMethod
+    public void message(@FormatString String fmt, Object... args) {
       console.printf(fmt, args);
     }
   }
diff --git a/java/com/google/gerrit/pgm/util/LogFileManager.java b/java/com/google/gerrit/pgm/util/LogFileManager.java
index ca491bd..40314d6 100644
--- a/java/com/google/gerrit/pgm/util/LogFileManager.java
+++ b/java/com/google/gerrit/pgm/util/LogFileManager.java
@@ -134,7 +134,7 @@
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Error listing logs to compress in %s", logs_dir);
       }
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atSevere().withCause(e).log("Failed to process log files: %s", e.getMessage());
     }
     logger.atInfo().log("Log file maintenance has finished.");
diff --git a/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
index 37cf20e..3070ce8 100644
--- a/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
+++ b/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
@@ -82,7 +82,7 @@
       for (Runnable task : taskList) {
         try {
           task.run();
-        } catch (Exception err) {
+        } catch (RuntimeException err) {
           logger.atSevere().withCause(err).log("Cleanup task failed");
         }
       }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index a0343af..460d842 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -35,6 +35,9 @@
         exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC +
                   PROLOG_SRC,
     ),
+    # Pin AutoFactory to the javax inject API. See the "Bump auto-factory
+    # version to 1.1.0" change for why; the follow-up removes this pin.
+    javacopts = ["-Acom.google.auto.factory.InjectApi=javax"],
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
     visibility = ["//visibility:public"],
@@ -68,6 +71,7 @@
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
+        "//java/com/google/gerrit/util/crypto",
         "//java/org/apache/commons/net",
         "//lib:args4j",
         "//lib:autolink",
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 1ed962d..b904bae 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -109,6 +110,29 @@
     return tag.substring("mailMessageId=".length());
   }
 
+  public void ensureValidInReplyTo(ChangeNotes changeNotes, PatchSet.Id psId, String inReplyTo)
+      throws BadRequestException {
+    if (inReplyTo != null) {
+      HumanComment parent =
+          getPublishedHumanComment(changeNotes, inReplyTo)
+              .orElseThrow(
+                  () ->
+                      new BadRequestException(
+                          String.format("Invalid inReplyTo, comment %s not found", inReplyTo)));
+      if (parent.key.patchSetId != psId.get()) {
+        throw new BadRequestException(
+            "Invalid comment in_reply_to. Comment replies must be submitted on the same patchset as"
+                + " the parent comment. (in_reply_to: "
+                + inReplyTo
+                + ", in_reply_to_patchset: "
+                + parent.key.patchSetId
+                + ", comment_patchset: "
+                + psId.get()
+                + ")");
+      }
+    }
+  }
+
   private static final Ordering<Comparable<?>> NULLS_FIRST = Ordering.natural().nullsFirst();
 
   private final DiffOperations diffOperations;
@@ -137,15 +161,14 @@
       @Nullable String parentUuid,
       @Nullable List<FixSuggestion> fixSuggestions) {
     if (unresolved == null) {
-      if (parentUuid == null) {
-        // Default to false if comment is not descended from another.
-        unresolved = false;
-      } else {
-        // Inherit unresolved value from inReplyTo comment if not specified.
-        Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
-        Optional<HumanComment> parent = getPublishedHumanComment(changeNotes, key);
-        unresolved = parent.map(p -> p.unresolved).orElse(false);
-      }
+      // If the unresolved state is not specified, inherit it from the parent comment or default to
+      // false.
+      unresolved =
+          parentUuid != null
+              ? getPublishedHumanComment(changeNotes, parentUuid)
+                  .map(p -> p.unresolved)
+                  .orElse(false)
+              : false;
     }
     HumanComment c =
         new HumanComment(
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 1106883..764006f 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -190,6 +190,11 @@
     return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
   }
 
+  /** Returns the immutable {@link PropertyMap} containing properties attached to this user. */
+  public PropertyMap properties() {
+    return properties;
+  }
+
   /**
    * Checks if the current user has the same account id of another.
    *
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index abd67be..9ff59c6 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -263,7 +263,7 @@
   private final Provider<SocketAddress> remotePeerProvider;
   private final Account.Id accountId;
 
-  private AccountState state;
+  private volatile AccountState state;
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
@@ -293,10 +293,40 @@
         groupBackend,
         enablePeerIPInReflogRecord,
         remotePeerProvider,
-        state.account().id(),
+        state,
         realUser,
         PropertyMap.EMPTY,
         permissionMode);
+  }
+
+  private IdentifiedUser(
+      AuthConfig authConfig,
+      Realm realm,
+      String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean enablePeerIPInReflogRecord,
+      Provider<SocketAddress> remotePeerProvider,
+      AccountState state,
+      @Nullable CurrentUser realUser,
+      PropertyMap properties,
+      ImpersonationPermissionMode permissionMode) {
+    this(
+        authConfig,
+        realm,
+        anonymousCowardName,
+        refLogIdentityProvider,
+        canonicalUrl,
+        accountCache,
+        groupBackend,
+        enablePeerIPInReflogRecord,
+        remotePeerProvider,
+        state.account().id(),
+        realUser,
+        properties,
+        permissionMode);
     this.state = state;
   }
 
@@ -573,19 +603,30 @@
             throw e;
           };
     }
-    return new IdentifiedUser(
-        authConfig,
-        realm,
-        anonymousCowardName,
-        refLogIdentityProvider,
-        Providers.of(canonicalUrl.get()),
-        accountCache,
-        groupBackend,
-        enablePeerIPInReflogRecord,
-        remotePeer,
-        state,
-        realUser,
-        permissionMode);
+    // Note: Lazy-loaded caches (effectiveGroups, validEmails, invalidEmails) are intentionally
+    // not copied to prevent cross-thread reference leaks or data races on mutable collections;
+    // they are safely re-evaluated if needed by the background thread.
+    CurrentUser copyRealUser = (realUser == this) ? null : realUser;
+    if (copyRealUser != null && copyRealUser.isIdentifiedUser()) {
+      copyRealUser = ((IdentifiedUser) copyRealUser).materializedCopy();
+    }
+    IdentifiedUser copy =
+        new IdentifiedUser(
+            authConfig,
+            realm,
+            anonymousCowardName,
+            refLogIdentityProvider,
+            Providers.of(canonicalUrl.get()),
+            accountCache,
+            groupBackend,
+            enablePeerIPInReflogRecord,
+            remotePeer,
+            state(),
+            copyRealUser,
+            properties(),
+            permissionMode);
+    copy.setAccessPath(getAccessPath());
+    return copy;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/RequestCleanup.java b/java/com/google/gerrit/server/RequestCleanup.java
index 1d421ed..7a83c61 100644
--- a/java/com/google/gerrit/server/RequestCleanup.java
+++ b/java/com/google/gerrit/server/RequestCleanup.java
@@ -44,7 +44,7 @@
       for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
         try {
           i.next().run();
-        } catch (Exception err) {
+        } catch (RuntimeException err) {
           logger.atSevere().withCause(err).log("Failed to execute per-request cleanup");
         }
         i.remove();
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 48dd5e2..4d1b3f0 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -21,12 +21,14 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
 import com.google.gerrit.server.cache.CacheModule;
@@ -148,26 +150,42 @@
 
   @Override
   public ImmutableMap<Account.Id, AccountState> get(Collection<Account.Id> accountIds) {
+    if (accountIds.isEmpty()) {
+      return ImmutableMap.of();
+    }
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "Loading accounts", Metadata.builder().resourceCount(accountIds.size()).build())) {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
+        String[] refNames = new String[accountIds.size()];
+        int i = 0;
+        for (Account.Id id : accountIds) {
+          refNames[i++] = RefNames.refsUsers(id);
+        }
+        Map<String, Ref> refs = allUsers.getRefDatabase().exactRef(refNames);
         Set<CachedAccountDetails.Key> keys =
             Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
-          Ref userRef = allUsers.exactRef(RefNames.refsUsers(id));
-          if (userRef == null) {
+          Ref userRef = refs.get(RefNames.refsUsers(id));
+          if (userRef == null || userRef.getObjectId() == null) {
             continue;
           }
           keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
         }
+        if (keys.isEmpty()) {
+          return ImmutableMap.of();
+        }
         CachedPreferences defaultPreferences = defaultPreferenceCache.get();
-        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        ImmutableSetMultimap<Account.Id, ExternalId> extIdsByAccount = externalIds.allByAccount();
+        ImmutableMap.Builder<Account.Id, AccountState> result =
+            ImmutableMap.builderWithExpectedSize(keys.size());
         for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
             accountDetailsCache.getAll(keys).entrySet()) {
+          Account.Id id = account.getKey().accountId();
           result.put(
-              account.getKey().accountId(),
-              AccountState.forCachedAccount(account.getValue(), defaultPreferences, externalIds));
+              id,
+              AccountState.forCachedAccount(
+                  account.getValue(), defaultPreferences, extIdsByAccount.get(id)));
         }
         return result.build();
       }
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index 99552b9..ba2b72f 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -98,7 +98,7 @@
       }
       logger.atInfo().log(
           "Deactivations complete, %d account(s) were deactivated", numberOfAccountsDeactivated);
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atSevere().withCause(e).log(
           "Failed to complete deactivation of accounts: %s", e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 78375bb..fbf2d2c 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -61,6 +63,11 @@
     DELETED
   }
 
+  public void fillAccountInfo(Map<Account.Id, AccountInfo> in, Set<FillOptions> options)
+      throws PermissionBackendException {
+    fillAccountInfo(in.values(), options);
+  }
+
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
       throws PermissionBackendException;
 
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index 263e7b6..98e4679 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -99,6 +100,7 @@
    * specified in one of {@code get} or {@code put} call before the call to {@code fill}. Otherwise,
    * returns unfilled AccountInfo.
    */
+  @CanIgnoreReturnValue
   @Nullable
   public synchronized AccountInfo get(@Nullable Account.Id id) {
     if (id == null) {
@@ -124,18 +126,37 @@
    */
   @SuppressWarnings("ReferenceEquality") // Intentional reference equality check
   public void fill() throws PermissionBackendException {
+    if (primeAccountInfo.isEmpty() && provided.isEmpty()) {
+      return;
+    }
     try (TraceTimer timer = TraceContext.newTimer("Fill accounts", Metadata.empty())) {
+      List<DuplicateAccountInfo> duplicates = null;
       for (AccountInfo info : provided) {
-        primeAccountInfo.putIfAbsent(Account.id(info._accountId), info);
-      }
-      directory.fillAccountInfo(primeAccountInfo.values(), options);
-      for (AccountInfo info : provided) {
-        AccountInfo filledInfo = primeAccountInfo.get(Account.id(info._accountId));
-        // Check if it's the same instance.
-        if (filledInfo != info) {
-          filledInfo.copyTo(info);
+        Account.Id id = Account.id(info._accountId);
+        AccountInfo prime = primeAccountInfo.putIfAbsent(id, info);
+        if (prime != null && prime != info) {
+          if (duplicates == null) {
+            duplicates = new ArrayList<>();
+          }
+          duplicates.add(new DuplicateAccountInfo(prime, info));
         }
       }
+      directory.fillAccountInfo(primeAccountInfo, options);
+      if (duplicates != null) {
+        for (DuplicateAccountInfo dup : duplicates) {
+          dup.prime.copyTo(dup.target);
+        }
+      }
+    }
+  }
+
+  private static class DuplicateAccountInfo {
+    final AccountInfo prime;
+    final AccountInfo target;
+
+    DuplicateAccountInfo(AccountInfo prime, AccountInfo target) {
+      this.prime = prime;
+      this.target = target;
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index e2fd6af..d2ee098 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -73,7 +73,21 @@
   public static AccountState forCachedAccount(
       CachedAccountDetails account, CachedPreferences defaultConfig, ExternalIds externalIds)
       throws IOException {
-    ImmutableSet<ExternalId> extIds = externalIds.byAccount(account.account().id());
+    return forCachedAccount(account, defaultConfig, externalIds.byAccount(account.account().id()));
+  }
+
+  /**
+   * Creates an AccountState for a given account and pre-fetched external IDs.
+   *
+   * @param account the account
+   * @param defaultConfig default preferences
+   * @param extIds the external IDs
+   * @return the account state
+   */
+  public static AccountState forCachedAccount(
+      CachedAccountDetails account,
+      CachedPreferences defaultConfig,
+      ImmutableSet<ExternalId> extIds) {
     return new AccountState(
         account.account(),
         extIds,
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index 55192e9..ec781e0 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -14,11 +14,17 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import java.util.Random;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -62,6 +68,19 @@
   List<Account.Id> firstNIds(int n) throws IOException;
 
   /**
+   * Returns n random account IDs.
+   *
+   * @param n the number of account IDs that should be returned
+   * @param seed seed that should be used to randomize the order
+   * @return n random account IDs
+   */
+  default ImmutableList<Account.Id> randomNIds(int n, long seed) throws IOException {
+    List<Account.Id> allIds = new ArrayList<>(allIds());
+    Collections.shuffle(allIds, new Random(seed));
+    return allIds.stream().limit(n).collect(toImmutableList());
+  }
+
+  /**
    * Checks if any account exists.
    *
    * @return {@code true} if at least one account exists, otherwise {@code false}.
diff --git a/java/com/google/gerrit/server/account/AuthTokenExpiryNotifier.java b/java/com/google/gerrit/server/account/AuthTokenExpiryNotifier.java
index 99b2da4..986b7d0 100644
--- a/java/com/google/gerrit/server/account/AuthTokenExpiryNotifier.java
+++ b/java/com/google/gerrit/server/account/AuthTokenExpiryNotifier.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.account.storage.notedb.AccountsNoteDbImpl;
 import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.git.WorkQueue;
@@ -40,7 +39,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final long FIRST_NOTIFICATION_BEFORE_EXPIRY = 7L; // 7 days
 
-  private final AccountsNoteDbImpl accounts;
+  private final Accounts accounts;
   private final AuthTokenAccessor tokenAccessor;
   private final EmailFactories emailFactories;
 
@@ -81,7 +80,7 @@
 
   @Inject
   public AuthTokenExpiryNotifier(
-      AccountsNoteDbImpl accounts, AuthTokenAccessor tokenAccessor, EmailFactories emailFactories) {
+      Accounts accounts, AuthTokenAccessor tokenAccessor, EmailFactories emailFactories) {
     this.accounts = accounts;
     this.tokenAccessor = tokenAccessor;
     this.emailFactories = emailFactories;
@@ -102,18 +101,22 @@
                   now.plus(FIRST_NOTIFICATION_BEFORE_EXPIRY - 1, ChronoUnit.DAYS))) {
             logger.atInfo().log(
                 "Token %s for account %s is expiring soon.", token.id(), account.account().id());
-            emailFactories
-                .createOutgoingEmail(
-                    AUTH_TOKEN_WILL_EXPIRE,
-                    emailFactories.createAuthTokenWillExpireEmail(account.account(), token))
-                .send();
+            try {
+              emailFactories
+                  .createOutgoingEmail(
+                      AUTH_TOKEN_WILL_EXPIRE,
+                      emailFactories.createAuthTokenWillExpireEmail(account.account(), token))
+                  .send();
+            } catch (EmailException e) {
+              logger.atSevere().withCause(e).log(
+                  "Failed to send token expiry notification email for token %s of account %s",
+                  token.id(), account.account().id());
+            }
           }
         }
       }
     } catch (IOException | ConfigInvalidException e) {
       throw new RuntimeException("Failed to read accounts from NoteDB", e);
-    } catch (EmailException e) {
-      logger.atSevere().withCause(e).log("Failed to send token expiry notification email");
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index aed73de..82bb4f4 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
@@ -139,6 +140,9 @@
 
   @Override
   public Optional<InternalGroup> get(AccountGroup.Id groupId) {
+    if (groupId == null) {
+      return Optional.empty();
+    }
     try {
       return byId.get(groupId);
     } catch (ExecutionException e) {
@@ -176,12 +180,27 @@
 
   @Override
   public Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids) {
+    if (groupUuids == null || groupUuids.isEmpty()) {
+      return ImmutableMap.of();
+    }
+    if (groupUuids.size() == 1) {
+      AccountGroup.UUID singleUuid = Iterables.getOnlyElement(groupUuids);
+      return get(singleUuid)
+          .map(g -> (Map<AccountGroup.UUID, InternalGroup>) ImmutableMap.of(singleUuid, g))
+          .orElseGet(ImmutableMap::of);
+    }
     try {
       ImmutableSet<String> groupUuidsStringSet =
-          groupUuids.stream().map(u -> u.get()).collect(toImmutableSet());
-      return byUUID.getAll(groupUuidsStringSet).entrySet().stream()
-          .filter(g -> g.getValue().isPresent())
-          .collect(toImmutableMap(g -> AccountGroup.uuid(g.getKey()), g -> g.getValue().get()));
+          groupUuids.stream().map(AccountGroup.UUID::get).collect(toImmutableSet());
+      ImmutableMap<AccountGroup.UUID, InternalGroup> result =
+          byUUID.getAll(groupUuidsStringSet).entrySet().stream()
+              .filter(g -> g.getValue().isPresent())
+              .collect(toImmutableMap(g -> AccountGroup.uuid(g.getKey()), g -> g.getValue().get()));
+      for (InternalGroup group : result.values()) {
+        byId.asMap().putIfAbsent(group.getId(), Optional.of(group));
+        byName.asMap().putIfAbsent(group.getNameKey().get(), Optional.of(group));
+      }
+      return result;
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot look up groups %s by uuids", groupUuids);
       return ImmutableMap.of();
@@ -231,7 +250,8 @@
   public void evict(Collection<AccountGroup.UUID> groupUuids) {
     if (groupUuids != null && !groupUuids.isEmpty()) {
       logger.atFine().log("Evict groups %s by UUID", groupUuids);
-      byUUID.invalidateAll(groupUuids);
+      byUUID.invalidateAll(
+          groupUuids.stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 004a14b..ee44ca6 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -21,10 +21,10 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -227,9 +227,6 @@
 
   static class ParentGroupsLoader
       extends CacheLoader<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> {
-    // Be conservative with batching: We don't want to exhaust the number of
-    // results per page and maximum terms per query. Both are usually 1000+.
-    private static final int MAX_BATCH_SIZE = 100;
     private final RetryHelper retryHelper;
 
     @Inject
@@ -250,16 +247,13 @@
     public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> loadAll(
         Iterable<? extends AccountGroup.UUID> keys) {
       int numKeys = Iterables.size(keys);
-      Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> result =
-          Maps.newHashMapWithExpectedSize(numKeys);
+      if (numKeys == 0) {
+        return ImmutableMap.of();
+      }
       try (TraceTimer timer = TraceContext.newTimer("Loading " + numKeys + " parent groups")) {
-        Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups =
-            retryHelper
-                .groupIndexQuery("loadParentGroups", q -> q.bySubgroups(ImmutableSet.copyOf(keys)))
-                .call();
-        Iterables.partition(keys, MAX_BATCH_SIZE)
-            .forEach(keyPartition -> result.putAll(bySubgroups));
-        return result;
+        return retryHelper
+            .groupIndexQuery("loadParentGroups", q -> q.bySubgroups(ImmutableSet.copyOf(keys)))
+            .call();
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupUuid.java b/java/com/google/gerrit/server/account/GroupUuid.java
index 652420d..d4384d8 100644
--- a/java/com/google/gerrit/server/account/GroupUuid.java
+++ b/java/com/google/gerrit/server/account/GroupUuid.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.util.crypto.SecureRandomUtil;
 import java.security.MessageDigest;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -25,7 +26,7 @@
     MessageDigest md = Constants.newMessageDigest();
     md.update(Constants.encode("group " + groupName + "\n"));
     md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
-    md.update(Constants.encode(String.valueOf(Math.random())));
+    md.update(SecureRandomUtil.newBytes(16));
     return AccountGroup.uuid(ObjectId.fromRaw(md.digest()).name());
   }
 
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 6d5c66b..45214a6 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.collect.Streams.stream;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
 import static java.util.stream.Stream.concat;
 
 import com.google.common.base.Strings;
@@ -45,6 +44,7 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -90,9 +90,9 @@
   }
 
   @Override
-  public void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
+  public void fillAccountInfo(Map<Account.Id, AccountInfo> in, Set<FillOptions> options)
       throws PermissionBackendException {
-    if (options.equals(ID_ONLY)) {
+    if (in.isEmpty() || options.equals(ID_ONLY)) {
       return;
     }
 
@@ -106,20 +106,90 @@
     }
 
     Set<FillOptions> fillOptionsWithoutSecondaryEmails =
-        Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS));
-    Set<Account.Id> ids = stream(in).map(a -> Account.id(a._accountId)).collect(toSet());
+        options.contains(FillOptions.SECONDARY_EMAILS)
+            ? Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS))
+            : options;
+    ImmutableMap<Account.Id, AccountState> accountStates = accountCache.get(in.keySet());
+    for (Map.Entry<Account.Id, AccountInfo> entry : in.entrySet()) {
+      Account.Id id = entry.getKey();
+      AccountInfo info = entry.getValue();
+      AccountState state = accountStates.get(id);
+      if (state != null) {
+        if (!options.contains(FillOptions.SECONDARY_EMAILS)
+            || Objects.equals(currentUserId, state.account().id())
+            || canViewSecondaryEmails) {
+          fill(info, state, options);
+        } else {
+          // user is not allowed to see secondary emails
+          fill(info, state, fillOptionsWithoutSecondaryEmails);
+        }
+      } else {
+        info._accountId = options.contains(FillOptions.ID) ? id.get() : null;
+        info.deleted = options.contains(FillOptions.DELETED) ? true : null;
+      }
+    }
+  }
+
+  @Override
+  public void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
+      throws PermissionBackendException {
+    if (options.equals(ID_ONLY)) {
+      return;
+    }
+    if (in instanceof Collection && ((Collection<?>) in).isEmpty()) {
+      return;
+    }
+
+    boolean canViewSecondaryEmails = false;
+    Account.Id currentUserId = null;
+    if (self.get().isIdentifiedUser()) {
+      currentUserId = self.get().getAccountId();
+      if (permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+        canViewSecondaryEmails = true;
+      }
+    }
+
+    Set<FillOptions> fillOptionsWithoutSecondaryEmails =
+        options.contains(FillOptions.SECONDARY_EMAILS)
+            ? Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS))
+            : options;
+
+    Set<Account.Id> ids;
+    if (in instanceof Collection) {
+      Collection<? extends AccountInfo> infos = (Collection<? extends AccountInfo>) in;
+      ids = Sets.newHashSetWithExpectedSize(infos.size());
+      for (AccountInfo a : infos) {
+        if (a._accountId != null) {
+          ids.add(Account.id(a._accountId));
+        }
+      }
+    } else {
+      ids = Sets.newHashSet();
+      for (AccountInfo a : in) {
+        if (a._accountId != null) {
+          ids.add(Account.id(a._accountId));
+        }
+      }
+    }
+    if (ids.isEmpty()) {
+      return;
+    }
+
     ImmutableMap<Account.Id, AccountState> accountStates = accountCache.get(ids);
     for (AccountInfo info : in) {
+      if (info._accountId == null) {
+        continue;
+      }
       Account.Id id = Account.id(info._accountId);
       AccountState state = accountStates.get(id);
       if (state != null) {
         if (!options.contains(FillOptions.SECONDARY_EMAILS)
             || Objects.equals(currentUserId, state.account().id())
             || canViewSecondaryEmails) {
-          fill(info, accountStates.get(id), options);
+          fill(info, state, options);
         } else {
           // user is not allowed to see secondary emails
-          fill(info, accountStates.get(id), fillOptionsWithoutSecondaryEmails);
+          fill(info, state, fillOptionsWithoutSecondaryEmails);
         }
 
       } else {
@@ -131,9 +201,34 @@
 
   @Override
   public void fillAccountAttributeInfo(Iterable<? extends AccountAttribute> in) {
-    Set<Account.Id> ids = stream(in).map(a -> Account.id(a.accountId)).collect(toSet());
+    if (in instanceof Collection && ((Collection<?>) in).isEmpty()) {
+      return;
+    }
+    Set<Account.Id> ids;
+    if (in instanceof Collection) {
+      Collection<? extends AccountAttribute> attrs = (Collection<? extends AccountAttribute>) in;
+      ids = Sets.newHashSetWithExpectedSize(attrs.size());
+      for (AccountAttribute a : attrs) {
+        if (a.accountId != null) {
+          ids.add(Account.id(a.accountId));
+        }
+      }
+    } else {
+      ids = Sets.newHashSet();
+      for (AccountAttribute a : in) {
+        if (a.accountId != null) {
+          ids.add(Account.id(a.accountId));
+        }
+      }
+    }
+    if (ids.isEmpty()) {
+      return;
+    }
     ImmutableMap<Account.Id, AccountState> accountStates = accountCache.get(ids);
     for (AccountAttribute accountAttribute : in) {
+      if (accountAttribute.accountId == null) {
+        continue;
+      }
       Account.Id id = Account.id(accountAttribute.accountId);
       AccountState accountState = accountStates.get(id);
       if (accountState != null) {
diff --git a/java/com/google/gerrit/server/account/MaxAuthTokenLifetimeApplier.java b/java/com/google/gerrit/server/account/MaxAuthTokenLifetimeApplier.java
index 4c813d1..222ae75 100644
--- a/java/com/google/gerrit/server/account/MaxAuthTokenLifetimeApplier.java
+++ b/java/com/google/gerrit/server/account/MaxAuthTokenLifetimeApplier.java
@@ -18,13 +18,14 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.account.storage.notedb.AccountsNoteDbImpl;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.git.MultiProgressMonitor.TaskKind;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.project.CoreLockKeys;
 import com.google.gerrit.server.project.LockManager;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -43,8 +44,8 @@
 
   private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
   private final AuthTokenAccessor tokenAccessor;
-  private final LockManager lockManager;
-  private final AccountsNoteDbImpl accounts;
+  private final DynamicItem<LockManager> lockManager;
+  private final Accounts accounts;
   private final Instant expiryInstant;
 
   private MultiProgressMonitor mpm;
@@ -59,8 +60,8 @@
   public MaxAuthTokenLifetimeApplier(
       MultiProgressMonitor.Factory multiProgressMonitorFactory,
       AuthTokenAccessor tokenAccessor,
-      LockManager lockManager,
-      AccountsNoteDbImpl accounts,
+      DynamicItem<LockManager> lockManager,
+      Accounts accounts,
       @Assisted Instant expiryInstant) {
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.tokenAccessor = tokenAccessor;
@@ -71,7 +72,7 @@
 
   @Override
   public void run() {
-    Lock lock = lockManager.getLock("ReduceMaxAuthTokenLifetime");
+    Lock lock = lockManager.get().getLock(CoreLockKeys.REDUCE_MAX_AUTH_TOKEN_LIFETIME);
     if (!lock.tryLock()) {
       logger.atWarning().log("Task applying limit to auth token lifetime already running.");
       return;
diff --git a/java/com/google/gerrit/server/account/PasswordMigrator.java b/java/com/google/gerrit/server/account/PasswordMigrator.java
index 38c3df5..892e937 100644
--- a/java/com/google/gerrit/server/account/PasswordMigrator.java
+++ b/java/com/google/gerrit/server/account/PasswordMigrator.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
@@ -35,6 +36,7 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.project.CoreLockKeys;
 import com.google.gerrit.server.project.LockManager;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -65,7 +67,7 @@
   private final AllUsersName allUsers;
   private final ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
   private final Optional<Instant> expirationDate;
-  private final LockManager lockManager;
+  private final DynamicItem<LockManager> lockManager;
 
   private MultiProgressMonitor mpm;
   private Task doneTask;
@@ -86,7 +88,7 @@
       ExternalIdNotes.FactoryNoReindex externalIdNotesFactory,
       Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
       @Assisted Optional<Instant> expirationDate,
-      LockManager lockManager) {
+      DynamicItem<LockManager> lockManager) {
     this.repoManager = repoManager;
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.tokenAccessor = tokenAccessor;
@@ -101,7 +103,7 @@
 
   @Override
   public void run() {
-    Lock lock = lockManager.getLock("MigratePasswordsToTokens");
+    Lock lock = lockManager.get().getLock(CoreLockKeys.MIGRATE_PASSWORDS_TO_TOKENS);
     if (!lock.tryLock()) {
       logger.atWarning().log("Migration of passwords to tokens already running.");
       return;
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index a05baf5..cfe7857 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
 import com.google.inject.Singleton;
@@ -31,7 +32,6 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
-import javax.inject.Inject;
 
 /**
  * An implementation of {@link ServiceUserClassifier} that will consider a user to be a robot if
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
index 560a1c7..1d11bb9 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.ImplementedBy;
-import javax.inject.Inject;
-import javax.inject.Singleton;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 
 @Singleton
 public class ExternalIdKeyFactory {
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
index aa321e7..cb3e894 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
@@ -28,9 +28,9 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
index 083f993..0384324 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
@@ -23,8 +23,15 @@
 import com.google.gerrit.server.account.storage.notedb.validators.ExternalIdUpdateValidator;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndexRewriter;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.index.account.AccountIndexerImpl;
 import com.google.gerrit.server.index.account.ReindexAccountsAfterRefUpdate;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 /** Module that binds {@link AccountsUpdate} */
 public class AccountNoteDbWriteStorageModule extends AbstractModule {
@@ -44,5 +51,16 @@
     DynamicSet.bind(binder(), CommitValidationListener.class).to(AccountCommitValidator.class);
     DynamicSet.bind(binder(), CommitValidationListener.class).to(ExternalIdUpdateValidator.class);
     DynamicSet.bind(binder(), MergeValidationListener.class).to(AccountMergeValidator.class);
+
+    // Indexing
+    install(new FactoryModuleBuilder().build(AccountIndexerImpl.Factory.class));
+    bind(AccountIndexRewriter.class);
+  }
+
+  @Provides
+  @Singleton
+  AccountIndexer getAccountIndexer(
+      AccountIndexerImpl.Factory factory, AccountIndexCollection indexes) {
+    return factory.create(indexes);
   }
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
index 28d3a43..b0c35be 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.config.VersionedDefaultPreferences;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -46,11 +47,11 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Stream;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
+/** NoteDb-based implementation of {@link Accounts}. */
 @Singleton
 public class AccountsNoteDbImpl implements Accounts {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index 663eddd..1820e70 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -57,7 +57,9 @@
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryableAction.Action;
 import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -67,8 +69,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index f5798fc..c22d087 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -277,7 +277,7 @@
     try {
       Response<String> result = getActive.apply(account);
       return result.statusCode() == SC_OK && result.value().equals("ok");
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get active", e);
     }
   }
@@ -466,7 +466,7 @@
   public EmailApi email(String email) throws RestApiException {
     try {
       return emailApi.create(account, email);
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot parse email", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 060a8a6..8714f27 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -561,7 +561,7 @@
   public String topic() throws RestApiException {
     try {
       return getTopic.apply(change).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get topic", e);
     }
   }
@@ -727,7 +727,7 @@
   public ValidationOptionInfos getValidationOptions() throws RestApiException {
     try {
       return getValidationOptions.apply(change).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get validation options", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index bdabcbd..8b1b4c2 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -101,7 +101,7 @@
           return ChangeEditApiImpl.this.get(this);
         }
       };
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot retrieve change edit", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
index 490ec5b..acfce25 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeMessageApiImpl.java
@@ -49,7 +49,7 @@
   public ChangeMessageInfo get() throws RestApiException {
     try {
       return getChangeMessage.apply(changeMessageResource).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot retrieve change message", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index 6aa2cf1..52b7835 100644
--- a/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -116,7 +116,7 @@
         @SuppressWarnings("unused")
         var unused = deleteReviewed.apply(file, new Input());
       }
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException(String.format("Cannot set %sreviewed", reviewed ? "" : "un"), e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/changes/FlowApiImpl.java b/java/com/google/gerrit/server/api/changes/FlowApiImpl.java
index 7e7a616..16433c3 100644
--- a/java/com/google/gerrit/server/api/changes/FlowApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/FlowApiImpl.java
@@ -46,7 +46,7 @@
   public FlowInfo get() throws RestApiException {
     try {
       return getFlow.apply(flowResource).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get flow", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 01b14f2..b4f8c98 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -568,7 +568,7 @@
   public Map<String, ActionInfo> actions() throws RestApiException {
     try {
       return revisionActions.apply(revision).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get actions", e);
     }
   }
@@ -678,7 +678,7 @@
   public String description() throws RestApiException {
     try {
       return getDescription.apply(revision).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get description", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/config/CachesApiImpl.java b/java/com/google/gerrit/server/api/config/CachesApiImpl.java
index c0a54e6..a525508 100644
--- a/java/com/google/gerrit/server/api/config/CachesApiImpl.java
+++ b/java/com/google/gerrit/server/api/config/CachesApiImpl.java
@@ -46,7 +46,7 @@
   public CacheInfo get() throws RestApiException {
     try {
       return getCache.apply(cache).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get cache", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java b/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java
index 7eacf20..5475cc5 100644
--- a/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java
+++ b/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java
@@ -42,7 +42,7 @@
   public ExperimentInfo get() throws RestApiException {
     try {
       return getExperiment.apply(experiment).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get experiment", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
index 22b9c71..a962834 100644
--- a/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -199,7 +199,7 @@
   public List<TopMenu.MenuEntry> topMenus() throws RestApiException {
     try {
       return listTopMenus.apply(new ConfigResource()).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get top menus", e);
     }
   }
@@ -285,7 +285,7 @@
     try {
       ListCaches listCaches = listCachesProvider.get();
       return (Map<String, CacheInfo>) listCaches.apply(new ConfigResource()).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot retrieve caches", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index c7a2c69..872aefe 100644
--- a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -143,7 +143,7 @@
   public String name() throws RestApiException {
     try {
       return getName.apply(rsrc).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get group name", e);
     }
   }
@@ -217,7 +217,7 @@
   public GroupOptionsInfo options() throws RestApiException {
     try {
       return getOptions.apply(rsrc).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get group options", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
index 347094f..34fb356 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -57,7 +57,7 @@
   public PluginInfo get() throws RestApiException {
     try {
       return getStatus.apply(resource).value();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw asRestApiException("Cannot get status", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 6af6dfd..8dc6d06 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.common.ValidationOptionInfos;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.BranchesCollection;
 import com.google.gerrit.server.restapi.project.CreateBranch;
+import com.google.gerrit.server.restapi.project.CreateCommit;
 import com.google.gerrit.server.restapi.project.DeleteBranch;
 import com.google.gerrit.server.restapi.project.FilesCollection;
 import com.google.gerrit.server.restapi.project.GetBranch;
@@ -57,6 +60,7 @@
   private final FilesCollection filesCollection;
   private final GetBranch getBranch;
   private final GetContent getContent;
+  private final CreateCommit createCommit;
   private final GetReflog getReflog;
   private final String ref;
   private final ProjectResource project;
@@ -72,6 +76,7 @@
       FilesCollection filesCollection,
       GetBranch getBranch,
       GetContent getContent,
+      CreateCommit createCommit,
       GetReflog getReflog,
       GetBranchValidationOptions getBranchValidationOptions,
       SuggestBranchReviewers suggestReviewers,
@@ -84,6 +89,7 @@
     this.getBranchValidationOptions = getBranchValidationOptions;
     this.getBranch = getBranch;
     this.getContent = getContent;
+    this.createCommit = createCommit;
     this.getReflog = getReflog;
     this.project = project;
     this.suggestReviewers = suggestReviewers;
@@ -163,6 +169,15 @@
   }
 
   @Override
+  public CommitInfo createCommit(CreateCommitInput input) throws RestApiException {
+    try {
+      return createCommit.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot commit files", e);
+    }
+  }
+
+  @Override
   public List<ReflogEntryInfo> reflog() throws RestApiException {
     try {
       return getReflog.apply(resource()).value();
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
index e055a00..ee14164 100644
--- a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -29,7 +29,9 @@
 import com.google.gerrit.server.restapi.project.CommitIncludedIn;
 import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
 import com.google.gerrit.server.restapi.project.GetCommit;
+import com.google.gerrit.server.restapi.project.ListDiffFiles;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Map;
 
@@ -44,6 +46,7 @@
   private final CommitIncludedIn includedIn;
   private final CommitResource commitResource;
   private final FilesInCommitCollection.ListFiles listFiles;
+  private final Provider<ListDiffFiles> listDiffFiles;
 
   @Inject
   CommitApiImpl(
@@ -52,12 +55,14 @@
       CherryPickCommit cherryPickCommit,
       CommitIncludedIn includedIn,
       FilesInCommitCollection.ListFiles listFiles,
+      Provider<ListDiffFiles> listDiffFiles,
       @Assisted CommitResource commitResource) {
     this.changes = changes;
     this.getCommit = getCommit;
     this.cherryPickCommit = cherryPickCommit;
     this.includedIn = includedIn;
     this.listFiles = listFiles;
+    this.listDiffFiles = listDiffFiles;
     this.commitResource = commitResource;
   }
 
@@ -96,4 +101,13 @@
       throw asRestApiException("Cannot retrieve files", e);
     }
   }
+
+  @Override
+  public Map<String, FileInfo> diffFiles(String base, boolean nameOnly) throws RestApiException {
+    try {
+      return listDiffFiles.get().setBase(base).setNameOnly(nameOnly).apply(commitResource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff files", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index ea9d09e..5319195 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -52,6 +52,8 @@
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -63,8 +65,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.FileResource;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.Check;
@@ -80,6 +84,7 @@
 import com.google.gerrit.server.restapi.project.GetAccess;
 import com.google.gerrit.server.restapi.project.GetConfig;
 import com.google.gerrit.server.restapi.project.GetDescription;
+import com.google.gerrit.server.restapi.project.GetDiffFile;
 import com.google.gerrit.server.restapi.project.GetHead;
 import com.google.gerrit.server.restapi.project.GetParent;
 import com.google.gerrit.server.restapi.project.Index;
@@ -109,6 +114,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class ProjectApiImpl implements ProjectApi {
   interface Factory {
@@ -163,6 +169,8 @@
   private final PostSubmitRequirementsReview postSubmitRequirementsReview;
   private final LabelApiImpl.Factory labelApi;
   private final SubmitRequirementApiImpl.Factory submitRequirementApi;
+  private final GitRepositoryManager repoManager;
+  private final Provider<GetDiffFile> getDiffFile;
 
   @AssistedInject
   ProjectApiImpl(
@@ -170,6 +178,7 @@
       CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
+      GitRepositoryManager repoManager,
       GetDescription getDescription,
       PutDescription putDescription,
       ChildProjectApiImpl.Factory childApi,
@@ -209,12 +218,14 @@
       SubmitRequirementApiImpl.Factory submitRequirementApi,
       PostSubmitRequirements postSubmitRequirements,
       PostSubmitRequirementsReview postSubmitRequirementsReview,
+      Provider<GetDiffFile> getDiffFile,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
         createProject,
         projectApi,
         projects,
+        repoManager,
         getDescription,
         putDescription,
         childApi,
@@ -255,6 +266,7 @@
         submitRequirementApi,
         postSubmitRequirements,
         postSubmitRequirementsReview,
+        getDiffFile,
         null);
   }
 
@@ -264,6 +276,7 @@
       CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
+      GitRepositoryManager repoManager,
       GetDescription getDescription,
       PutDescription putDescription,
       ChildProjectApiImpl.Factory childApi,
@@ -303,12 +316,14 @@
       SubmitRequirementApiImpl.Factory submitRequirementApi,
       PostSubmitRequirements postSubmitRequirements,
       PostSubmitRequirementsReview postSubmitRequirementsReview,
+      Provider<GetDiffFile> getDiffFile,
       @Assisted String name) {
     this(
         permissionBackend,
         createProject,
         projectApi,
         projects,
+        repoManager,
         getDescription,
         putDescription,
         childApi,
@@ -349,6 +364,7 @@
         submitRequirementApi,
         postSubmitRequirements,
         postSubmitRequirementsReview,
+        getDiffFile,
         name);
   }
 
@@ -357,6 +373,7 @@
       CreateProject createProject,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
+      GitRepositoryManager repoManager,
       GetDescription getDescription,
       PutDescription putDescription,
       ChildProjectApiImpl.Factory childApi,
@@ -397,11 +414,13 @@
       SubmitRequirementApiImpl.Factory submitRequirementApi,
       PostSubmitRequirements postSubmitRequirements,
       PostSubmitRequirementsReview postSubmitRequirementsReview,
+      Provider<GetDiffFile> getDiffFile,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
     this.projectApi = projectApi;
     this.projects = projects;
+    this.repoManager = repoManager;
     this.getDescription = getDescription;
     this.putDescription = putDescription;
     this.childApi = childApi;
@@ -443,6 +462,7 @@
     this.submitRequirementApi = submitRequirementApi;
     this.postSubmitRequirements = postSubmitRequirements;
     this.postSubmitRequirementsReview = postSubmitRequirementsReview;
+    this.getDiffFile = getDiffFile;
   }
 
   @Override
@@ -584,6 +604,28 @@
   }
 
   @Override
+  public Map<String, FileInfo> diffFiles(String oldCommit, String newCommit, boolean nameOnly)
+      throws RestApiException {
+    if (newCommit == null) {
+      throw new BadRequestException("new commit SHA1 is required");
+    }
+    return commit(newCommit).diffFiles(oldCommit, nameOnly);
+  }
+
+  @Override
+  public DiffInfo diffFile(String oldCommit, String newCommit, String path)
+      throws RestApiException {
+    try {
+      FileResource resource =
+          FileResource.create(
+              repoManager, checkExists().getProjectState(), ObjectId.fromString(newCommit), path);
+      return getDiffFile.get().setBase(oldCommit).apply(resource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get diff file", e);
+    }
+  }
+
+  @Override
   public ListRefsRequest<BranchInfo> branches() {
     return new ListRefsRequest<>() {
       @Override
diff --git a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
index afa0384..c0977db 100644
--- a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
+++ b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.gerrit.util.crypto.SecureRandomUtil;
 import com.google.inject.Singleton;
 import java.security.MessageDigest;
 import java.time.Instant;
@@ -40,7 +41,7 @@
     md.update(Constants.encode("label " + label + "\n"));
     md.update(Constants.encode("value " + value + "\n"));
     md.update(Constants.encode("granted " + granted.toEpochMilli() + "\n"));
-    md.update(Constants.encode(String.valueOf(Math.random())));
+    md.update(SecureRandomUtil.newBytes(16));
     return PatchSetApproval.uuid(ObjectId.fromRaw(md.digest()).name());
   }
 }
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
index 676640d..e1caf2f 100644
--- a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetApproval.UUID;
 import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.inject.Singleton;
 import java.time.Instant;
 import java.util.Locale;
-import javax.inject.Singleton;
 
 /**
  * Implementation of {@link PatchSetApprovalUuidGenerator} that returns predictable {@link UUID}.
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenRevokedListener.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenRevokedListener.java
new file mode 100644
index 0000000..6c0db8c
--- /dev/null
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenRevokedListener.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Notified when an account's OAuth token is revoked and evicted (single account), or when all
+ * tokens are revoked in bulk, so a plugin holding token-derived state (e.g. a Git-over-HTTP
+ * validation cache) can invalidate it.
+ *
+ * <p>A single-account revocation fires {@link #onTokenRevoked} once, after the token is revoked at
+ * the IdP (RFC 7009) and evicted from the {@code oauth_tokens} cache. A bulk {@code revoke --all}
+ * fires {@link #onAllTokensRevoked} instead (possibly more than once, before and after the revoke
+ * loop). The two are never mixed for one operation.
+ */
+@ExtensionPoint
+public interface OAuthTokenRevokedListener {
+  /**
+   * Called after {@code accountId}'s OAuth token was revoked and evicted.
+   *
+   * @param accountId the affected account
+   */
+  void onTokenRevoked(Account.Id accountId);
+
+  /**
+   * Called for a bulk revocation ({@code revoke --all}) instead of per account. May fire more than
+   * once per operation (before and after the revoke loop) and even when nothing was cached, so
+   * implementations must be idempotent. Default: no-op.
+   */
+  default void onAllTokensRevoked() {}
+}
diff --git a/java/com/google/gerrit/server/cache/CacheInfoFactory.java b/java/com/google/gerrit/server/cache/CacheInfoFactory.java
index dafa186..48122db 100644
--- a/java/com/google/gerrit/server/cache/CacheInfoFactory.java
+++ b/java/com/google/gerrit/server/cache/CacheInfoFactory.java
@@ -24,11 +24,11 @@
 
 public class CacheInfoFactory {
 
-  public static CacheInfo create(Cache<?, ?> cache) {
-    return create(null, cache);
+  public static CacheInfo create(Cache<?, ?> cache, boolean includeDiskStats) {
+    return create(null, cache, includeDiskStats);
   }
 
-  public static CacheInfo create(String name, Cache<?, ?> cache) {
+  public static CacheInfo create(String name, Cache<?, ?> cache, boolean includeDiskStats) {
     CacheInfo cacheInfo = new CacheInfo();
     cacheInfo.name = name;
 
@@ -44,10 +44,12 @@
 
     if (cache instanceof PersistentCache) {
       cacheInfo.type = CacheType.DISK;
-      PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
-      cacheInfo.entries.setDisk(diskStats.size());
-      cacheInfo.entries.setSpace(diskStats.space());
-      cacheInfo.hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
+      if (includeDiskStats) {
+        PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
+        cacheInfo.entries.setDisk(diskStats.size());
+        cacheInfo.entries.setSpace(diskStats.space());
+        cacheInfo.hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
+      }
     } else {
       cacheInfo.type = CacheType.MEM;
     }
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index 7053df0..e6dd0a7 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -59,6 +59,12 @@
             Long.class,
             new Description("Memory eviction count").setGauge().setUnit("evicted entries"),
             F_NAME);
+    CallbackMetric1<String, Long> memReq =
+        metrics.newCallbackMetric(
+            "caches/memory_request_count",
+            Long.class,
+            new Description("Memory request count").setGauge().setUnit("requests"),
+            F_NAME);
     CallbackMetric1<String, Long> perDiskEnt =
         metrics.newCallbackMetric(
             "caches/disk_cached",
@@ -79,9 +85,18 @@
                 .setGauge()
                 .setUnit("invalidated entries"),
             F_NAME);
+    CallbackMetric1<String, Long> perDiskReq =
+        metrics.newCallbackMetric(
+            "caches/disk_request_count",
+            Long.class,
+            new Description("Disk request count for persistent cache")
+                .setGauge()
+                .setUnit("requests"),
+            F_NAME);
 
     ImmutableSet<CallbackMetric<?>> cacheMetrics =
-        ImmutableSet.of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit, perDiskInvalid);
+        ImmutableSet.of(
+            memEnt, memHit, memEvict, memReq, perDiskEnt, perDiskHit, perDiskInvalid, perDiskReq);
 
     metrics.newTrigger(
         cacheMetrics,
@@ -93,12 +108,14 @@
             memEnt.set(name, c.size());
             memHit.set(name, cstats.hitRate() * 100);
             memEvict.set(name, cstats.evictionCount());
+            memReq.set(name, cstats.requestCount());
             if (c instanceof PersistentCache
                 && config.getBoolean("cache", "enableDiskStatMetrics", false)) {
               PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
               perDiskEnt.set(name, d.size());
               perDiskHit.set(name, hitRatio(d));
               perDiskInvalid.set(name, d.invalidatedCount());
+              perDiskReq.set(name, d.requestCount());
             }
           }
           cacheMetrics.forEach(CallbackMetric::prune);
diff --git a/java/com/google/gerrit/server/cache/h2/CacheStoreStartupExecutor.java b/java/com/google/gerrit/server/cache/h2/CacheStoreStartupExecutor.java
new file mode 100644
index 0000000..903173c
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/h2/CacheStoreStartupExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.h2;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CacheStoreStartupExecutor {}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index ab4c852..000b03d 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -87,6 +87,8 @@
   private final boolean pruneOnStartup;
   private final Schedule schedule;
   private final AtomicBoolean isDiskCacheReadOnly;
+  @Nullable private final ExecutorService startupExecutor;
+  private final boolean preWarmForBloomFilter;
 
   @Inject
   H2CacheFactory(
@@ -95,6 +97,7 @@
       DynamicMap<Cache<?, ?>> cacheMap,
       @Nullable @CacheCleanupExecutor ScheduledExecutorService cleanupExecutor,
       @Nullable @CacheStoreExecutor ExecutorService storeExecutor,
+      @Nullable @CacheStoreStartupExecutor ExecutorService startupExecutor,
       @Nullable @CacheDir Path cacheDir,
       Set<CacheOptions> options,
       @Named("DiskCacheReadOnly") AtomicBoolean isDiskCacheReadOnly) {
@@ -102,6 +105,7 @@
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
     pruneOnStartup = cfg.getBoolean("cachePruning", null, "pruneOnStartup", true);
+    preWarmForBloomFilter = cfg.getBoolean("cache", null, "preWarmForBloomFilter", true);
     caches = new ArrayList<>();
     schedule =
         ScheduleConfig.createSchedule(cfg, "cachePruning")
@@ -112,13 +116,15 @@
     this.cleanup = cleanupExecutor;
     this.options = options;
     this.isDiskCacheReadOnly = isDiskCacheReadOnly;
+    this.startupExecutor = startupExecutor;
   }
 
   @Override
   public void start() {
     if (executor != null) {
+      ExecutorService se = startupExecutor != null ? startupExecutor : executor;
       for (H2CacheImpl<?, ?> cache : caches) {
-        executor.execute(cache::start);
+        se.execute(cache::start);
         if (cleanup != null) {
           if (pruneOnStartup) {
             @SuppressWarnings("unused")
@@ -135,6 +141,9 @@
                   TimeUnit.MILLISECONDS);
         }
       }
+      if (se != executor) {
+        se.shutdown();
+      }
     }
   }
 
@@ -142,6 +151,10 @@
   public void stop() {
     if (executor != null) {
       try {
+        if (startupExecutor != null) {
+          startupExecutor.shutdownNow();
+        }
+
         if (cleanup != null) {
           cleanup.shutdownNow();
         }
@@ -213,9 +226,10 @@
   }
 
   private <V, K> SqlStore<K, V> newSqlStore(PersistentCacheDef<K, V> def, long maxSize) {
+    String cacheName = def.name() + "-v" + COMPATIBILITY_VERSION;
     StringBuilder url = new StringBuilder();
-    url.append("jdbc:h2:")
-        .append(cacheDir.resolve(def.name() + "-v" + COMPATIBILITY_VERSION).toUri());
+    url.append("jdbc:h2:file:")
+        .append(cacheDir.resolve(cacheName).toAbsolutePath().toString().replace(";", "\\;"));
     if (h2CacheSize >= 0) {
       url.append(";CACHE_SIZE=");
       // H2 CACHE_SIZE is always given in KB
@@ -253,7 +267,9 @@
         refreshAfterWrite,
         options.contains(CacheOptions.BUILD_BLOOM_FILTER),
         options.contains(CacheOptions.TRACK_LAST_ACCESS),
-        isDiskCacheReadOnly);
+        isDiskCacheReadOnly,
+        preWarmForBloomFilter,
+        cacheDir.resolve(cacheName + ".mv.db"));
   }
 
   private boolean has(String name, String var) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 39afb38..d4a6052 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.cache.h2;
 
+import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
 import com.google.common.cache.AbstractLoadingCache;
 import com.google.common.cache.Cache;
@@ -37,7 +38,10 @@
 import com.google.gerrit.util.concurrent.ConcurrentBloomFilter;
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.InvalidClassException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
@@ -208,7 +212,9 @@
   }
 
   void start() {
+    logger.atFine().log("Opening disk cache %s...", cacheName);
     store.open();
+    logger.atFine().log("Finished opening disk cache %s...", cacheName);
   }
 
   void stop() {
@@ -346,6 +352,8 @@
     private boolean trackLastAccess;
     private final AtomicBoolean isDiskCacheReadOnly;
     private volatile boolean ensuredSchemaCreation;
+    private final Path cacheFilePath;
+    private final boolean preWarmForBloomFilter;
 
     SqlStore(
         String jdbcUrl,
@@ -359,7 +367,9 @@
         @Nullable Duration refreshAfterWrite,
         boolean buildBloomFilter,
         boolean trackLastAccess,
-        AtomicBoolean isDiskCacheReadOnly) {
+        AtomicBoolean isDiskCacheReadOnly,
+        boolean preWarmForBloomFilter,
+        Path cacheFilePath) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
@@ -370,6 +380,8 @@
       this.buildBloomFilter = buildBloomFilter;
       this.trackLastAccess = trackLastAccess;
       this.isDiskCacheReadOnly = isDiskCacheReadOnly;
+      this.cacheFilePath = cacheFilePath;
+      this.preWarmForBloomFilter = preWarmForBloomFilter;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -422,6 +434,24 @@
       }
     }
 
+    void warmupOsPageCache() {
+      // null check because tests use in-memory h2.
+      if (cacheFilePath == null || !Files.exists(cacheFilePath)) {
+        return;
+      }
+      logger.atFine().log("Warming OS page cache for %s", cacheFilePath.getFileName());
+      Stopwatch sw = Stopwatch.createStarted();
+      byte[] buf = new byte[65536];
+      try (InputStream in = Files.newInputStream(cacheFilePath)) {
+        while (in.read(buf) != -1) {}
+      } catch (IOException e) {
+        logger.atWarning().log(
+            "Failed to warm OS page cache for %s: %s", cacheFilePath.getFileName(), e.getMessage());
+      }
+      logger.atFine().log(
+          "Finished warming OS page cache for %s after %s", cacheFilePath.getFileName(), sw);
+    }
+
     void open() {
       bloomFilter.initIfNeeded();
     }
@@ -459,6 +489,9 @@
     }
 
     private void buildBloomFilter() {
+      if (preWarmForBloomFilter) {
+        warmupOsPageCache();
+      }
       SqlHandle c = null;
       try (TraceTimer ignored = TraceContext.newTimer("Build bloom filter", Metadata.empty())) {
         c = acquire();
@@ -748,6 +781,7 @@
     }
 
     DiskStats diskStats() {
+      warmupOsPageCache();
       long size = 0;
       long space = 0;
       SqlHandle c = null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheModule.java b/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
index baf08e6..0ac8123 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheModule.java
@@ -115,6 +115,24 @@
   }
 
   @Provides
+  @Singleton
+  @Nullable
+  @CacheStoreStartupExecutor
+  ExecutorService createDiskCacheStoreStartupExecutor(
+      @Nullable @CacheDir Path cacheDir, @GerritServerConfig Config cfg) {
+    if (cacheDir == null) {
+      return null;
+    }
+    int startupThreads = cfg.getInt("cache", null, "startupThreads", 1);
+    if (startupThreads > 1) {
+      return new LoggingContextAwareExecutorService(
+          Executors.newFixedThreadPool(
+              1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-Startup-%d").build()));
+    }
+    return null;
+  }
+
+  @Provides
   Set<CacheOptions> getOptions() {
     return options;
   }
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index f68867be..a333d99 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -86,11 +86,11 @@
   @Override
   public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
     change = ctx.getChange();
-    PatchSet.Id psId = change.currentPatchSetId();
-    ChangeUpdate update = ctx.getUpdate(psId);
     if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
+    PatchSet.Id psId = change.currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
     patchSet = psUtil.get(ctx.getNotes(), psId);
     change.setStatus(Change.Status.ABANDONED);
     change.setLastUpdatedOn(ctx.getWhen());
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index 239fa7a..43598e4 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -82,6 +82,7 @@
   private final Collection<Address> addresses;
   private final ReviewerState state;
   private final boolean forGroup;
+  private boolean allowDowngradeToCc = true;
 
   // Unlike addedCCs, addedReviewers is a PatchSetApproval because the ReviewerResult returned
   // via the REST API is supposed to include vote information.
@@ -118,6 +119,10 @@
     this.forGroup = forGroup;
   }
 
+  public void setAllowDowngradeToCc(boolean allowDowngradeToCc) {
+    this.allowDowngradeToCc = allowDowngradeToCc;
+  }
+
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException, IOException {
     change = ctx.getChange();
@@ -130,7 +135,10 @@
       if (state == CC) {
         addedCCs =
             approvalsUtil.addCcs(
-                ctx.getNotes(), ctx.getUpdate(change.currentPatchSetId()), accountIds, forGroup);
+                ctx.getNotes(),
+                ctx.getUpdate(change.currentPatchSetId()),
+                accountIds,
+                forGroup || !allowDowngradeToCc);
       } else {
         addedReviewers =
             approvalsUtil.addReviewers(
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 67e6a0e..821424c 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -17,10 +17,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.project.CoreLockKeys;
 import com.google.gerrit.server.project.LockManager;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
@@ -79,7 +81,7 @@
   private final OneOffRequestContext oneOffRequestContext;
   private final AbandonUtil abandonUtil;
   private final RetryHelper retryHelper;
-  private final LockManager lockManager;
+  private final DynamicItem<LockManager> lockManager;
   private final long abandonAfterMillis;
   private final boolean abandonIfMergeable;
   @Nullable private final String message;
@@ -90,7 +92,7 @@
       OneOffRequestContext oneOffRequestContext,
       AbandonUtil abandonUtil,
       RetryHelper retryHelper,
-      LockManager lockManager,
+      DynamicItem<LockManager> lockManager,
       @Assisted long abandonAfterMillis,
       @Assisted boolean abandonIfMergeable,
       @Assisted("message") @Nullable String message,
@@ -110,7 +112,7 @@
       OneOffRequestContext oneOffRequestContext,
       AbandonUtil abandonUtil,
       RetryHelper retryHelper,
-      LockManager lockManager,
+      DynamicItem<LockManager> lockManager,
       ChangeCleanupConfig cfg) {
     this.oneOffRequestContext = oneOffRequestContext;
     this.abandonUtil = abandonUtil;
@@ -124,7 +126,7 @@
 
   @Override
   public void run() {
-    Lock lock = lockManager.getLock("change-cleanup");
+    Lock lock = lockManager.get().getLock(CoreLockKeys.CHANGE_CLEANUP);
     if (!lock.tryLock()) {
       logger.atInfo().log(
           "Couldn't acquire change-cleanup lock. Assuming another server is running"
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 25c2279..ab769f7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -348,26 +348,68 @@
     return setReviewersAndCcsAsStrings(
         Iterables.transform(reviewers, Account.Id::toString),
         Iterables.transform(ccs, Account.Id::toString),
+        ImmutableList.of(),
+        ImmutableList.of(),
         /* skipVisibilityCheck= */ true);
   }
 
+  /**
+   * Sets reviewers and CCs as strings.
+   *
+   * <p>Note: Callers must ensure that the reviewer and CC collections are disjoint across roles.
+   */
   @CanIgnoreReturnValue
   public ChangeInserter setReviewersAndCcsAsStrings(
       Iterable<String> reviewers, Iterable<String> ccs) {
-    return setReviewersAndCcsAsStrings(reviewers, ccs, /* skipVisibilityCheck= */ false);
+    return setReviewersAndCcsAsStrings(
+        reviewers, ccs, ImmutableList.of(), ImmutableList.of(), /* skipVisibilityCheck= */ false);
+  }
+
+  /**
+   * Sets reviewers and CCs (both regular and silent) as strings.
+   *
+   * <p>Note: Callers must ensure that all four collections (regular reviewers, silent reviewers,
+   * regular CCs, silent CCs) are mutually disjoint (e.g. as provided by {@code ReceiveCommits}).
+   */
+  @CanIgnoreReturnValue
+  public ChangeInserter setReviewersAndCcsAsStrings(
+      Iterable<String> reviewers,
+      Iterable<String> ccs,
+      Iterable<String> silentReviewers,
+      Iterable<String> silentCcs) {
+    return setReviewersAndCcsAsStrings(
+        reviewers, ccs, silentReviewers, silentCcs, /* skipVisibilityCheck= */ false);
   }
 
   @CanIgnoreReturnValue
   private ChangeInserter setReviewersAndCcsAsStrings(
-      Iterable<String> reviewers, Iterable<String> ccs, boolean skipVisibilityCheck) {
+      Iterable<String> reviewers,
+      Iterable<String> ccs,
+      Iterable<String> silentReviewers,
+      Iterable<String> silentCcs,
+      boolean skipVisibilityCheck) {
+    // Assumes mutually disjoint sets across all four collections (regular reviewers, silent
+    // reviewers, regular CCs, and silent CCs), as provided by the primary caller, ReceiveCommits.
     reviewerInputs =
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER, skipVisibilityCheck)),
+                    .map(
+                        id ->
+                            newReviewerInput(
+                                id, ReviewerState.REVIEWER, skipVisibilityCheck, false)),
+                Streams.stream(silentReviewers)
+                    .distinct()
+                    .map(
+                        id ->
+                            newReviewerInput(
+                                id, ReviewerState.REVIEWER, skipVisibilityCheck, true)),
                 Streams.stream(ccs)
                     .distinct()
-                    .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck)))
+                    .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck, false)),
+                Streams.stream(silentCcs)
+                    .distinct()
+                    .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck, true)))
             .collect(toImmutableList());
     return this;
   }
@@ -614,15 +656,21 @@
                     emailFactories.createStartReviewChangeEmail();
                 startReviewEmail.markAsCreateChange();
                 startReviewEmail.addReviewers(
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
+                    reviewerAdditions
+                        .flattenResults(
+                            ReviewerOp.Result::addedReviewers, ReviewerModification.NOT_SILENT)
+                        .stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
                 startReviewEmail.addReviewersByEmail(
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
+                    reviewerAdditions.flattenResults(
+                        ReviewerOp.Result::addedReviewersByEmail, ReviewerModification.NOT_SILENT));
                 startReviewEmail.addExtraCC(
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
+                    reviewerAdditions.flattenResults(
+                        ReviewerOp.Result::addedCCs, ReviewerModification.NOT_SILENT));
                 startReviewEmail.addExtraCCByEmail(
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
+                    reviewerAdditions.flattenResults(
+                        ReviewerOp.Result::addedCCsByEmail, ReviewerModification.NOT_SILENT));
                 ChangeEmail changeEmail =
                     emailFactories.createChangeEmail(
                         change.getProject(), change.getId(), startReviewEmail);
@@ -707,6 +755,7 @@
             ctx.getRevWalk().getObjectReader(),
             commitId,
             ctx.getIdentifiedUser(),
+            change.getCherryPickOf(),
             diffOperationsForCommitValidationFactory.create(
                 ctx.getRepoView(), ctx.getInserter()))) {
       if (!validate) {
@@ -739,22 +788,8 @@
   }
 
   private static InternalReviewerInput newReviewerInput(
-      String reviewer, ReviewerState state, boolean skipVisibilityCheck) {
-    // Disable individual emails when adding reviewers, as all reviewers will receive the single
-    // bulk new change email.
-    InternalReviewerInput input =
-        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
-
-    // Ignore failures for reasons like the reviewer being inactive or being unable to see the
-    // change. This is required for the push path, where it automatically sets reviewers from
-    // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
-    // theory we could provide finer control to do this for some reviewers and not others, but it's
-    // not worth complicating the ChangeInserter interface further at this time.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
-
-    input.skipVisibilityCheck = skipVisibilityCheck;
-
-    return input;
+      String reviewer, ReviewerState state, boolean skipVisibilityCheck, boolean silent) {
+    return ReviewerModifier.newReviewerInput(reviewer, state, skipVisibilityCheck, silent);
   }
 
   private ImmutableList<InternalReviewerInput> getReviewerInputs() {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 86065ed..16b6cf3 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -51,7 +51,6 @@
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
@@ -123,6 +122,8 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gerrit.server.util.AttentionSetUtil;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -137,7 +138,9 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -248,6 +251,7 @@
   private final Metrics metrics;
   private final RevisionJson revisionJson;
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
+  private final ThreadLocalRequestContext requestContext;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
   private final boolean cacheQueryResultsByChangeNum;
@@ -273,6 +277,7 @@
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
+      ThreadLocalRequestContext requestContext,
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       ExperimentFeatures experimentFeatures,
@@ -293,6 +298,7 @@
     this.removeReviewerControl = removeReviewerControl;
     this.trackingFooters = trackingFooters;
     this.metrics = metrics;
+    this.requestContext = requestContext;
     this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
@@ -324,24 +330,29 @@
   }
 
   public ChangeInfo format(ChangeData cd) {
-    return format(cd, Optional.empty(), true, getPluginInfos(cd));
+    return format(cd, Optional.empty(), true, getPluginInfos(cd), userProvider.get());
   }
 
   public ChangeInfo format(RevisionResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, getPluginInfos(cd));
+    return format(
+        cd, Optional.of(rsrc.getPatchSet().id()), true, getPluginInfos(cd), userProvider.get());
   }
 
   public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
       throws PermissionBackendException {
     try (Timer0.Context ignored = metrics.formatQueryResultsLatency.start()) {
       accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
+      CurrentUser user = userProvider.get();
       List<List<ChangeInfo>> res = new ArrayList<>(in.size());
-      Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
+      Map<Change.Id, ChangeInfo> cache = new ConcurrentHashMap<>(in.size());
+      List<ChangeData> allChanges =
+          in.stream().flatMap(e -> e.entities().stream()).collect(toList());
+      ensureLoaded(allChanges);
       ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
-          getPluginInfos(in.stream().flatMap(e -> e.entities().stream()).collect(toList()));
+          getPluginInfos(allChanges);
       for (QueryResult<ChangeData> r : in) {
-        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache, pluginInfosByChange);
+        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache, pluginInfosByChange, user);
         if (!infos.isEmpty() && r.more()) {
           infos.get(infos.size() - 1)._moreChanges = true;
         }
@@ -355,11 +366,19 @@
   public List<ChangeInfo> format(Collection<ChangeData> in) throws PermissionBackendException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     ensureLoaded(in);
-    List<ChangeInfo> out = new ArrayList<>(in.size());
+    // CurrentUser is thread-safe for reading. It is not mutated during the parallel formatting.
+    CurrentUser user = userProvider.get();
     ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange = getPluginInfos(in);
-    for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId())));
-    }
+    List<ChangeInfo> out =
+        in.parallelStream()
+            .map(
+                cd -> {
+                  try (ManualRequestContext ctx = new ManualRequestContext(user, requestContext)) {
+                    return format(
+                        cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()), user);
+                  }
+                })
+            .collect(toList());
     accountLoader.fill();
     return out;
   }
@@ -379,7 +398,7 @@
       return checkOnly(changeDataFactory.create(project, id));
     }
     ChangeData cd = changeDataFactory.create(notes);
-    return format(cd, Optional.empty(), true, getPluginInfos(cd));
+    return format(cd, Optional.empty(), true, getPluginInfos(cd), userProvider.get());
   }
 
   private static List<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
@@ -482,15 +501,16 @@
       ChangeData cd,
       Optional<PatchSet.Id> limitToPsId,
       boolean fillAccountLoader,
-      List<PluginDefinedInfo> pluginInfosForChange) {
+      List<PluginDefinedInfo> pluginInfosForChange,
+      CurrentUser user) {
     try {
       if (fillAccountLoader) {
         accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        ChangeInfo res = toChangeInfo(cd, limitToPsId, pluginInfosForChange);
+        ChangeInfo res = toChangeInfo(cd, limitToPsId, pluginInfosForChange, user);
         accountLoader.fill();
         return res;
       }
-      return toChangeInfo(cd, limitToPsId, pluginInfosForChange);
+      return toChangeInfo(cd, limitToPsId, pluginInfosForChange, user);
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
@@ -510,29 +530,38 @@
           TraceContext.newTimer(
               "Load change data for lazyLoad options",
               Metadata.builder().resourceCount(all.size()).build())) {
-        for (ChangeData cd : all) {
-          // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
-          cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
-        }
-        ChangeData.ensureChangeLoaded(all);
-        if (has(ALL_REVISIONS)) {
-          ChangeData.ensureAllPatchSetsLoaded(all);
-        } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
-          ChangeData.ensureCurrentPatchSetLoaded(all);
-        }
-        if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
-          ChangeData.ensureReviewedByLoadedForOpenChanges(all);
-        }
-        if (has(STAR) && userProvider.get().isIdentifiedUser()) {
-          ChangeData.ensureChangeServerId(all);
-        }
-        ChangeData.ensureCurrentApprovalsLoaded(all);
+        boolean isIdentifiedUser = userProvider.get().isIdentifiedUser();
+        all.parallelStream()
+            .forEach(
+                cd -> {
+                  // Mark all ChangeDatas as coming from the index, but allow backfilling data from
+                  // NoteDb
+                  cd.setStorageConstraint(
+                      ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+
+                  Set<ChangeData> singleCdSet = Collections.singleton(cd);
+                  ChangeData.ensureChangeLoaded(singleCdSet);
+                  if (has(ALL_REVISIONS)) {
+                    ChangeData.ensureAllPatchSetsLoaded(singleCdSet);
+                  } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
+                    ChangeData.ensureCurrentPatchSetLoaded(singleCdSet);
+                  }
+                  if (has(REVIEWED) && isIdentifiedUser) {
+                    ChangeData.ensureReviewedByLoadedForOpenChanges(singleCdSet);
+                  }
+                  if (has(STAR) && isIdentifiedUser) {
+                    ChangeData.ensureChangeServerId(singleCdSet);
+                  }
+                  ChangeData.ensureCurrentApprovalsLoaded(singleCdSet);
+                });
       }
     } else {
-      for (ChangeData cd : all) {
-        // Mark all ChangeDatas as coming from the index. Disallow using NoteDb
-        cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_ONLY);
-      }
+      all.parallelStream()
+          .forEach(
+              cd -> {
+                // Mark all ChangeDatas as coming from the index. Disallow using NoteDb
+                cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_ONLY);
+              });
     }
   }
 
@@ -543,50 +572,61 @@
   private List<ChangeInfo> toChangeInfos(
       List<ChangeData> changes,
       Map<Change.Id, ChangeInfo> cache,
-      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange) {
+      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange,
+      CurrentUser user) {
     try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
-      List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
-      for (int i = 0; i < changes.size(); i++) {
-        // We can only cache and re-use an entity if it's not the last in the list. The last entity
-        // may later get _moreChanges set. If it was cached or re-used, that setting would propagate
-        // to the original entity yielding wrong results.
-        // This problem has two sides where 'last in the list' has to be respected:
-        // (1) Caching
-        // (2) Reusing
-        boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
-        ChangeData cd = changes.get(i);
-        if (cd.hasFailedParsingFromIndex()) {
-          Optional<ChangeInfo> faultyChangeInfo = createFaultyChangeInfo(cd);
-          if (faultyChangeInfo.isPresent()) {
-            changeInfos.add(faultyChangeInfo.get());
-          }
-          continue;
-        }
-        try {
-          Change.Id cdUniqueId = cd.virtualId();
-          ChangeInfo info = cache.get(cdUniqueId);
-          if (info != null && isCacheable) {
-            changeInfos.add(info);
-            continue;
-          }
+      List<ChangeInfo> changeInfos =
+          IntStream.range(0, changes.size())
+              .parallel()
+              .mapToObj(
+                  i -> {
+                    try (ManualRequestContext ctx =
+                        new ManualRequestContext(user, requestContext)) {
+                      ChangeData cd = changes.get(i);
+                      // Cache/re-use only if it is not the last entity in the list.
+                      // The last entity may have _moreChanges set later, which would
+                      // propagate to the original cached/re-used entity, yielding
+                      // incorrect results. This applies to both caching and reusing.
+                      boolean isCacheable =
+                          cacheQueryResultsByChangeNum && (i != changes.size() - 1);
+                      if (cd.hasFailedParsingFromIndex()) {
+                        return createFaultyChangeInfo(cd).orElse(null);
+                      }
+                      try {
+                        Change.Id cdUniqueId = cd.virtualId();
+                        if (isCacheable) {
+                          ChangeInfo info = cache.get(cdUniqueId);
+                          if (info != null) {
+                            return info;
+                          }
+                        }
 
-          // Compute and cache if possible
-          ensureLoaded(Collections.singleton(cd));
-          info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
-          changeInfos.add(info);
-          if (isCacheable) {
-            cache.put(cdUniqueId, info);
-          }
-        } catch (RuntimeException e) {
-          Optional<RequestCancelledException> requestCancelledException =
-              RequestCancelledException.getFromCausalChain(e);
-          if (requestCancelledException.isPresent()) {
-            throw e;
-          }
-          logger.atWarning().withCause(e).log(
-              "Omitting corrupt change %s from results", cd.getId());
-        }
-      }
+                        ChangeInfo info =
+                            format(
+                                cd,
+                                Optional.empty(),
+                                false,
+                                pluginInfosByChange.get(cd.getId()),
+                                user);
+                        if (isCacheable) {
+                          cache.put(cdUniqueId, info);
+                        }
+                        return info;
+                      } catch (RuntimeException e) {
+                        Optional<RequestCancelledException> requestCancelledException =
+                            RequestCancelledException.getFromCausalChain(e);
+                        if (requestCancelledException.isPresent()) {
+                          throw e;
+                        }
+                        logger.atWarning().withCause(e).log(
+                            "Omitting corrupt change %s from results", cd.getId());
+                        return null;
+                      }
+                    }
+                  })
+              .filter(java.util.Objects::nonNull)
+              .collect(toList());
+
       if (has(STAR) && userProvider.get().isIdentifiedUser()) {
         populateStarField(changeInfos);
       }
@@ -600,7 +640,7 @@
       notes = cd.notes();
     } catch (StorageException e) {
       String msg = "Error loading change";
-      logger.atWarning().withCause(e).log(msg + " %s", cd.getId());
+      logger.atWarning().withCause(e).log("%s %s", msg, cd.getId());
       ChangeInfo info = new ChangeInfo();
       info._number = cd.getId().get();
       ProblemInfo p = new ProblemInfo();
@@ -639,18 +679,21 @@
   private ChangeInfo toChangeInfo(
       ChangeData cd,
       Optional<PatchSet.Id> limitToPsId,
-      List<PluginDefinedInfo> pluginInfosForChange)
+      List<PluginDefinedInfo> pluginInfosForChange,
+      CurrentUser user)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
-      return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange);
+      return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange, user);
     }
   }
 
   private ChangeInfo toChangeInfoImpl(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, List<PluginDefinedInfo> pluginInfos)
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      List<PluginDefinedInfo> pluginInfos,
+      CurrentUser user)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     ChangeInfo out = new ChangeInfo();
-    CurrentUser user = userProvider.get();
 
     if (has(CHECK)) {
       out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
@@ -767,7 +810,7 @@
     if (has(LABELS) || has(DETAILED_LABELS)) {
       out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
       out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
-      out.removableReviewers = removableReviewers(cd, out);
+      out.removableReviewers = removableReviewers(cd, out, user);
     }
 
     setSubmitter(cd, out);
@@ -954,7 +997,7 @@
     }
   }
 
-  private List<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
+  private List<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out, CurrentUser user)
       throws PermissionBackendException {
     try (TraceTimer timer =
         TraceContext.newTimer(
@@ -989,7 +1032,7 @@
       // Check if the user has the permission to remove a reviewer. This means we can bypass the
       // permission checks for a specific reviewer in the loop saving potentially many permission
       // checks.
-      PermissionBackend.WithUser withUser = permissionBackend.user(userProvider.get());
+      PermissionBackend.WithUser withUser = permissionBackend.user(user);
       boolean canRemoveAnyReviewer =
           withUser.change(cd).test(ChangePermission.REMOVE_REVIEWER)
               || withUser
@@ -1013,7 +1056,7 @@
           if ((cd.change().isMerged() && value != 0)
               || (!canRemoveAnyReviewer
                   && !RemoveReviewerControl.canRemoveReviewerWithoutPermissionCheck(
-                      cd.change(), userProvider.get(), id, value))) {
+                      cd.change(), user, id, value))) {
             fixed.add(id);
           }
         }
@@ -1028,8 +1071,7 @@
         for (AccountInfo ai : ccs) {
           if (ai._accountId != null) {
             Account.Id id = Account.id(ai._accountId);
-            if (canRemoveAnyReviewer
-                || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+            if (canRemoveAnyReviewer || removeReviewerControl.testRemoveReviewer(cd, user, id, 0)) {
               removable.add(id);
             }
           }
@@ -1050,7 +1092,7 @@
         for (AccountInfo info : infos) {
           if (info._accountId == null) {
             if (canRemoveAnyReviewer
-                || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), null, 0)) {
+                || removeReviewerControl.testRemoveReviewer(cd, user, null, 0)) {
               result.add(info);
             }
           }
diff --git a/java/com/google/gerrit/server/change/CommentsValidator.java b/java/com/google/gerrit/server/change/CommentsValidator.java
index c02fb83..336d2f5 100644
--- a/java/com/google/gerrit/server/change/CommentsValidator.java
+++ b/java/com/google/gerrit/server/change/CommentsValidator.java
@@ -27,18 +27,17 @@
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
@@ -80,21 +79,12 @@
         ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
         ensureRangeIsValid(path, comment.range);
         ensureValidPatchsetLevelComment(path, comment);
-        ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
+        commentsUtil.ensureValidInReplyTo(revision.getNotes(), patchSetId, comment.inReplyTo);
         ensureFixSuggestionsAreAddable(comment.fixSuggestions, path);
       }
     }
   }
 
-  private void ensureValidInReplyTo(ChangeNotes changeNotes, String inReplyTo)
-      throws BadRequestException {
-    if (inReplyTo != null
-        && !commentsUtil.getPublishedHumanComment(changeNotes, inReplyTo).isPresent()) {
-      throw new BadRequestException(
-          String.format("Invalid inReplyTo, comment %s not found", inReplyTo));
-    }
-  }
-
   private Set<String> getAffectedFilePaths(RevisionResource revision)
       throws PatchListNotAvailableException {
     ObjectId newId = revision.getPatchSet().commitId();
diff --git a/java/com/google/gerrit/server/change/DraftCommentsCleanupRunner.java b/java/com/google/gerrit/server/change/DraftCommentsCleanupRunner.java
index 74454b5..0340ff0 100644
--- a/java/com/google/gerrit/server/change/DraftCommentsCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/DraftCommentsCleanupRunner.java
@@ -16,12 +16,14 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+import com.google.gerrit.server.project.CoreLockKeys;
 import com.google.gerrit.server.project.LockManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -68,17 +70,18 @@
   }
 
   private final DeleteZombieCommentsRefs.Factory factory;
-  private final LockManager lockManager;
+  private final DynamicItem<LockManager> lockManager;
 
   @Inject
-  DraftCommentsCleanupRunner(DeleteZombieCommentsRefs.Factory factory, LockManager lockManager) {
+  DraftCommentsCleanupRunner(
+      DeleteZombieCommentsRefs.Factory factory, DynamicItem<LockManager> lockManager) {
     this.factory = factory;
     this.lockManager = lockManager;
   }
 
   @Override
   public void run() {
-    Lock lock = lockManager.getLock("draft-comments-cleanup");
+    Lock lock = lockManager.get().getLock(CoreLockKeys.DRAFT_COMMENTS_CLEANUP);
     if (!lock.tryLock()) {
       logger.atInfo().log(
           "Couldn't acquire draft-comments-cleanup lock. Assuming the task is running");
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index ab557dc..11e6b98 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -39,6 +39,18 @@
     return getFileInfoMap(change, patchSet.commitId(), null);
   }
 
+  default Map<String, FileInfo> getFileInfoMap(
+      Change change, PatchSet patchSet, boolean skipDiffStat)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    return getFileInfoMap(change, patchSet.commitId(), null, skipDiffStat);
+  }
+
+  default Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base, boolean skipDiffStat)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    return getFileInfoMap(change, objectId, base);
+  }
+
   /**
    * Computes the list of modified files for a given change and patchset against its parent. For
    * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index cda6191..32c1849 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
@@ -47,17 +47,28 @@
   public Map<String, FileInfo> getFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
       throws ResourceConflictException, PatchListNotAvailableException {
+    return getFileInfoMap(change, objectId, base, /* skipDiffStat= */ false);
+  }
+
+  @Nullable
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base, boolean skipDiffStat)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    DiffOptions diffOptions =
+        skipDiffStat
+            ? DiffOptions.DEFAULTS.toBuilder().skipDiffStat(true).build()
+            : DiffOptions.DEFAULTS;
     try {
       if (base == null) {
         // Setting parentNum=0 requests the default parent, which is the only parent for
         // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
             diffs.listModifiedFilesAgainstParent(
-                change.getProject(), objectId, /* parentNum= */ 0, DiffOptions.DEFAULTS));
+                change.getProject(), objectId, /* parentNum= */ 0, diffOptions));
       }
       return asFileInfo(
-          diffs.listModifiedFiles(
-              change.getProject(), base.commitId(), objectId, DiffOptions.DEFAULTS));
+          diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId, diffOptions));
     } catch (DiffNotAvailableException e) {
       convertException(e);
       return null; // unreachable. handleAndThrow will throw an exception anyway
@@ -97,6 +108,15 @@
     for (String path : fileDiffs.keySet()) {
       FileDiffOutput fileDiff = fileDiffs.get(path);
       FileInfo fileInfo = new FileInfo();
+      if (fileDiff.isNegative() || fileDiff.isTooExpensive()) {
+        fileInfo.diffsTooExpensiveToCompute = true;
+        fileInfo.status =
+            fileDiff.changeType() != Patch.ChangeType.MODIFIED
+                ? fileDiff.changeType().getCode()
+                : null;
+        result.put(path, fileInfo);
+        continue;
+      }
       fileInfo.status =
           fileDiff.changeType() != Patch.ChangeType.MODIFIED
               ? fileDiff.changeType().getCode()
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 861bb70..2c79ad4 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -136,6 +136,13 @@
    */
   Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
       throws PermissionBackendException {
+    return permittedLabels(
+        permissionBackend.absentUser(filterApprovalsBy).change(cd), filterApprovalsBy, cd);
+  }
+
+  private Map<String, Collection<String>> permittedLabels(
+      PermissionBackend.ForChange userPerm, Account.Id filterApprovalsBy, ChangeData cd)
+      throws PermissionBackendException {
     try (TraceTimer timer =
         TraceContext.newTimer(
             "Get permitted labels",
@@ -147,8 +154,7 @@
         if (isMerged && !labelType.isAllowPostSubmit()) {
           continue;
         }
-        Set<LabelPermission.WithValue> can =
-            permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
+        Set<LabelPermission.WithValue> can = userPerm.test(labelType);
         for (LabelPermission.WithValue val : can) {
           logger.atFine().log(
               "User %s For label %s can vote %s ", filterApprovalsBy, val.label(), val.value());
@@ -499,7 +505,7 @@
       PermissionBackend.ForChange perm = null;
       if (detailed) {
         perm = permissionBackend.absentUser(accountId).change(cd);
-        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+        pvr = getPermittedVotingRanges(permittedLabels(perm, accountId, cd));
       }
       for (Map.Entry<String, LabelInfo> e : labels.entrySet()) {
         Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index b18ae7f..cc5aea7 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -475,6 +475,7 @@
             ctx.getRevWalk().getObjectReader(),
             commitId,
             ctx.getIdentifiedUser(),
+            origNotes.getChange().getCherryPickOf(),
             diffOperationsForCommitValidationFactory.create(
                 ctx.getRepoView(), ctx.getInserter()))) {
       if (!validate) {
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index f34a673..1808ff5 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -85,6 +85,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -130,6 +131,16 @@
 
     /** Whether the visibility check for the reviewer account should be skipped. */
     public boolean skipVisibilityCheck = false;
+
+    /**
+     * Whether an existing REVIEWER should be downgraded to CC if the input state is CC.
+     *
+     * <p>If false, and the account is already a REVIEWER, the state will remain REVIEWER.
+     */
+    public boolean allowDowngradeToCc = true;
+
+    /** Whether the reviewer/CC was added silently (omitted from outgoing notification emails). */
+    public boolean silent = false;
   }
 
   public static InternalReviewerInput newReviewerInput(
@@ -141,6 +152,17 @@
     return in;
   }
 
+  public static InternalReviewerInput newReviewerInput(
+      String reviewer, ReviewerState state, boolean skipVisibilityCheck, boolean silent) {
+    InternalReviewerInput input = newReviewerInput(reviewer, state, NotifyHandling.NONE);
+    // Ignore failures for reasons like the reviewer being inactive or being unable to see the
+    // change to prevent push failures when commit footers contain invalid or inactive users.
+    input.otherFailureBehavior = FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+    input.skipVisibilityCheck = skipVisibilityCheck;
+    input.silent = silent;
+    return input;
+  }
+
   public static Optional<InternalReviewerInput> newReviewerInputFromCommitIdentity(
       Change change,
       ObjectId commitId,
@@ -161,6 +183,10 @@
     in.state = CC;
     in.notify = notify;
     in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
+    // Automatic addition of author/committer as CC is an implicit system action on push.
+    // Preserve existing REVIEWER status so implicit auto-CC does not demote explicitly assigned
+    // reviewers.
+    in.allowDowngradeToCc = false;
     return Optional.of(in);
   }
 
@@ -465,6 +491,15 @@
     private final ReviewerInput input;
     @Nullable private final FailureType failureType;
 
+    /**
+     * Predicate evaluating to true if the modification should result in a notification (i.e., it is
+     * NOT silent).
+     */
+    public static final Predicate<ReviewerModification> NOT_SILENT =
+        m ->
+            !(m.input instanceof InternalReviewerInput)
+                || !((InternalReviewerInput) m.input).silent;
+
     private ReviewerModification(ReviewerInput input, FailureType failureType) {
       this.input = input;
       this.failureType = requireNonNull(failureType);
@@ -527,6 +562,9 @@
                 this.reviewersByEmail,
                 state(),
                 forGroup);
+        if (input instanceof InternalReviewerInput internalInput) {
+          ((AddReviewersOp) op).setAllowDowngradeToCc(internalInput.allowDowngradeToCc);
+        }
       }
       this.exactMatchFound = exactMatchFound;
     }
@@ -699,12 +737,19 @@
 
     public <T> ImmutableSet<T> flattenResults(
         Function<ReviewerOp.Result, ? extends Collection<T>> func) {
+      return flattenResults(func, a -> true);
+    }
+
+    public <T> ImmutableSet<T> flattenResults(
+        Function<ReviewerOp.Result, ? extends Collection<T>> func,
+        Predicate<ReviewerModification> filter) {
       modifications()
           .forEach(
               a ->
                   checkArgument(
                       a.op != null && a.op.getResult() != null, "missing result on %s", a));
       return modifications().stream()
+          .filter(filter)
           .map(a -> a.op.getResult())
           .map(func)
           .flatMap(Collection::stream)
@@ -717,6 +762,10 @@
               a -> {
                 if (a.isFailure()) {
                   if (a.isIgnorableFailure()) {
+                    logger.atWarning().log(
+                        "Ignored failure while adding reviewer '%s': %s",
+                        a.input.reviewer, a.result.error);
+
                     return false;
                   }
                   // Shouldn't happen, caller should have checked that there were no errors.
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 0ebeec0..03e4e30 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -24,8 +24,10 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.client.ListChangesOption.PARENTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
+import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -39,6 +41,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -280,6 +283,8 @@
           RevWalk rw = newRevWalk(repo)) {
         AttributesNodeProvider attributesNodeProvider =
             repo != null ? repo.createAttributesNodeProvider() : null;
+        boolean allPatchSetsLoaded = has(ALL_REVISIONS) || has(MESSAGES);
+        PatchSet priorPs = null;
         for (PatchSet in : map.values()) {
           PatchSet.Id id = in.id();
           boolean want;
@@ -294,8 +299,18 @@
             res.put(
                 in.commitId().name(),
                 toRevisionInfo(
-                    accountLoader, cd, in, repo, rw, false, changeInfo, attributesNodeProvider));
+                    accountLoader,
+                    cd,
+                    in,
+                    allPatchSetsLoaded ? Optional.ofNullable(priorPs) : Optional.empty(),
+                    /* isFirstPatchSet= */ allPatchSetsLoaded && priorPs == null,
+                    repo,
+                    rw,
+                    false,
+                    changeInfo,
+                    attributesNodeProvider));
           }
+          priorPs = in;
         }
         return res;
       }
@@ -342,6 +357,31 @@
       @Nullable ChangeInfo changeInfo,
       @Nullable AttributesNodeProvider attributesNodeProvider)
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+    return toRevisionInfo(
+        accountLoader,
+        cd,
+        in,
+        Optional.empty(),
+        /* isFirstPatchSet= */ false,
+        repo,
+        rw,
+        fillCommit,
+        changeInfo,
+        attributesNodeProvider);
+  }
+
+  private RevisionInfo toRevisionInfo(
+      AccountLoader accountLoader,
+      ChangeData cd,
+      PatchSet in,
+      Optional<PatchSet> priorPs,
+      boolean isFirstPatchSet,
+      @Nullable Repository repo,
+      @Nullable RevWalk rw,
+      boolean fillCommit,
+      @Nullable ChangeInfo changeInfo,
+      @Nullable AttributesNodeProvider attributesNodeProvider)
+      throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
     Change c = cd.change();
     RevisionInfo out = new RevisionInfo();
     out.isCurrent = in.id().equals(c.currentPatchSetId());
@@ -360,9 +400,22 @@
       out.realUploader = accountLoader.get(in.realUploader());
     }
     out.fetch = makeFetchMap(cd, in);
-    out.kind =
-        changeKindCache.getChangeKind(
-            rw, repo != null ? repo.getConfig() : null, attributesNodeProvider, cd, in);
+    if (isFirstPatchSet || in.id().get() <= 1) {
+      out.kind = ChangeKind.REWORK;
+    } else if (priorPs.isPresent()) {
+      out.kind =
+          changeKindCache.getChangeKind(
+              cd.project(),
+              rw,
+              repo != null ? repo.getConfig() : null,
+              attributesNodeProvider,
+              priorPs.get().commitId(),
+              in.commitId());
+    } else {
+      out.kind =
+          changeKindCache.getChangeKind(
+              rw, repo != null ? repo.getConfig() : null, attributesNodeProvider, cd, in);
+    }
     out.description = in.description().orElse(null);
     out.conflicts =
         in.conflicts()
@@ -430,7 +483,7 @@
 
     if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
       try {
-        out.files = fileInfoJson.getFileInfoMap(c, in);
+        out.files = fileInfoJson.getFileInfoMap(c, in, has(SKIP_DIFFSTAT));
         out.files.remove(Patch.COMMIT_MSG);
         out.files.remove(Patch.MERGE_LIST);
       } catch (ResourceConflictException e) {
@@ -473,7 +526,7 @@
 
   @Nullable
   private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
-    if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
+    if (has(ALL_REVISIONS) || has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
       return repoManager.openRepository(project);
     }
     return null;
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index 2bd8d5f..45b5898 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
@@ -45,6 +46,7 @@
 import com.google.inject.Module;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -98,28 +100,31 @@
   @Override
   public ImmutableMap<CommentContextKey, CommentContext> getAll(
       Iterable<CommentContextKey> inputKeys) {
-    ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
-
     // We do two transformations to the input keys: first we adjust the max context padding, and
     // second we hash the file path. The transformed keys are used to request context from the
     // cache. Keeping a map of the original inputKeys to the transformed keys
+    int estimatedSize = (inputKeys instanceof Collection) ? ((Collection<?>) inputKeys).size() : 16;
     Map<CommentContextKey, CommentContextKey> inputKeysToCacheKeys =
-        Streams.stream(inputKeys)
-            .collect(
-                Collectors.toMap(
-                    Function.identity(),
-                    k ->
-                        adjustMaxContextPadding(k).toBuilder()
-                            .path(Loader.hashPath(k.path()))
-                            .build()));
+        Maps.newHashMapWithExpectedSize(estimatedSize);
+    for (CommentContextKey k : inputKeys) {
+      inputKeysToCacheKeys.computeIfAbsent(
+          k,
+          key ->
+              adjustMaxContextPadding(key).toBuilder().path(Loader.hashPath(key.path())).build());
+    }
 
     try {
       ImmutableMap<CommentContextKey, CommentContext> allContext =
           contextCache.getAll(inputKeysToCacheKeys.values());
 
-      for (CommentContextKey inputKey : inputKeys) {
-        CommentContextKey cacheKey = inputKeysToCacheKeys.get(inputKey);
-        result.put(inputKey, allContext.get(cacheKey));
+      ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+          ImmutableMap.builderWithExpectedSize(inputKeysToCacheKeys.size());
+      for (Map.Entry<CommentContextKey, CommentContextKey> entry :
+          inputKeysToCacheKeys.entrySet()) {
+        CommentContext ctx = allContext.get(entry.getValue());
+        if (ctx != null) {
+          result.put(entry.getKey(), ctx);
+        }
       }
       return result.build();
     } catch (ExecutionException e) {
@@ -152,24 +157,23 @@
       AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
       allBuilder.setContentType(commentContext.contentType());
 
-      commentContext
-          .lines()
-          .entrySet()
-          .forEach(
-              c ->
-                  allBuilder.addContext(
-                      CommentContextProto.newBuilder()
-                          .setLineNumber(c.getKey())
-                          .setContextLine(c.getValue())));
+      for (Map.Entry<Integer, String> c : commentContext.lines().entrySet()) {
+        allBuilder.addContext(
+            CommentContextProto.newBuilder()
+                .setLineNumber(c.getKey())
+                .setContextLine(c.getValue()));
+      }
       return Protos.toByteArray(allBuilder.build());
     }
 
     @Override
     public CommentContext deserialize(byte[] in) {
-      ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
       AllCommentContextProto proto = Protos.parseUnchecked(AllCommentContextProto.parser(), in);
-      proto.getContextList().stream()
-          .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+      ImmutableMap.Builder<Integer, String> contextLinesMap =
+          ImmutableMap.builderWithExpectedSize(proto.getContextCount());
+      for (CommentContextProto c : proto.getContextList()) {
+        contextLinesMap.put(c.getLineNumber(), c.getContextLine());
+      }
       return CommentContext.create(contextLinesMap.build(), proto.getContentType());
     }
   }
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index f43a8b8..72ef681 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -76,6 +77,7 @@
   private final Duration maxAuthTokenLifetime;
   private final int maxAuthTokensPerAccount;
   private final boolean httpPasswordFallbackEnabled;
+  @Nullable private final String oauthTokenEncryptionKey;
 
   @Inject
   AuthConfig(@GerritServerConfig Config cfg) throws XsrfException {
@@ -115,6 +117,7 @@
         (int)
             ConfigUtil.getTimeUnit(cfg, "auth", null, "externalIdsRefExpiry", 0, TimeUnit.SECONDS);
     httpPasswordFallbackEnabled = cfg.getBoolean("auth", "httpPasswordFallbackEnabled", true);
+    oauthTokenEncryptionKey = cfg.getString("auth", null, "tokenEncryptionKey");
 
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP
         && authType != AuthType.LDAP
@@ -371,6 +374,16 @@
     return authType == AuthType.OAUTH;
   }
 
+  /**
+   * Base64-encoded AES key ({@code auth.tokenEncryptionKey}) used to encrypt stored OAuth tokens in
+   * the {@code oauth_tokens} cache, or {@code null} when unset (tokens are then stored in
+   * cleartext).
+   */
+  @Nullable
+  public String getOAuthTokenEncryptionKey() {
+    return oauthTokenEncryptionKey;
+  }
+
   public boolean isAllowRegisterNewEmail() {
     return allowRegisterNewEmail;
   }
diff --git a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
index db21e1f..77c5617 100644
--- a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
@@ -17,8 +17,8 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.Optional;
-import javax.inject.Singleton;
 import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 990aa4f..6d2efeb 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
 import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountVisibility;
@@ -116,6 +117,7 @@
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
+import com.google.gerrit.server.auth.oauth.OAuthTokenRevokedListener;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.CacheRemovalListener;
@@ -381,6 +383,11 @@
     DynamicMap.mapOf(binder(), new TypeLiteral<CacheDef<?, ?>>() {});
     DynamicSet.setOf(binder(), CacheRemovalListener.class);
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
+    // In sys (not web) so GetOAuthToken and the oauth-token SSH command can refresh expired tokens.
+    DynamicMap.mapOf(binder(), OAuthServiceProvider.class);
+    // Fired when a token is revoked+evicted, so a provider plugin can drop derived state (e.g. a
+    // Git-over-HTTP token-validation cache); core stays ignorant of which plugin, if any, listens.
+    DynamicSet.setOf(binder(), OAuthTokenRevokedListener.class);
     DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
diff --git a/java/com/google/gerrit/server/config/ScheduleConfig.java b/java/com/google/gerrit/server/config/ScheduleConfig.java
index a942d66..77f648c 100644
--- a/java/com/google/gerrit/server/config/ScheduleConfig.java
+++ b/java/com/google/gerrit/server/config/ScheduleConfig.java
@@ -72,6 +72,10 @@
  *         <li>{@code <minutes>}: {@code 00}-{@code 59}
  *       </ul>
  *       The timezone cannot be specified but is always the system default time-zone.
+ *   <li>{@code minimumInitialDelay}. Minimum time after schedule creation before the first
+ *       execution. If the delay selected by {@code startTime} is shorter, it is advanced by whole
+ *       {@code interval} periods until the minimum delay is met. If 0 or not specified, no minimum
+ *       is applied.
  *   <li>{@code jitter}: A maximum random delay that will be added to the job's start time. If 0 or
  *       not specified, no jitter is applied.
  *       <ul>
@@ -79,8 +83,8 @@
  *       </ul>
  * </ul>
  *
- * <p>The section and the subsection from which the {@code interval}, {@code startTime} and {@code
- * jitter} parameters are read can be configured.
+ * <p>The section and the subsection from which the {@code interval}, {@code startTime}, {@code
+ * minimumInitialDelay}, and {@code jitter} parameters are read can be configured.
  *
  * <p>Examples for a schedule configuration:
  *
@@ -114,6 +118,7 @@
 
   @VisibleForTesting static final String KEY_INTERVAL = "interval";
   @VisibleForTesting static final String KEY_STARTTIME = "startTime";
+  @VisibleForTesting static final String KEY_MINIMUM_INITIAL_DELAY = "minimumInitialDelay";
   @VisibleForTesting static final String KEY_JITTER = "jitter";
 
   private static final long MISSING_CONFIG = -1L;
@@ -129,6 +134,7 @@
         .setNow(computeNow())
         .setKeyInterval(KEY_INTERVAL)
         .setKeyStartTime(KEY_STARTTIME)
+        .setKeyMinimumInitialDelay(KEY_MINIMUM_INITIAL_DELAY)
         .setKeyJitter(KEY_JITTER)
         .setConfig(config)
         .setSection(section);
@@ -145,6 +151,8 @@
 
   abstract String keyStartTime();
 
+  abstract String keyMinimumInitialDelay();
+
   abstract String keyJitter();
 
   abstract ZonedDateTime now();
@@ -152,6 +160,8 @@
   @Memoized
   public Optional<Schedule> schedule() {
     long interval = computeInterval(config(), section(), subsection(), keyInterval());
+    long minimumInitialDelay =
+        computeMinimumInitialDelay(config(), section(), subsection(), keyMinimumInitialDelay());
     long jitter = computeJitter(config(), section(), subsection(), keyJitter());
 
     long initialDelay;
@@ -163,14 +173,16 @@
       initialDelay = interval;
     }
 
-    if (isInvalidOrMissing(interval, initialDelay, jitter)) {
+    if (isInvalidOrMissing(interval, initialDelay, minimumInitialDelay, jitter)) {
       return Optional.empty();
     }
 
+    initialDelay = applyMinimumInitialDelay(initialDelay, interval, minimumInitialDelay);
     return Optional.of(Schedule.create(interval, initialDelay));
   }
 
-  private boolean isInvalidOrMissing(long interval, long initialDelay, long jitter) {
+  private boolean isInvalidOrMissing(
+      long interval, long initialDelay, long minimumInitialDelay, long jitter) {
     String key = section() + (subsection() != null ? "." + subsection() : "");
     if (interval == MISSING_CONFIG && initialDelay == MISSING_CONFIG) {
       logger.atInfo().log("No schedule configuration for \"%s\".", key);
@@ -202,7 +214,17 @@
       initialDelay = INVALID_CONFIG;
     }
 
-    if (interval == INVALID_CONFIG || initialDelay == INVALID_CONFIG || jitter == INVALID_CONFIG) {
+    if (minimumInitialDelay != INVALID_CONFIG && minimumInitialDelay < 0) {
+      logger.atSevere().log(
+          "Invalid minimum initial delay value \"%d\" for \"%s\". It must be >= 0",
+          minimumInitialDelay, key);
+      minimumInitialDelay = INVALID_CONFIG;
+    }
+
+    if (interval == INVALID_CONFIG
+        || initialDelay == INVALID_CONFIG
+        || minimumInitialDelay == INVALID_CONFIG
+        || jitter == INVALID_CONFIG) {
       logger.atSevere().log("Invalid schedule configuration for \"%s\" is ignored. ", key);
       return true;
     }
@@ -216,6 +238,10 @@
     b.append(formatValue(keyInterval()));
     b.append(", ");
     b.append(formatValue(keyStartTime()));
+    if (config().getString(section(), subsection(), keyMinimumInitialDelay()) != null) {
+      b.append(", ");
+      b.append(formatValue(keyMinimumInitialDelay()));
+    }
     return b.toString();
   }
 
@@ -262,6 +288,28 @@
     }
   }
 
+  private static long computeMinimumInitialDelay(
+      Config rc, String section, String subsection, String keyMinimumInitialDelay) {
+    try {
+      return ConfigUtil.getTimeUnit(
+          rc, section, subsection, keyMinimumInitialDelay, 0, TimeUnit.MILLISECONDS);
+    } catch (IllegalArgumentException e) {
+      logger.atSevere().log("%s", e.getMessage());
+      return INVALID_CONFIG;
+    }
+  }
+
+  private static long applyMinimumInitialDelay(
+      long initialDelay, long interval, long minimumInitialDelay) {
+    if (initialDelay >= minimumInitialDelay) {
+      return initialDelay;
+    }
+
+    long deficit = minimumInitialDelay - initialDelay;
+    long intervalsToAdd = (deficit + interval - 1) / interval;
+    return initialDelay + intervalsToAdd * interval;
+  }
+
   private static long computeInitialDelay(
       Config rc,
       String section,
@@ -329,6 +377,8 @@
 
     public abstract Builder setKeyStartTime(String keyStartTime);
 
+    public abstract Builder setKeyMinimumInitialDelay(String keyMinimumInitialDelay);
+
     public abstract Builder setKeyJitter(String keyJitter);
 
     @VisibleForTesting
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 9f85857..cc9f8e0 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -44,7 +44,6 @@
   public final Path mail_dir;
   public final Path hooks_dir;
   public final Path static_dir;
-  public final Path index_dir;
 
   public final Path gerrit_sh;
   public final Path gerrit_service;
@@ -91,7 +90,6 @@
     mail_dir = etc_dir.resolve("mail");
     hooks_dir = p.resolve("hooks");
     static_dir = p.resolve("static");
-    index_dir = p.resolve("index");
 
     gerrit_sh = bin_dir.resolve("gerrit.sh");
     gerrit_service = bin_dir.resolve("gerrit.service");
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index c6662f5..7aa33bf 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -131,6 +131,7 @@
               builder, builder::setAllowAutocompletingComments, info.allowAutocompletingComments);
       builder = setIfNotNull(builder, builder::setDiffPageSidebar, info.diffPageSidebar);
       builder = setIfNotNull(builder, builder::setAiChatSelectedModel, info.aiChatSelectedModel);
+      builder = setIfNotNull(builder, builder::setLabelFilter, info.labelFilter);
       return builder.build();
     }
 
@@ -201,6 +202,7 @@
       res.diffPageSidebar = proto.hasDiffPageSidebar() ? proto.getDiffPageSidebar() : null;
       res.aiChatSelectedModel =
           proto.hasAiChatSelectedModel() ? proto.getAiChatSelectedModel() : null;
+      res.labelFilter = proto.hasLabelFilter() ? proto.getLabelFilter() : null;
       return res;
     }
 
@@ -295,6 +297,12 @@
       builder =
           setEnumIfNotNull(
               builder,
+              builder::setResponsiveMode,
+              UserPreferences.DiffPreferencesInfo.ResponsiveMode::valueOf,
+              info.responsiveMode);
+      builder =
+          setEnumIfNotNull(
+              builder,
               builder::setIgnoreWhitespace,
               UserPreferences.DiffPreferencesInfo.Whitespace::valueOf,
               info.ignoreWhitespace);
@@ -330,6 +338,17 @@
       res.hideEmptyPane = proto.hasHideEmptyPane() ? proto.getHideEmptyPane() : null;
       res.matchBrackets = proto.hasMatchBrackets() ? proto.getMatchBrackets() : null;
       res.lineWrapping = proto.hasLineWrapping() ? proto.getLineWrapping() : null;
+      if (proto.hasResponsiveMode()) {
+        res.responsiveMode =
+            DiffPreferencesInfo.ResponsiveMode.valueOf(proto.getResponsiveMode().name());
+      } else if (proto.hasLineWrapping()) {
+        res.responsiveMode =
+            proto.getLineWrapping()
+                ? DiffPreferencesInfo.ResponsiveMode.FULL_RESPONSIVE
+                : DiffPreferencesInfo.ResponsiveMode.NONE;
+      } else {
+        res.responsiveMode = null;
+      }
       res.ignoreWhitespace =
           proto.hasIgnoreWhitespace()
               ? DiffPreferencesInfo.Whitespace.valueOf(proto.getIgnoreWhitespace().name())
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 22c6995..dc52f87 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -33,7 +33,6 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.UnsupportedEncodingException;
 import java.net.URL;
 import java.nio.charset.Charset;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -97,7 +96,7 @@
     return optionsExt;
   }
 
-  public byte[] markdownToDocHtml(String md, String charEnc) throws UnsupportedEncodingException {
+  public byte[] markdownToDocHtml(String md, String charEnc) {
     Node root = parseMarkdown(md);
     HtmlRenderer renderer = HtmlRenderer.builder(markDownOptions()).build();
     String title = findTitle(root);
@@ -119,7 +118,7 @@
     html.append("<body>\n");
     html.append(renderer.render(root));
     html.append("\n</body></html>");
-    return html.toString().getBytes(charEnc);
+    return html.toString().getBytes(Charset.forName(charEnc));
   }
 
   public String extractTitleFromMarkdown(byte[] data, String charEnc) {
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index e0bc112..70cc1c4 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
@@ -37,6 +39,9 @@
   public RevCommit commit;
   public IdentifiedUser user;
 
+  /** The source change and patch set that this commit was cherry-picked from (if any) */
+  @Nullable public PatchSet.Id cherryPickOf;
+
   /**
    * Use this for computing the modified files of the received commits. Using {@link
    * com.google.gerrit.server.patch.DiffOperations} from commit validators is not safe, see javadoc
@@ -57,6 +62,7 @@
       ObjectReader reader,
       ObjectId commitId,
       IdentifiedUser user,
+      @Nullable PatchSet.Id cherryPickOf,
       DiffOperationsForCommitValidation diffOperations)
       throws IOException {
     this();
@@ -68,6 +74,7 @@
     this.revWalk = new RevWalk(reader);
     this.commit = revWalk.parseCommit(commitId);
     this.user = user;
+    this.cherryPickOf = cherryPickOf;
     this.diffOperations = diffOperations;
     revWalk.parseBody(commit);
   }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 6380db3..fd571fc 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -62,9 +62,14 @@
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.Inject;
@@ -90,6 +95,7 @@
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final DiffOperations diffOperations;
+  private final PatchListCache patchListCache;
   private final Emails emails;
   private final Provider<PersonIdent> myIdent;
   private final ChangeData.Factory changeDataFactory;
@@ -105,6 +111,7 @@
       Emails emails,
       DynamicItem<UrlFormatter> urlFormatter,
       DiffOperations diffOperations,
+      PatchListCache patchListCache,
       @GerritPersonIdent Provider<PersonIdent> myIdent,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
@@ -116,6 +123,7 @@
     this.urlFormatter = urlFormatter;
     this.emails = emails;
     this.diffOperations = diffOperations;
+    this.patchListCache = patchListCache;
     this.myIdent = myIdent;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
@@ -353,7 +361,7 @@
   private void addCommitMessage(ChangeAttribute changeAttribute, ChangeNotes notes) {
     try {
       addCommitMessage(changeAttribute, changeDataFactory.create(notes).commitMessage());
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atSevere().withCause(e).log(
           "Error while getting full commit message for change %d", changeAttribute.number);
     }
@@ -460,7 +468,6 @@
     p.ref = patchSet.refName();
     p.uploader = asAccountAttribute(patchSet.uploader(), accountLoader);
     p.createdOn = patchSet.createdOn().getEpochSecond();
-    PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
       RevCommit c = revWalk.parseCommit(ObjectId.fromString(p.revision));
@@ -478,20 +485,20 @@
         p.author = asAccountAttribute(author.getAccount(), accountLoader);
       }
 
-      Map<String, FileDiffOutput> modifiedFiles =
-          diffOperations.listModifiedFilesAgainstParent(
-              changeData.project(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
-      for (FileDiffOutput fileDiff : modifiedFiles.values()) {
-        p.sizeDeletions += fileDiff.deletions();
-        p.sizeInsertions += fileDiff.insertions();
-      }
+      ChangedLines changedLines =
+          patchListCache
+              .getDiffSummary(
+                  DiffSummaryKey.fromPatchListKey(
+                      PatchListKey.againstBase(patchSet.commitId(), c.getParentCount())),
+                  changeData.project())
+              .getChangedLines();
+      p.sizeDeletions = changedLines.deletions;
+      p.sizeInsertions = changedLines.insertions;
       p.kind =
           changeKindCache.getChangeKind(
               revWalk, repoConfig, attributesNodeProvider, changeData, patchSet);
-    } catch (IOException | StorageException e) {
+    } catch (IOException | StorageException | PatchListNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
-    } catch (DiffNotAvailableException e) {
-      logger.atSevere().withCause(e).log("Cannot get size information for %s.", pId);
     }
     return p;
   }
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 9156f61..30c0ffe 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -26,41 +26,14 @@
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
 
-  /**
-   * If true, gerrit checks implicit merges on each merge operations.
-   *
-   * <p>If only this option is set (without {@link
-   * #GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE}) - then the outcome of the check is
-   * only logged and doesn't block merge operation. Any exceptions during the check are logged and
-   * doesn't block merge operation.
-   */
-  public static String GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE =
-      "GerritBackendFeature__check_implicit_merges_on_merge";
-
-  /**
-   * If true, gerrit rejects implicit merges on merge.
-   *
-   * <p>Should work together with {@link #GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE}.
-   *
-   * <p>If {@link #GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE} is set to true
-   * then implicit merges are rejected even if rejectImplicitMerges in project config is set to
-   * false.
-   *
-   * <p>If {@link #GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE} is set to false
-   * then implicit merges are rejected only if rejectImplicitMerges in project config is set to
-   * true.
-   */
-  public static String GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE =
-      "GerritBackendFeature__reject_implicit_merges_on_merge";
-
-  /** If true, gerrit ignores rejectImplicitMerges setting from the project config on merge. */
-  public static String GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE =
-      "GerritBackendFeature__always_reject_implicit_merges_on_merge";
-
   /** Whether we allow fix suggestions in HumanComments. */
   public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
       "GerritBackendFeature__allow_fix_suggestions_in_comments";
 
+  /** Whether to enforce a timeout during file diff computation. */
+  public static final String TIMEOUT_FILE_DIFF_COMPUTATION =
+      "GerritBackendFeature__timeout_file_diff_computation";
+
   /** Whether submit_records should only be returned along with submit_requirements. */
   public static final String SKIP_SUBMIT_RECORDS_WITHOUT_SUBMIT_REQUIREMENTS =
       "GerritBackendFeature__skip_submit_records_without_submit_requirements";
@@ -68,4 +41,8 @@
   /** Whether to consider votes of deleted accounts. */
   public static final String CONSIDER_VOTES_OF_DELETED_ACCOUNTS =
       "GerritBackendFeature__consider_votes_of_deleted_accounts";
+
+  /** Whether we restrict the creation of branch permissions. */
+  public static final String GERRIT_BACKEND_FEATURE_RESTRICT_BRANCH_PERMISSIONS =
+      "GerritBackendFeature__restrict_branch_permissions";
 }
diff --git a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
index 99babce..26aea43 100644
--- a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
+++ b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
@@ -95,7 +95,7 @@
           new Event(
               util.changeInfo(changeData), util.accountInfo(accountState), added, removed, when);
       listeners.runEach(l -> l.onAttentionSetChanged(event));
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atSevere().withCause(e).log("Exception while firing AttentionSet changed event");
     }
   }
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index b64d872..9c94954 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -204,7 +204,7 @@
               update(cached, cd);
               anyUpdated = true;
             }
-          } catch (Exception ex) {
+          } catch (RuntimeException ex) {
             anyUpdated = true;
             // Do not let a bad change prevent other changes from being available.
             logger.atFinest().withCause(ex).log("Can't load changeData for %s", id);
@@ -256,7 +256,7 @@
           cd.setReviewers(pc.reviewers());
           cd.setMetaRevision(pc.metaRevision());
           cdByChange.put(cd.getId(), cd);
-        } catch (Exception ex) {
+        } catch (RuntimeException ex) {
           // Do not let a bad change prevent other changes from being available.
           logger.atFinest().withCause(ex).log("Can't load changeData for %s", pc.change().getId());
         }
@@ -269,7 +269,7 @@
           Change.Id id = e2.getKey();
           try {
             cdByChange.put(id, cdFactory.createNonPrivate(branch, id, e2.getValue()));
-          } catch (Exception ex) {
+          } catch (RuntimeException ex) {
             // Do not let a bad change prevent other changes from being available.
             logger.atFinest().withCause(ex).log("Can't load changeData for %s", id);
           }
@@ -519,7 +519,7 @@
         }
 
         return result;
-      } catch (Exception e) {
+      } catch (RuntimeException e) {
         logger.atWarning().withCause(e).log("Failed to deserialize CachedProjectChanges");
         return new CachedProjectChanges();
       }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 58d862e..0eb142d 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
@@ -115,6 +116,7 @@
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
   private final MessageIdGenerator messageIdGenerator;
+  private final AccountCache accountCache;
 
   @Inject
   CommitUtil(
@@ -129,7 +131,8 @@
       ChangeNotes.Factory changeNotesFactory,
       ChangeReverted changeReverted,
       BatchUpdate.Factory updateFactory,
-      MessageIdGenerator messageIdGenerator) {
+      MessageIdGenerator messageIdGenerator,
+      AccountCache accountCache) {
     this.repoManager = repoManager;
     this.serverIdent = serverIdent;
     this.seq = seq;
@@ -142,6 +145,7 @@
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
     this.messageIdGenerator = messageIdGenerator;
+    this.accountCache = accountCache;
   }
 
   public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
@@ -473,12 +477,25 @@
         ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
-
     Set<Account.Id> reviewers = new HashSet<>();
-    reviewers.add(changeToRevert.getOwner());
-    reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
+    if (accountExists(changeToRevert.getOwner())) {
+      reviewers.add(changeToRevert.getOwner());
+    }
+    for (Account.Id reviewer : reviewerSet.byState(ReviewerStateInternal.REVIEWER)) {
+      // Add the original reviewers only if they exist, to avoid adding deleted accounts.
+      if (accountExists(reviewer)) {
+        reviewers.add(reviewer);
+      }
+    }
     reviewers.remove(user.getAccountId());
-    Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
+
+    Set<Account.Id> ccs = new HashSet<>();
+    for (Account.Id cc : reviewerSet.byState(ReviewerStateInternal.CC)) {
+      // Add the original CCs only if they exist, to avoid adding deleted accounts.
+      if (accountExists(cc)) {
+        ccs.add(cc);
+      }
+    }
     ccs.remove(user.getAccountId());
     ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     ins.setRevertOf(notes.getChangeId());
@@ -515,6 +532,10 @@
     bu.addOp(revertedChangeId, new PostRevertedMessageOp(revertingChangeKey));
   }
 
+  private boolean accountExists(Account.Id accountId) {
+    return accountCache.get(accountId).isPresent();
+  }
+
   private class ChangeRevertedNotifyOp implements BatchUpdateOp {
     private final Change.Id revertedChangeId;
     private final Change.Id revertingChangeId;
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 6209854..888205d 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -64,6 +64,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Predicate;
 import org.apache.commons.lang3.mutable.MutableBoolean;
 import org.eclipse.jgit.lib.Config;
 
@@ -293,10 +294,16 @@
   }
 
   public <T> List<T> getTaskInfos(TaskInfoFactory<T> factory) {
+    return getTaskInfos(task -> true, factory);
+  }
+
+  public <T> List<T> getTaskInfos(Predicate<Task<?>> filter, TaskInfoFactory<T> factory) {
     List<T> taskInfos = new ArrayList<>();
     for (Executor exe : queues) {
       for (Task<?> task : exe.getTasks()) {
-        taskInfos.add(factory.getTaskInfo(task));
+        if (filter.test(task)) {
+          taskInfos.add(factory.getTaskInfo(task));
+        }
       }
     }
     return taskInfos;
@@ -891,6 +898,11 @@
       return executor.queueName;
     }
 
+    private boolean isWaitingToStart() {
+      State state = runningState.get();
+      return state == State.READY || state == State.PARKED;
+    }
+
     @Override
     @CanIgnoreReturnValue
     public boolean cancel(boolean mayInterruptIfRunning) {
@@ -906,6 +918,8 @@
           if (runningState.compareAndSet(null, State.RUNNING)) {
             isSetRunningDuringCancellation = true;
             ((CancelableRunnable) runnable).cancel();
+          } else if (isWaitingToStart()) {
+            ((CancelableRunnable) runnable).cancel();
           } else if (runnable instanceof CanceledWhileRunning) {
             ((CanceledWhileRunning) runnable).setCanceledWhileRunning();
           }
@@ -995,11 +1009,11 @@
     private void setThreadName(String oldThreadName) {
       try {
         Thread.currentThread().setName(oldThreadName + "[" + this + "]");
-      } catch (Exception e) {
+      } catch (RuntimeException e) {
         logger.atWarning().withCause(e).log("Cannot describe task");
         try {
           Thread.currentThread().setName(oldThreadName + "[" + runnable.getClass().getName() + "]");
-        } catch (Exception e2) {
+        } catch (RuntimeException e2) {
           logger.atWarning().withCause(e2).log("Cannot get runnable class name");
           Thread.currentThread().setName(oldThreadName + "[unknown task]");
         }
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 5db5c31..291e15b 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -422,7 +422,7 @@
         receivePack.sendError("timeout while processing changes");
         rejectCommandsNotAttempted(commands);
         return;
-      } catch (Exception e) {
+      } catch (RuntimeException e) {
         logger.atSevere().withCause(e.getCause()).log("error while processing push");
         receivePack.sendError("internal error");
         rejectCommandsNotAttempted(commands);
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index d0c32f4..0d02bb9 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -182,6 +182,7 @@
               objectReader,
               commit,
               user,
+              change != null ? change.getCherryPickOf() : null,
               diffOperationsForCommitValidation)) {
         CommitValidators validators;
         if (isMerged) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 848e259..5afea4c 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -215,7 +215,6 @@
 import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.io.StringWriter;
-import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -283,6 +282,7 @@
   public static final String DIRECT_PUSH_JUSTIFICATION_OPTION = "push-justification";
 
   private static final String CUSTOM_KEYED_VALUE_OPTION = "custom-keyed-value";
+  private static final String SILENT_SUFFIX = ":silent";
 
   interface Factory {
     ReceiveCommits create(
@@ -1867,6 +1867,8 @@
     PermissionBackend.ForRef perm;
     Set<String> reviewer = Sets.newLinkedHashSet();
     Set<String> cc = Sets.newLinkedHashSet();
+    Set<String> silentReviewer = Sets.newLinkedHashSet();
+    Set<String> silentCc = Sets.newLinkedHashSet();
     Map<String, Short> labels = new HashMap<>();
     String message;
     List<RevCommit> baseCommit;
@@ -1979,14 +1981,36 @@
         name = "--reviewer",
         aliases = {"-r"},
         metaVar = "REVIEWER",
-        usage = "add reviewer to changes")
-    void reviewer(String str) {
-      reviewer.add(str);
+        usage = "add reviewer to changes (append ':silent' via push option to suppress email)")
+    void reviewer(String str) throws CmdLineException {
+      parseReviewerOrCc(str, "reviewer", reviewer, silentReviewer);
     }
 
-    @Option(name = "--cc", metaVar = "CC", usage = "add CC to changes")
-    void cc(String str) {
-      cc.add(str);
+    @Option(
+        name = "--cc",
+        metaVar = "CC",
+        usage = "add CC to changes (append ':silent' via push option to suppress email)")
+    void cc(String str) throws CmdLineException {
+      parseReviewerOrCc(str, "CC", cc, silentCc);
+    }
+
+    // Note: The ":silent" suffix is only supported when passed via push options (-o) because
+    // colons (":") cannot be used in Git ref names or refspecs (they serve as the <src>:<dst>
+    // delimiter and are rejected by git-check-ref-format client-side).
+    private void parseReviewerOrCc(
+        String str, String optionName, Set<String> normalSet, Set<String> silentSet)
+        throws CmdLineException {
+      if (str.endsWith(SILENT_SUFFIX)) {
+        String name = str.substring(0, str.length() - SILENT_SUFFIX.length());
+        if (name.isEmpty()) {
+          throw cmdLineParser.reject(optionName + " identifier cannot be empty");
+        }
+        // If an account is specified as silent, keep it silent even if also specified normally.
+        silentSet.add(name);
+        normalSet.remove(name);
+      } else if (!silentSet.contains(str)) {
+        normalSet.add(str);
+      }
     }
 
     @Option(
@@ -2017,12 +2041,9 @@
       message = token.replace("_", " ");
       try {
         // Other characters can be represented using percent-encoding.
-        message = URLDecoder.decode(message, UTF_8.name());
+        message = URLDecoder.decode(message, UTF_8);
       } catch (IllegalArgumentException e) {
         // Ignore decoding errors; leave message as percent-encoded.
-      } catch (UnsupportedEncodingException e) {
-        // This shouldn't happen; surely URLDecoder recognizes UTF-8.
-        throw new IllegalStateException(e);
       }
     }
 
@@ -2074,6 +2095,15 @@
     }
 
     /**
+     * Get silent reviewer strings from magic branch options
+     *
+     * @return set of silent reviewer strings to pass to {@code ReviewerModifier}.
+     */
+    ImmutableSet<String> getSilentReviewers() {
+      return ImmutableSet.copyOf(silentReviewer);
+    }
+
+    /**
      * Get CC strings from magic branch options
      *
      * <p>The set of CCs on a change includes strings passed explicitly via options
@@ -2081,7 +2111,20 @@
      * @return set of CC strings to pass to {@code ReviewerModifier}.
      */
     ImmutableSet<String> getCcs() {
-      return ImmutableSet.copyOf(cc);
+      return ImmutableSet.copyOf(Sets.difference(cc, reviewersAndSilentReviewers()));
+    }
+
+    /**
+     * Get silent CC strings from magic branch options
+     *
+     * @return set of silent CC strings to pass to {@code ReviewerModifier}.
+     */
+    ImmutableSet<String> getSilentCcs() {
+      return ImmutableSet.copyOf(Sets.difference(silentCc, reviewersAndSilentReviewers()));
+    }
+
+    private Set<String> reviewersAndSilentReviewers() {
+      return Sets.union(reviewer, silentReviewer);
     }
 
     void setWithholdComments(boolean withholdComments) {
@@ -2639,7 +2682,8 @@
 
       if (idList.isEmpty()) {
         messages.add(
-            new ValidationMessage("warning: pushing without Change-Id is deprecated", false));
+            new ValidationMessage(
+                "pushing without Change-Id is deprecated", ValidationMessage.Type.WARNING));
         break;
       }
     }
@@ -2745,6 +2789,12 @@
                 "Creating new change for %s even though it is already tracked", name);
           }
 
+          Change change = null;
+          ChangeLookup lookup = pending.get(c);
+          if (lookup.changeKey != null && lookup.destChanges.size() == 1) {
+            change = lookup.destChanges.getFirst().change();
+          }
+
           // Validate the received commits. Do not invoke the CommitValidationInfoListener's yet
           // because we create changes/patch-sets for the commits only later and we need to provide
           // the patch set ID, that we don't know yet, to CommitValidationInfoListener's.
@@ -2759,7 +2809,7 @@
                   magicBranch.merged,
                   rejectCommits,
                   /* invokeCommitValidationInfoListeners= */ false,
-                  /* change= */ null);
+                  change);
           validationInfosByCommit.put(c.name(), validationResult.validationInfos());
           messages.addAll(validationResult.messages());
           if (!validationResult.isValid()) {
@@ -3155,8 +3205,18 @@
           }
 
           bu.setNotify(magicBranch.getNotifyForNewChange());
+          logger.atFine().log(
+              "Inserting change with reviewers %s (silent: %s) and ccs %s (silent: %s)",
+              magicBranch.getReviewers(),
+              magicBranch.getSilentReviewers(),
+              magicBranch.getCcs(),
+              magicBranch.getSilentCcs());
           bu.insertChange(
-              ins.setReviewersAndCcsAsStrings(magicBranch.getReviewers(), magicBranch.getCcs())
+              ins.setReviewersAndCcsAsStrings(
+                      magicBranch.getReviewers(),
+                      magicBranch.getCcs(),
+                      magicBranch.getSilentReviewers(),
+                      magicBranch.getSilentCcs())
                   .setApprovals(approvals)
                   .setMessage(msg.toString())
                   .setRequestScopePropagator(requestScopePropagator)
@@ -3557,11 +3617,12 @@
           if (messageEq && parentsEq && authorEq) {
             addMessage(
                 String.format(
-                    "warning: no changes between prior commit %s and new commit %s",
-                    abbreviateName(priorCommit, reader), abbreviateName(newCommit, reader)));
+                    "no changes between prior commit %s and new commit %s",
+                    abbreviateName(priorCommit, reader), abbreviateName(newCommit, reader)),
+                ValidationMessage.Type.WARNING);
           } else {
             StringBuilder msg = new StringBuilder();
-            msg.append("warning: ").append(abbreviateName(newCommit, reader));
+            msg.append("commit ").append(abbreviateName(newCommit, reader));
             msg.append(":");
             msg.append(" no files changed");
             if (!authorEq) {
@@ -3573,7 +3634,7 @@
             if (!parentsEq) {
               msg.append(", was rebased");
             }
-            addMessage(msg.toString());
+            addMessage(msg.toString(), ValidationMessage.Type.WARNING);
           }
         }
       }
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 90fa19a..6d07735 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -28,6 +28,7 @@
 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.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -285,6 +286,7 @@
               ctx.getRevWalk().getObjectReader(),
               commitId,
               ctx.getIdentifiedUser(),
+              change.getCherryPickOf(),
               diffOperationsForCommitValidationFactory.create(
                   ctx.getRepoView(), ctx.getInserter()))) {
         commitValidationInfoListeners.runEach(
@@ -432,24 +434,21 @@
           Streams.concat(
               inputs,
               magicBranch.getReviewers().stream()
-                  .map(r -> newReviewerInput(r, ReviewerState.REVIEWER)),
-              magicBranch.getCcs().stream().map(r -> newReviewerInput(r, ReviewerState.CC)));
+                  .map(r -> newReviewerInput(r, ReviewerState.REVIEWER, false)),
+              magicBranch.getSilentReviewers().stream()
+                  .map(r -> newReviewerInput(r, ReviewerState.REVIEWER, true)),
+              magicBranch.getCcs().stream().map(r -> newReviewerInput(r, ReviewerState.CC, false)),
+              magicBranch.getSilentCcs().stream()
+                  .map(r -> newReviewerInput(r, ReviewerState.CC, true)));
     }
 
     return inputs.collect(toImmutableList());
   }
 
-  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
-    // Disable individual emails when adding reviewers, as all reviewers will receive the single
-    // bulk new patch set email.
-    InternalReviewerInput input =
-        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
-
-    // Ignore failures for reasons like the reviewer being inactive or being unable to see the
-    // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
-
-    return input;
+  private static InternalReviewerInput newReviewerInput(
+      String reviewer, ReviewerState state, boolean silent) {
+    return ReviewerModifier.newReviewerInput(
+        reviewer, state, /* skipVisibilityCheck= */ false, silent);
   }
 
   private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage) {
@@ -555,6 +554,20 @@
 
     reviewerAdditions.postUpdate(ctx);
 
+    ImmutableSet<Account.Id> addedReviewers =
+        reviewerAdditions
+            .flattenResults(ReviewerOp.Result::addedReviewers, ReviewerModification.NOT_SILENT)
+            .stream()
+            .map(PatchSetApproval::accountId)
+            .collect(toImmutableSet());
+    ImmutableSet<Account.Id> allAddedReviewerIds =
+        reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
+            .map(PatchSetApproval::accountId)
+            .collect(toImmutableSet());
+    ImmutableSet<Account.Id> addedCcs =
+        reviewerAdditions.flattenResults(
+            ReviewerOp.Result::addedCCs, ReviewerModification.NOT_SILENT);
+
     // TODO(dborowitz): Merge email templates so we only have to send one.
     emailNewPatchSetFactory
         .create(
@@ -564,14 +577,16 @@
             approvalCopierResult.outdatedApprovals().stream()
                 .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
                 .collect(toImmutableSet()),
-            Streams.concat(
-                    oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::accountId))
+            Streams.concat(oldRecipients.getReviewers().stream(), addedReviewers.stream())
                 .collect(toImmutableSet()),
             Streams.concat(
-                    oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
+                    // Existing CCs continue to receive patch set updates per the active
+                    // notification policy unless they are being promoted to a reviewer (in
+                    // which case addedReviewers determines whether they receive an email).
+                    // Silent CC additions only mute the addition notification for newly added CCs.
+                    oldRecipients.getCcOnly().stream()
+                        .filter(ccId -> !allAddedReviewerIds.contains(ccId)),
+                    addedCcs.stream())
                 .collect(toImmutableSet()),
             changeKind,
             notes.getMetaId())
@@ -583,7 +598,7 @@
         ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
       fireApprovalsEvent(ctx);
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atWarning().withCause(e).log("comment-added event invocation failed");
     }
     if (mergedByPushOp != null) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 4a8172f..0e99361 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -526,6 +525,9 @@
   /** Limits the number of files per change. */
   private static class FileCountValidator implements CommitValidationListener {
 
+    // Threshold above which a warning is logged and recorded in the validation/file_count metric.
+    // Note that because rename detection is disabled during validation for performance reasons,
+    // file renames count as two modifications (1 deletion + 1 addition).
     private static final int FILE_COUNT_WARNING_THRESHOLD = 10_000;
 
     private final int maxFileCount;
@@ -539,7 +541,9 @@
               "validation/file_count",
               new Description("Count commits with many files per change."),
               Field.ofInteger("file_count", (meta, value) -> {})
-                  .description("number of files in the patchset")
+                  .description(
+                      "number of modified files in the patchset (without rename detection; file"
+                          + " renames count as 1 deletion + 1 addition)")
                   .build(),
               Field.ofString("host_repo", (meta, value) -> {})
                   .description("host and repository of the change in the format 'host/repo'")
@@ -593,19 +597,17 @@
 
     private int countChangedFiles(CommitReceivedEvent receiveEvent)
         throws DiffNotAvailableException {
-      // For merge commits this will compare against auto-merge.
+      // For merge commits this compares against auto-merge. Do not detect renames; detecting
+      // renames requires reading file contents and computing pairwise similarity, which is a
+      // significant performance bottleneck for changes with many files. Note that without rename
+      // detection, a renamed file counts as 2 changed files (1 deletion + 1 addition).
       Map<String, ModifiedFile> modifiedFiles =
           receiveEvent.diffOperations.loadModifiedFilesAgainstParentIfNecessary(
               receiveEvent.getProjectNameKey(),
               receiveEvent.commit,
               0,
-              /* enableRenameDetection= */ true);
-      // We don't want to count the COMMIT_MSG and MERGE_LIST files.
-      List<ModifiedFile> modifiedFilesList =
-          modifiedFiles.values().stream()
-              .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
-              .collect(Collectors.toList());
-      return modifiedFilesList.size();
+              /* enableRenameDetection= */ false);
+      return modifiedFiles.size();
     }
   }
 
diff --git a/java/com/google/gerrit/server/index/GerritIndexStatus.java b/java/com/google/gerrit/server/index/GerritIndexStatus.java
index 9f0622e..3be4ab5 100644
--- a/java/com/google/gerrit/server/index/GerritIndexStatus.java
+++ b/java/com/google/gerrit/server/index/GerritIndexStatus.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.index;
 
 import com.google.common.primitives.Ints;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import java.io.IOException;
+import java.nio.file.Path;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -32,10 +32,8 @@
 
   private final FileBasedConfig cfg;
 
-  public GerritIndexStatus(SitePaths sitePaths) throws ConfigInvalidException, IOException {
-    cfg =
-        new FileBasedConfig(
-            sitePaths.index_dir.resolve("gerrit_index.config").toFile(), FS.detect());
+  public GerritIndexStatus(Path indexDir) throws ConfigInvalidException, IOException {
+    cfg = new FileBasedConfig(indexDir.resolve("gerrit_index.config").toFile(), FS.detect());
     cfg.load();
     convertLegacyConfig();
   }
diff --git a/java/com/google/gerrit/server/index/IndexDir.java b/java/com/google/gerrit/server/index/IndexDir.java
new file mode 100644
index 0000000..499c237
--- /dev/null
+++ b/java/com/google/gerrit/server/index/IndexDir.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2026 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.index;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on {@link java.nio.file.Path} holding the resolved Gerrit secondary index directory.
+ *
+ * <p>Honors {@code index.directory} in {@code gerrit.config} if set, defaulting to {@code
+ * site_path/index} otherwise.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface IndexDir {}
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index b5a7bc9..c29c08e 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 
+import com.google.common.base.Strings;
 import com.google.common.base.Ticker;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -35,14 +36,12 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexDefinition;
-import com.google.gerrit.server.index.account.AccountIndexRewriter;
-import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.index.account.AccountIndexerImpl;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -50,6 +49,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.change.PendingIndexUpdateScanner;
 import com.google.gerrit.server.index.change.StalenessChecker;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexDefinition;
@@ -67,9 +67,14 @@
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.multibindings.OptionalBinder;
+import java.io.IOException;
+import java.nio.file.Path;
 import java.util.Collection;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
 
 /**
  * Module for non-indexer-specific secondary index setup.
@@ -79,6 +84,7 @@
  */
 @SuppressWarnings("ProvidesMethodOutsideOfModule")
 public class IndexModule extends LifecycleModule {
+  public static final String INDEX = "index";
   public static final ImmutableList<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
       ImmutableList.of(
           AccountSchemaDefinitions.INSTANCE,
@@ -89,7 +95,7 @@
   /** Type of secondary index. */
   public static IndexType getIndexType(Injector injector) {
     Config cfg = injector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    String configValue = cfg != null ? cfg.getString("index", null, "type") : null;
+    String configValue = cfg != null ? cfg.getString(INDEX, null, "type") : null;
     return new IndexType(configValue);
   }
 
@@ -123,10 +129,9 @@
         .setDefault()
         .toInstance(Ticker.systemTicker());
 
-    bind(AccountIndexRewriter.class);
+    // AccountIndexer is bound in AccountNoteDbWriteStorageModule.
     bind(AccountIndexCollection.class);
     listener().to(AccountIndexCollection.class);
-    factory(AccountIndexerImpl.Factory.class);
 
     bind(ChangeIndexRewriter.class);
     bind(ChangeIndexCollection.class);
@@ -134,6 +139,7 @@
     factory(ChangeIndexer.Factory.class);
     factory(StalenessChecker.Factory.class);
     factory(AllChangesIndexer.Factory.class);
+    install(new PendingIndexUpdateScanner.Module());
 
     bind(GroupIndexRewriter.class);
     // GroupIndexCollection is already bound very high up in SchemaModule.
@@ -188,13 +194,6 @@
 
   @Provides
   @Singleton
-  AccountIndexer getAccountIndexer(
-      AccountIndexerImpl.Factory factory, AccountIndexCollection indexes) {
-    return factory.create(indexes);
-  }
-
-  @Provides
-  @Singleton
   ChangeIndexer getChangeIndexer(
       @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
       ChangeIndexer.Factory factory,
@@ -228,8 +227,7 @@
     int threads = this.threads;
     if (threads == 0) {
       threads =
-          config.getInt(
-              "index", null, "threads", Runtime.getRuntime().availableProcessors() / 2 + 1);
+          config.getInt(INDEX, null, "threads", Runtime.getRuntime().availableProcessors() / 2 + 1);
     }
     if (threads < 0) {
       return MoreExecutors.newDirectExecutorService();
@@ -249,7 +247,7 @@
     int threads = this.threads;
     if (threads == 0) {
       threads =
-          config.getInt("index", null, "batchThreads", Runtime.getRuntime().availableProcessors());
+          config.getInt(INDEX, null, "batchThreads", Runtime.getRuntime().availableProcessors());
     }
     if (threads < 0) {
       return MoreExecutors.newDirectExecutorService();
@@ -264,6 +262,35 @@
     return new StalenessChecker(indexes, repoManager, indexConfig);
   }
 
+  @Provides
+  @Singleton
+  @IndexDir
+  Path getIndexDirectory(@GerritServerConfig Config cfg, SitePaths site) {
+    return indexDirectory(cfg, site);
+  }
+
+  /**
+   * Resolves the index directory before the Guice injector exists (e.g. during {@code Init}), by
+   * reading {@code gerrit.config} directly from disk.
+   */
+  public static Path indexDirectory(SitePaths sitePaths) throws IOException {
+    FileBasedConfig cfg = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.DETECTED);
+    if (cfg.getFile().exists()) {
+      try {
+        cfg.load();
+      } catch (ConfigInvalidException e) {
+        throw new IOException("Invalid config file " + sitePaths.gerrit_config, e);
+      }
+    }
+    return indexDirectory(cfg, sitePaths);
+  }
+
+  /** Resolves the index directory from an already-loaded {@code gerrit.config}. */
+  public static Path indexDirectory(Config cfg, SitePaths site) {
+    String name = cfg.getString(INDEX, null, "directory");
+    return Strings.isNullOrEmpty(name) ? site.resolve(INDEX) : site.resolve(name);
+  }
+
   @Singleton
   private static class ShutdownIndexExecutors implements LifecycleListener {
     private final ListeningExecutorService interactiveExecutor;
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index f81a9ce..8c07af3 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -25,11 +25,11 @@
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
+import java.nio.file.Path;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -37,9 +37,9 @@
 public final class IndexUtils {
 
   /** Mark an index version as ready to serve queries. */
-  public static void setReady(SitePaths sitePaths, String name, int version, boolean ready) {
+  public static void setReady(Path indexDir, String name, int version, boolean ready) {
     try {
-      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      GerritIndexStatus cfg = new GerritIndexStatus(indexDir);
       cfg.setReady(name, version, ready);
       cfg.save();
     } catch (ConfigInvalidException | IOException e) {
diff --git a/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
index d4f55ba..e3c1c96 100644
--- a/java/com/google/gerrit/server/index/VersionManager.java
+++ b/java/com/google/gerrit/server/index/VersionManager.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.ProvisionException;
 import java.io.IOException;
+import java.nio.file.Path;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -99,6 +100,7 @@
   protected final boolean reuseExistingDocuments;
   protected final String runReindexMsg;
   protected final SitePaths sitePaths;
+  protected final Path indexDir;
 
   private final PluginSetContext<OnlineUpgradeListener> listeners;
 
@@ -108,11 +110,13 @@
 
   protected VersionManager(
       SitePaths sitePaths,
+      Path indexDir,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs,
       boolean onlineUpgrade,
       boolean reuseExistingDocuments) {
     this.sitePaths = sitePaths;
+    this.indexDir = indexDir;
     this.listeners = listeners;
     this.defs = Maps.newHashMapWithExpectedSize(defs.size());
     for (IndexDefinition<?, ?, ?> def : defs) {
@@ -267,7 +271,7 @@
 
   protected GerritIndexStatus createIndexStatus() {
     try {
-      return new GerritIndexStatus(sitePaths);
+      return new GerritIndexStatus(indexDir);
     } catch (ConfigInvalidException | IOException e) {
       throw fail(e);
     }
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 1f48e35..9e60be7 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -106,7 +106,7 @@
                   }
                   verboseWriter.println("Reindexed " + desc);
                   done.incrementAndGet();
-                } catch (Exception e) {
+                } catch (RuntimeException e) {
                   failed.incrementAndGet();
                   throw e;
                 }
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 52f3844..6ef9b03 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -351,7 +351,7 @@
       } catch (RejectedExecutionException e) {
         // Server shutdown, don't spam the logs.
         failSilently();
-      } catch (Exception e) {
+      } catch (RuntimeException e) {
         fail("Failed to index change " + r.id(), true, e);
       }
     }
@@ -513,7 +513,7 @@
                 logger.atInfo().log("Removing %d changes from index", changesInIndex.size());
                 for (Change.Id id : changesInIndex) {
                   logger.atFine().log("Deleting change %s from index", id);
-                  indexer.delete(id);
+                  indexer.delete(name, id);
                 }
               }
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 75142ac..7d71b79 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_CHANGE_NUMBER;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
@@ -141,7 +140,7 @@
           .build(cd -> cd.getId().get());
 
   public static final IndexedField<ChangeData, Integer>.SearchSpec CHANGENUM_SPEC =
-      CHANGENUM_FIELD.integer(FIELD_CHANGE_NUMBER);
+      CHANGENUM_FIELD.integer(ChangeQueryBuilder.FIELD_CHANGE_NUMBER);
 
   /** Newer style Change-Id key. */
   public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
@@ -258,6 +257,14 @@
     return r;
   }
 
+  public static final IndexedField<ChangeData, Integer> FILE_COUNT =
+      IndexedField.<ChangeData>integerBuilder("FileCount")
+          .stored()
+          .build(cd -> cd.currentFilePaths().size());
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec FILE_COUNT_SPEC =
+      FILE_COUNT.integerRange(ChangeQueryBuilder.FIELD_FILE_COUNT);
+
   /** Hashtags tied to a change */
   public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
       IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
@@ -1632,6 +1639,12 @@
                   + ","
                   + srResult.submitRequirement().name().toLowerCase(Locale.US));
         }
+        case TIMEOUT -> {
+          result.add(
+              SubmitRecord.Label.Status.NEED.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase(Locale.US));
+        }
         case NOT_APPLICABLE, ERROR ->
             result.add(
                 SubmitRecord.Label.Status.IMPOSSIBLE.name()
@@ -1668,6 +1681,51 @@
       STORED_SUBMIT_REQUIREMENTS_SPEC =
           STORED_SUBMIT_REQUIREMENTS_FIELD.storedOnly("full_submit_requirements");
 
+  /**
+   * Names of submit requirements that are not fulfilled for a change (i.e., whose evaluation status
+   * is {@link com.google.gerrit.entities.SubmitRequirementResult.Status#UNSATISFIED}, {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#ERROR}, or {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#TIMEOUT}).
+   *
+   * <p>Note that if evaluating a submit requirement results in an ERROR or TIMEOUT, it is
+   * considered unfulfilled and its name will be included in this field.
+   */
+  public static final IndexedField<ChangeData, Iterable<String>> UNMET_REQUIREMENT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("UnmetRequirement")
+          .build(
+              cd ->
+                  cd.submitRequirementsIncludingLegacy().values().stream()
+                      .filter(sr -> !sr.fulfilled())
+                      .map(sr -> sr.submitRequirement().name().toLowerCase(Locale.US))
+                      .collect(toImmutableSet()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec UNMET_REQUIREMENT_SPEC =
+      UNMET_REQUIREMENT_FIELD.exact("unmet_requirement");
+
+  /**
+   * The number of submit requirements that are not fulfilled for a change (i.e., whose evaluation
+   * status is {@link com.google.gerrit.entities.SubmitRequirementResult.Status#UNSATISFIED}, {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#ERROR}, or {@link
+   * com.google.gerrit.entities.SubmitRequirementResult.Status#TIMEOUT}).
+   *
+   * <p>Note that if evaluating a submit requirement results in an ERROR or TIMEOUT, it is
+   * considered unfulfilled and is counted here.
+   */
+  public static final IndexedField<ChangeData, Integer> UNSATISFIED_REQUIREMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("UnsatisfiedRequirementCount")
+          .stored()
+          .build(
+              cd ->
+                  (int)
+                      cd.submitRequirementsIncludingLegacy().values().stream()
+                          .filter(sr -> !sr.fulfilled())
+                          .count(),
+              (cd, field) -> cd.setUnsatisfiedRequirementCount(field));
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec
+      UNSATISFIED_REQUIREMENT_COUNT_SPEC =
+          UNSATISFIED_REQUIREMENT_COUNT_FIELD.integerRange("unsatisfied_requirement_count");
+
   private static void parseSubmitRequirements(
       Iterable<Cache.SubmitRequirementResultProto> values, ChangeData out) {
     out.setSubmitRequirements(
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 843c5de..0a743de 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
@@ -59,6 +60,8 @@
 /** Rewriter that pushes boolean logic into the secondary index. */
 @Singleton
 public class ChangeIndexRewriter implements IndexRewriter<ChangeData> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /** Set of all open change statuses. */
   public static final ImmutableSet<Change.Status> OPEN_STATUSES;
 
@@ -149,6 +152,10 @@
       throws QueryParseException {
     Predicate<ChangeData> s = rewriteImpl(in, opts);
     if (!(s instanceof ChangeDataSource)) {
+      logger.atFine().log(
+          "Query rewrite did not produce a ChangeDataSource; falling back to full-status index"
+              + " scan (and(or(open,closed), ...)). Original query: %s, rewritten: %s",
+          in, s);
       in = Predicate.and(Predicate.or(open(), closed()), in);
       s = rewriteImpl(in, opts);
     }
@@ -183,10 +190,11 @@
    * @param index index whose schema determines which fields are indexed.
    * @param opts other query options.
    * @param leafTerms number of leaf index query terms encountered so far.
-   * @return {@code null} if no part of this subtree can be queried in the index directly. {@code
-   *     in} if this subtree and all its children can be queried directly in the index. Otherwise, a
-   *     predicate that is semantically equivalent, with some of its subtrees wrapped to query the
-   *     index directly.
+   * @return {@code null} if no part of this subtree can be queried directly in the index, the
+   *     original predicate {@code in} if the entire subtree can, or a semantically equivalent
+   *     predicate with the indexed subtrees wrapped in index queries. When none of the children are
+   *     directly indexed, any existing {@link ChangeDataSource} children are preserved intact, as
+   *     can happen for a subtree of plugin data sources.
    * @throws QueryParseException if the underlying index implementation does not support this
    *     predicate.
    */
@@ -285,6 +293,13 @@
       ChangeIndex index,
       QueryOptions opts)
       throws QueryParseException {
+    if (isIndexed.isEmpty()) {
+      // A rewritten child may itself be a ChangeDataSource (for example, an OR of plugin
+      // datasources), while its sibling is a non-indexed ChangeDataSource. Keep that tree intact
+      // rather than adding a match-all index query, which could be selected ahead of the more
+      // selective datasource.
+      return copy(in, newChildren);
+    }
     if (isIndexed.cardinality() == 1) {
       int i = isIndexed.nextSetBit(0);
       Predicate<ChangeData> indexed = newChildren.remove(i);
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index ec27f90..8dfca33 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -50,7 +50,6 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -376,9 +375,9 @@
     }
   }
 
-  private void fireChangeDeletedFromIndexEvent(int id) {
+  private void fireChangeDeletedFromIndexEvent(String projectName, int id) {
     if (notifyListeners) {
-      indexedListeners.runEach(l -> l.onChangeDeleted(id));
+      indexedListeners.runEach(l -> l.onChangeDeleted(projectName, id));
     }
   }
 
@@ -430,7 +429,7 @@
    */
   public ListenableFuture<ChangeData> deleteAsync(Project.NameKey project, Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
-    return submit(new DeleteTask(id, Optional.of(project)));
+    return submit(new DeleteTask(id, project));
   }
 
   /**
@@ -438,9 +437,9 @@
    *
    * @param id change ID to delete.
    */
-  public void delete(Change.Id id) {
+  public void delete(Project.NameKey project, Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
-    doDelete(id);
+    doDelete(project, id);
   }
 
   /**
@@ -457,11 +456,7 @@
   }
 
   private void doDelete(Project.NameKey project, Change.Id id) {
-    new DeleteTask(id, Optional.of(project)).call();
-  }
-
-  private void doDelete(Change.Id id) {
-    new DeleteTask(id, Optional.empty()).call();
+    new DeleteTask(id, project).call();
   }
 
   /**
@@ -507,7 +502,7 @@
         indexImpl(changeDataFactory.create(project, id));
         return true;
       }
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       if (!isCausedByRepositoryNotFoundException(e)) {
         throw e;
       }
@@ -655,9 +650,9 @@
   // Not AbstractIndexTask as it doesn't need a request context.
   private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
-    private final Optional<Project.NameKey> project;
+    private final Project.NameKey project;
 
-    private DeleteTask(Change.Id id, Optional<Project.NameKey> project) {
+    private DeleteTask(Change.Id id, Project.NameKey project) {
       this.id = id;
       this.project = project;
     }
@@ -678,12 +673,7 @@
                     .changeId(id.get())
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
-          // Some index implementation require ProjectKey to build a database key
-          // If delete(K) method is used, this will require changeId -> projectKey lookup (index
-          // query), which is expensive.
-          // Use changeData with ProjectKey and deleteByValue(V) method, if possible
-          project.ifPresentOrElse(
-              p -> i.deleteByValue(changeDataFactory.create(p, id)), () -> i.delete(id));
+          i.deleteByValue(changeDataFactory.create(project, id));
         } catch (RuntimeException e) {
           throw new StorageException(
               String.format(
@@ -692,7 +682,7 @@
               e);
         }
       }
-      fireChangeDeletedFromIndexEvent(id.get());
+      fireChangeDeletedFromIndexEvent(project.name(), id.get());
       return null;
     }
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index aaa7535..f08a7ae 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -274,6 +274,7 @@
   @Deprecated static final Schema<ChangeData> V87 = schema(V86);
 
   /** Add REVIEWERS_COUNT_FIELD */
+  @Deprecated
   static final Schema<ChangeData> V88 =
       new Schema.Builder<ChangeData>()
           .add(V87)
@@ -281,6 +282,25 @@
           .addSearchSpecs(ChangeField.REVIEWER_COUNT_SPEC)
           .build();
 
+  /** Add met and unmet requirement tracking fields */
+  @Deprecated
+  static final Schema<ChangeData> V89 =
+      new Schema.Builder<ChangeData>()
+          .add(V88)
+          .addIndexedFields(
+              ChangeField.UNMET_REQUIREMENT_FIELD, ChangeField.UNSATISFIED_REQUIREMENT_COUNT_FIELD)
+          .addSearchSpecs(
+              ChangeField.UNMET_REQUIREMENT_SPEC, ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC)
+          .build();
+
+  /** Add file count field */
+  static final Schema<ChangeData> V90 =
+      new Schema.Builder<ChangeData>()
+          .add(V89)
+          .addIndexedFields(ChangeField.FILE_COUNT)
+          .addSearchSpecs(ChangeField.FILE_COUNT_SPEC)
+          .build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/PendingIndexUpdate.java b/java/com/google/gerrit/server/index/change/PendingIndexUpdate.java
new file mode 100644
index 0000000..2c67a51
--- /dev/null
+++ b/java/com/google/gerrit/server/index/change/PendingIndexUpdate.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2026 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.index.change;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryNotEmptyException;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Manages the change-index write-ahead intent files under {@code $site_dir/data/pending-index/}.
+ *
+ * <p>Each intent is a file at {@code <data_dir>/<pid>_<start_time>/<threadId>/sha(project, change)}
+ * with the JSON content of {@link Intent}.
+ */
+@Singleton
+public class PendingIndexUpdate {
+  record Intent(String project, int changeId, String operation) {}
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String PROCESS_MARKER =
+      ProcessHandle.current().pid() + "_" + ManagementFactory.getRuntimeMXBean().getStartTime();
+  private static final Gson GSON = new Gson();
+  private final ChangeIndexer indexer;
+  private final boolean enabled;
+  final Path intentDir;
+  final Path buildingDir;
+  final Path runningDir;
+
+  @Inject
+  PendingIndexUpdate(SitePaths sitePaths, ChangeIndexer indexer, @GerritServerConfig Config cfg) {
+    intentDir = sitePaths.data_dir.resolve("pending-index");
+    buildingDir = intentDir.resolve("building");
+    runningDir = intentDir.resolve(PROCESS_MARKER);
+    this.indexer = indexer;
+    this.enabled = computeEnabled(cfg);
+  }
+
+  /** Returns {@code true} if stale change recovery is active for this process. */
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  private static boolean computeEnabled(Config cfg) {
+    if (!cfg.getBoolean("index", null, "staleChangeRecovery", false)) {
+      return false;
+    }
+    if (cfg.getBoolean("index", null, "indexChangesAsync", false)) {
+      logger.atWarning().log(
+          "index.staleChangeRecovery has no effect when index.indexChangesAsync is true;"
+              + " stale change recovery is disabled");
+      return false;
+    }
+    for (String subsection : new String[] {"changes", "changes_open", "changes_closed"}) {
+      long commitWithin = cfg.getLong("index", subsection, "commitWithin", 0L);
+      if (commitWithin != 0) {
+        logger.atWarning().log(
+            "index.staleChangeRecovery has no effect when index.%s.commitWithin is non-zero;"
+                + " stale change recovery is disabled",
+            subsection);
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Returns the per-thread intent directory for {@code threadId}. */
+  public Path threadDir(long threadId) {
+    return runningDir.resolve(String.valueOf(threadId));
+  }
+
+  public String filename(Project.NameKey project, Change.Id changeId) {
+    return Hashing.sha256()
+        .hashString("%s_%s".formatted(project, changeId), StandardCharsets.UTF_8)
+        .toString();
+  }
+
+  public void cleanIfEmpty(Path dir) {
+    try {
+      Files.delete(dir);
+    } catch (NoSuchFileException | DirectoryNotEmptyException ignored) {
+      // Already gone or not empty.
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to delete directory %s", dir);
+    }
+  }
+
+  /** Writes an intent file for the given change under the thread's pending directory. */
+  public void write(long threadId, Project.NameKey project, Change.Id changeId, boolean delete)
+      throws IOException {
+    Files.createDirectories(buildingDir);
+    Path tmp =
+        Files.writeString(
+            Files.createTempFile(buildingDir, null, null),
+            GSON.toJson(new Intent(project.get(), changeId.get(), delete ? "delete" : "index")));
+
+    Path dir = threadDir(threadId);
+    Files.createDirectories(dir);
+    Files.move(tmp, dir.resolve(filename(project, changeId)), StandardCopyOption.ATOMIC_MOVE);
+  }
+
+  /** Deletes the intent file for {@code changeId} under the thread's pending directory. */
+  public void delete(long threadId, Project.NameKey project, Change.Id changeId) {
+    try {
+      Path threadDir = threadDir(threadId);
+      Files.deleteIfExists(threadDir.resolve(filename(project, changeId)));
+      cleanIfEmpty(threadDir);
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed to delete pending index intent for change %s in thread %d", changeId, threadId);
+    }
+  }
+
+  /** Reads the intent file, applies the index operation, then deletes the file. */
+  public void recover(Path file) throws IOException {
+    Intent intent;
+    try {
+      intent = GSON.fromJson(Files.readString(file), Intent.class);
+    } catch (JsonSyntaxException e) {
+      logger.atWarning().withCause(e).log(
+          "Malformed pending index intent, deleting %s", file.getFileName());
+      Files.deleteIfExists(file);
+      return;
+    }
+    if (intent == null
+        || intent.project() == null
+        || intent.operation() == null
+        || intent.changeId() <= 0) {
+      logger.atWarning().log("Malformed pending index intent, deleting %s", file.getFileName());
+      Files.deleteIfExists(file);
+      return;
+    }
+    Project.NameKey project = Project.nameKey(intent.project());
+    try {
+      switch (intent.operation()) {
+        case "delete" -> indexer.delete(project, Change.id(intent.changeId()));
+        case "index" -> indexer.index(project, Change.id(intent.changeId()));
+        default ->
+            logger.atSevere().log(
+                "Unknown operation '%s' in pending index intent: %s", intent.operation(), intent);
+      }
+    } catch (NoSuchChangeException e) {
+      // Ignore silently. change got deleted after intent.
+    } catch (RuntimeException e) {
+      // catch all indexing exceptions to not propagate further.
+      logger.atSevere().withCause(e).log("Exception while recovering index intent: %s", intent);
+    }
+    Files.deleteIfExists(file);
+  }
+}
diff --git a/java/com/google/gerrit/server/index/change/PendingIndexUpdateScanner.java b/java/com/google/gerrit/server/index/change/PendingIndexUpdateScanner.java
new file mode 100644
index 0000000..944fcd4
--- /dev/null
+++ b/java/com/google/gerrit/server/index/change/PendingIndexUpdateScanner.java
@@ -0,0 +1,177 @@
+// Copyright (C) 2026 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.index.change;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.MoreFiles;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/** Background scanner that recovers change index updates missed due to a crash/interrupt. */
+@Singleton
+public final class PendingIndexUpdateScanner implements Runnable, LifecycleListener {
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(PendingIndexUpdateScanner.class);
+    }
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final Duration DEFAULT_SCAN_INTERVAL = Duration.ofMinutes(5);
+
+  private final PendingIndexUpdate pendingIndexUpdate;
+  private final WorkQueue workQueue;
+  private final Duration scanInterval;
+
+  @Inject
+  PendingIndexUpdateScanner(
+      PendingIndexUpdate pendingIndexUpdate, WorkQueue workQueue, @GerritServerConfig Config cfg) {
+    this.pendingIndexUpdate = pendingIndexUpdate;
+    this.workQueue = workQueue;
+    this.scanInterval =
+        Duration.ofMillis(
+            cfg.getTimeUnit(
+                "index",
+                null,
+                "staleChangeRecoveryInterval",
+                DEFAULT_SCAN_INTERVAL.toMillis(),
+                TimeUnit.MILLISECONDS));
+  }
+
+  @Override
+  public void start() {
+    if (!pendingIndexUpdate.isEnabled()) {
+      return;
+    }
+
+    // Remove the in-process intents from previous crash.
+    try {
+      Path buildingDir = pendingIndexUpdate.buildingDir;
+      if (Files.exists(buildingDir)) {
+        MoreFiles.deleteRecursively(buildingDir);
+      }
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Unable to clean up building index directory");
+    }
+
+    // recover intents from previous crash.
+    var unused =
+        workQueue
+            .getDefaultQueue()
+            .submit(
+                () -> {
+                  Path intentDir = pendingIndexUpdate.intentDir;
+                  if (!Files.exists(intentDir)) {
+                    // fresh install or feature newly enabled
+                    return;
+                  }
+
+                  try (DirectoryStream<Path> pidDirs = Files.newDirectoryStream(intentDir)) {
+                    for (Path pidDir : pidDirs) {
+                      if (pendingIndexUpdate.runningDir.equals(pidDir)
+                          || pendingIndexUpdate.buildingDir.equals(pidDir)) {
+                        continue;
+                      }
+
+                      processPidDir(pidDir, true);
+                      pendingIndexUpdate.cleanIfEmpty(pidDir);
+                    }
+                  } catch (Exception e) {
+                    logger.atSevere().withCause(e).log(
+                        "Unable to recover index intents from previous run");
+                  }
+                });
+
+    unused =
+        workQueue
+            .getDefaultQueue()
+            .scheduleWithFixedDelay(
+                this, scanInterval.toMillis(), scanInterval.toMillis(), TimeUnit.MILLISECONDS);
+  }
+
+  @Override
+  public void stop() {}
+
+  @Override
+  public void run() {
+    try {
+      Path runningDir = pendingIndexUpdate.runningDir;
+      if (!Files.isDirectory(runningDir)) {
+        // no intents written yet.
+        return;
+      }
+
+      processPidDir(runningDir, false);
+    } catch (RuntimeException e) {
+      // catch all to not disrupt next run.
+      logger.atSevere().withCause(e).log("Error in pending index intent run");
+    }
+  }
+
+  private void processPidDir(Path pidDir, boolean skipDeadCheck) {
+    try (DirectoryStream<Path> threadDirs = Files.newDirectoryStream(pidDir)) {
+      for (Path threadDir : threadDirs) {
+        long threadId;
+        try {
+          threadId = Long.parseLong(threadDir.getFileName().toString());
+        } catch (NumberFormatException e) {
+          logger.atWarning().log(
+              "Unexpected entry in pending index dir: %s; skipping", threadDir.getFileName());
+          MoreFiles.deleteRecursively(threadDir);
+          continue;
+        }
+        if (skipDeadCheck || isThreadDead(threadId)) {
+          processDeadThreadDir(threadDir);
+        }
+      }
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Failed to run pending index update scan");
+    }
+  }
+
+  private static boolean isThreadDead(long threadId) {
+    return ManagementFactory.getThreadMXBean().getThreadInfo(threadId) == null;
+  }
+
+  private void processDeadThreadDir(Path threadDir) {
+    try (DirectoryStream<Path> intents = Files.newDirectoryStream(threadDir)) {
+      for (Path intent : intents) {
+        try {
+          pendingIndexUpdate.recover(intent);
+        } catch (IOException e) {
+          logger.atWarning().withCause(e).log(
+              "Failed to recover pending index intent %s", intent.getFileName());
+        }
+      }
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed to recover pending index updates for %s", threadDir);
+    }
+    pendingIndexUpdate.cleanIfEmpty(threadDir);
+  }
+}
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 52668c5..7a76ed8 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -118,7 +118,7 @@
                   }
                   verboseWriter.println("Reindexed " + desc);
                   done.incrementAndGet();
-                } catch (Exception e) {
+                } catch (RuntimeException e) {
                   failed.incrementAndGet();
                   throw e;
                 }
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
index 1c977d1..6acbb27 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -93,7 +93,7 @@
                   }
                   verboseWriter.println("Reindexed " + desc);
                   done.incrementAndGet();
-                } catch (Exception e) {
+                } catch (RuntimeException e) {
                   failed.incrementAndGet();
                   throw e;
                 }
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
index 80fc997..2388200 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
@@ -51,6 +51,7 @@
   @Override
   public void populateEmailContent() {
     email.addSoyParam("attentionSetUser", email.getNameFor(attentionSetUser));
+    email.addSoyParam("attentionSetUserEmail", email.getNameEmailFor(attentionSetUser));
     email.addSoyParam("reason", reason);
 
     changeEmail.addAuthors(RecipientType.TO);
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
index 26c71be..3b54438 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -360,7 +360,7 @@
         detail.append("\n");
       }
       return detail.toString();
-    } catch (Exception err) {
+    } catch (RuntimeException err) {
       logger.atWarning().withCause(err).log("Cannot format change detail");
       return "";
     }
@@ -648,7 +648,7 @@
     setThreadHeaders();
 
     email.addSoyParam("coverLetter", getCoverLetter());
-    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
+    email.addSoyParam("fromName", email.getNameEmailFor(email.getFrom()));
     email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
 
     addChangeRelatedSoyParams();
diff --git a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
index 079f660..dee7f8a 100644
--- a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
+++ b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
@@ -30,12 +30,12 @@
 import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Default versions of Gerrit email notifications. */
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
index 25b2ebd..46bf8cc 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -23,13 +23,13 @@
 import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.time.Duration;
 import java.util.concurrent.ExecutionException;
-import javax.inject.Provider;
 
 /**
  * Provides support for soy templates
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
index 259b219..0d8c971 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
@@ -371,7 +371,7 @@
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
-    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
+    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
     for (HumanComment c : put) {
@@ -422,7 +422,7 @@
     return cb;
   }
 
-  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
+  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
       throws ConfigInvalidException, IOException {
     // The old DraftCommentNotes already parsed the revision notes. We can reuse them as long as
     // the ref hasn't advanced.
@@ -431,7 +431,7 @@
       DraftCommentNotes draftNotes = changeNotes.load().getDraftCommentNotes();
       if (draftNotes != null) {
         ObjectId idFromNotes = firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
-        RevisionNoteMap<ChangeRevisionNote> rnm = draftNotes.getRevisionNoteMap();
+        RevisionNoteMap rnm = draftNotes.getRevisionNoteMap();
         if (idFromNotes.equals(curr) && rnm != null) {
           return rnm;
         }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
index bdadb81..1bec73e 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -48,4 +48,10 @@
   public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
   public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
   public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
+  public static final FooterKey FOOTER_REVIEWER = new FooterKey("Reviewer");
+  public static final FooterKey FOOTER_REVIEWER_EMAIL = new FooterKey("Reviewer-email");
+  public static final FooterKey FOOTER_CC = new FooterKey("CC");
+  public static final FooterKey FOOTER_CC_EMAIL = new FooterKey("CC-email");
+  public static final FooterKey FOOTER_REMOVED = new FooterKey("Removed");
+  public static final FooterKey FOOTER_REMOVED_EMAIL = new FooterKey("Removed-email");
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 6df8389..ad39d41 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -334,7 +334,7 @@
       ChangeNotes n = new ChangeNotes(args, rawChangeFromNoteDb, true, null, metaId);
       try {
         n.load();
-      } catch (Exception e) {
+      } catch (RuntimeException e) {
         return ChangeNotesResult.error(n.getChangeId(), e);
       }
       return ChangeNotesResult.notes(n);
@@ -383,7 +383,7 @@
 
   // Parsed note map state, used by ChangeUpdate to make in-place editing of
   // notes easier.
-  RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
+  RevisionNoteMap revisionNoteMap;
 
   private DraftCommentNotes draftCommentNotes;
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 08490a3..20659e1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -345,14 +345,14 @@
      * ChangeNotes} is capable of lazily loading it as necessary.
      */
     @Nullable
-    abstract RevisionNoteMap<ChangeRevisionNote> revisionNoteMap();
+    abstract RevisionNoteMap revisionNoteMap();
   }
 
   private class Loader implements Callable<ChangeNotesState> {
     private final Key key;
     private final Supplier<ChangeNotesRevWalk> walkSupplier;
 
-    private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
+    private RevisionNoteMap revisionNoteMap;
 
     private Loader(Key key, Supplier<ChangeNotesRevWalk> walkSupplier) {
       this.key = key;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 84de569..df60789 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -18,14 +18,12 @@
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
 
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
-import java.util.Locale;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -39,8 +37,6 @@
 /**
  * Commit implementation with some optimizations for change notes parsing.
  *
- * <p>
- *
  * <ul>
  *   <li>Caches the result of {@link #getFooterLines()}, which is otherwise very wasteful with
  *       allocations.
@@ -115,30 +111,66 @@
     }
   }
 
-  private ListMultimap<String, String> footerLines;
+  private List<FooterLine> footerLines;
 
   public ChangeNotesCommit(AnyObjectId id) {
     super(id);
   }
 
-  public List<String> getFooterLineValues(FooterKey key) {
+  private void initFooterLines() {
     if (footerLines == null) {
-      List<FooterLine> src = getFooterLines();
-      footerLines = MultimapBuilder.hashKeys(src.size()).arrayListValues(1).build();
-      for (FooterLine fl : src) {
-        footerLines.put(fl.getKey().toLowerCase(Locale.US), fl.getValue());
+      footerLines = getFooterLines();
+    }
+  }
+
+  public List<String> getFooterLineValues(FooterKey key) {
+    initFooterLines();
+    if (footerLines.isEmpty()) {
+      return ImmutableList.of();
+    }
+    String first = null;
+    List<String> r = null;
+    for (FooterLine fl : footerLines) {
+      if (fl.matches(key)) {
+        if (first == null) {
+          first = fl.getValue();
+        } else {
+          if (r == null) {
+            r = new ArrayList<>(2);
+            r.add(first);
+          }
+          r.add(fl.getValue());
+        }
       }
     }
-    return footerLines.get(key.getName().toLowerCase(Locale.US));
+    if (r != null) {
+      return r;
+    }
+    if (first != null) {
+      return ImmutableList.of(first);
+    }
+    return ImmutableList.of();
   }
 
   public boolean isAttentionSetCommitOnly(boolean hasChangeMessage) {
-    return !hasChangeMessage
-        && footerLines
-            .keySet()
-            .equals(
-                Sets.newHashSet(
-                    FOOTER_PATCH_SET.getName().toLowerCase(Locale.US),
-                    FOOTER_ATTENTION.getName().toLowerCase(Locale.US)));
+    if (hasChangeMessage) {
+      return false;
+    }
+    initFooterLines();
+    if (footerLines.size() < 2) {
+      return false;
+    }
+    boolean hasPatchSet = false;
+    boolean hasAttention = false;
+    for (FooterLine fl : footerLines) {
+      if (fl.matches(FOOTER_PATCH_SET)) {
+        hasPatchSet = true;
+      } else if (fl.matches(FOOTER_ATTENTION)) {
+        hasAttention = true;
+      } else {
+        return false;
+      }
+    }
+    return hasPatchSet && hasAttention;
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index c46d2d5..cc4174b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -150,7 +150,6 @@
 class ChangeNotesParser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final Splitter RULE_SPLITTER = Splitter.on(": ");
   private static final Splitter HASHTAG_SPLITTER = Splitter.on(",");
 
   // Private final members initialized in the constructor.
@@ -202,7 +201,7 @@
   private String originalSubject;
   private String submissionId;
   private String tag;
-  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
+  private RevisionNoteMap revisionNoteMap;
   private Boolean isPrivate;
   private Boolean workInProgress;
   private Boolean previousWorkInProgressFooter;
@@ -286,7 +285,7 @@
     return buildState();
   }
 
-  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
+  RevisionNoteMap getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
@@ -341,7 +340,7 @@
         }
         PatchSet ps = psBuilder.build();
         result.put(ps.id(), ps);
-      } catch (Exception ex) {
+      } catch (RuntimeException ex) {
         ConfigInvalidException cie = parseException("Error building patch set %s", e.getKey());
         cie.initCause(ex);
         throw cie;
@@ -483,8 +482,9 @@
     createdOn = commitTimestamp;
     parseTag(commit);
 
-    PatchSet.Id psId = parsePatchSetId(commit);
-    PatchSetState psState = parsePatchSetState(commit);
+    PatchSetIdAndState psIdAndState = parsePatchSetIdAndState(commit);
+    PatchSet.Id psId = psIdAndState.id;
+    PatchSetState psState = psIdAndState.state;
     if (psState != null) {
       if (!patchSetStates.containsKey(psId)) {
         patchSetStates.put(psId, psState);
@@ -574,7 +574,7 @@
     if (currRev.isPresent()) {
       parsePatchSet(commit, psId, currRev.get(), accountId, realAccountId, commitTimestamp);
     }
-    parseCurrentPatchSet(commit, psId);
+    parseCurrentPatchSet(commit, psId, currRev.isPresent());
 
     if (status == null) {
       status = parseStatus(commit);
@@ -590,14 +590,14 @@
     }
 
     Account.Id updater = accountId != null ? accountId : ownerId;
-    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+    for (ReviewerStateInternal state : ReviewerStateInternal.ALL_STATES) {
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
         parseReviewer(commitTimestamp, updater, realAccountId, state, line);
       }
       for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
         parseReviewerByEmail(commitTimestamp, updater, realAccountId, state, line);
       }
-      // Don't update timestamp when a reviewer was added, matching RevewDb
+      // Don't update timestamp when a reviewer was added, matching ReviewDb
       // behavior.
     }
 
@@ -842,16 +842,17 @@
     }
   }
 
-  private void parseCurrentPatchSet(ChangeNotesCommit commit, PatchSet.Id psId)
+  private void parseCurrentPatchSet(
+      ChangeNotesCommit commit, PatchSet.Id psId, boolean hasCommitFooter)
       throws ConfigInvalidException {
     // This commit implies a new current patch set if either it creates a new
     // patch set, or sets the current field explicitly.
     boolean current = false;
-    if (parseOneFooter(commit, FOOTER_COMMIT) != null) {
+    if (hasCommitFooter) {
       current = true;
     } else {
       String currentStr = parseOneFooter(commit, FOOTER_CURRENT);
-      if (Boolean.TRUE.toString().equalsIgnoreCase(currentStr)) {
+      if ("true".equalsIgnoreCase(currentStr)) {
         current = true;
       } else if (currentStr != null) {
         // Only "true" is allowed; unsetting the current patch set makes no
@@ -884,12 +885,14 @@
 
   private void parseCustomKeyedValues(ChangeNotesCommit commit) {
     for (String customKeyedValueLine : commit.getFooterLineValues(FOOTER_CUSTOM_KEYED_VALUE)) {
-      String[] parts = customKeyedValueLine.split("=", 2);
-      String key = parts[0];
-      String value = parts[1];
-      // Commits are parsed in reverse order and only the last set of values
-      // should be used.  An empty value for a key means it's a deletion.
-      customKeyedValues.putIfAbsent(key, value);
+      int eq = customKeyedValueLine.indexOf('=');
+      if (eq >= 0) {
+        String key = customKeyedValueLine.substring(0, eq);
+        String value = customKeyedValueLine.substring(eq + 1);
+        // Commits are parsed in reverse order and only the last set of values
+        // should be used.  An empty value for a key means it's a deletion.
+        customKeyedValues.putIfAbsent(key, value);
+      }
     }
   }
 
@@ -964,36 +967,41 @@
     return status;
   }
 
-  private PatchSet.Id parsePatchSetId(ChangeNotesCommit commit) throws ConfigInvalidException {
+  private static class PatchSetIdAndState {
+    final PatchSet.Id id;
+    @Nullable final PatchSetState state;
+
+    PatchSetIdAndState(PatchSet.Id id, @Nullable PatchSetState state) {
+      this.id = id;
+      this.state = state;
+    }
+  }
+
+  private PatchSetIdAndState parsePatchSetIdAndState(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     int s = psIdLine.indexOf(' ');
     String psIdStr = s < 0 ? psIdLine : psIdLine.substring(0, s);
-    Integer psId = Ints.tryParse(psIdStr);
-    if (psId == null) {
+    Integer psIdInt = Ints.tryParse(psIdStr);
+    if (psIdInt == null) {
       throw invalidFooter(FOOTER_PATCH_SET, psIdStr);
     }
-    return PatchSet.id(id, psId);
-  }
-
-  @Nullable
-  private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
-    String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
-    int s = psIdLine.indexOf(' ');
-    if (s < 0) {
-      return null;
-    }
-    String withParens = psIdLine.substring(s + 1);
-    if (withParens.startsWith("(") && withParens.endsWith(")")) {
-      PatchSetState state =
-          Enums.getIfPresent(
-                  PatchSetState.class,
-                  withParens.substring(1, withParens.length() - 1).toUpperCase(Locale.US))
-              .orNull();
-      if (state != null) {
-        return state;
+    PatchSet.Id psId = PatchSet.id(id, psIdInt);
+    PatchSetState psState = null;
+    if (s >= 0) {
+      String withParens = psIdLine.substring(s + 1);
+      if (withParens.startsWith("(") && withParens.endsWith(")")) {
+        psState =
+            Enums.getIfPresent(
+                    PatchSetState.class,
+                    withParens.substring(1, withParens.length() - 1).toUpperCase(Locale.US))
+                .orNull();
+      }
+      if (psState == null) {
+        throw invalidFooter(FOOTER_PATCH_SET, psIdLine);
       }
     }
-    throw invalidFooter(FOOTER_PATCH_SET, psIdLine);
+    return new PatchSetIdAndState(psId, psState);
   }
 
   private void parseDescription(PatchSet.Id psId, ChangeNotesCommit commit)
@@ -1308,7 +1316,7 @@
       } else {
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
         if (line.startsWith("Rule-Name: ")) {
-          String ruleName = RULE_SPLITTER.splitToList(line).get(1);
+          String ruleName = line.substring(11).trim();
           rec.ruleName = ruleName;
           continue;
         }
@@ -1396,10 +1404,10 @@
     String raw = parseOneFooter(commit, FOOTER_PRIVATE);
     if (raw == null) {
       return;
-    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+    } else if ("true".equalsIgnoreCase(raw)) {
       isPrivate = true;
       return;
-    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+    } else if ("false".equalsIgnoreCase(raw)) {
       isPrivate = false;
       return;
     }
@@ -1412,7 +1420,7 @@
       // No change to WIP state in this revision.
       previousWorkInProgressFooter = null;
       return;
-    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+    } else if ("true".equalsIgnoreCase(raw)) {
       // This revision moves the change into WIP.
       previousWorkInProgressFooter = true;
       if (workInProgress == null) {
@@ -1427,7 +1435,7 @@
         workInProgress = true;
       }
       return;
-    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+    } else if ("false".equalsIgnoreCase(raw)) {
       previousWorkInProgressFooter = false;
       hasReviewStarted = true;
       if (workInProgress == null) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index a17a68d..444da63 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -601,7 +601,7 @@
     if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
       return null;
     }
-    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
+    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
 
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
     for (HumanComment c : comments) {
@@ -645,7 +645,7 @@
     return rnm.noteMap.writeTree(inserter);
   }
 
-  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw, ObjectId curr)
+  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
       throws ConfigInvalidException, IOException {
     if (curr.equals(ObjectId.zeroId())) {
       return RevisionNoteMap.emptyMap();
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 895ae9d..b7577ef 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -299,7 +299,7 @@
             try {
               ChangeNotes changeNotes = changeNotesFactory.create(project, changeId);
               accountsInChange = collectAccounts(changeNotes);
-            } catch (Exception e) {
+            } catch (RuntimeException e) {
               logger.atWarning().withCause(e).log("Failed to run verification on ref %s", ref);
             }
           }
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index 6ee538a..021bc2c 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -204,7 +204,7 @@
       List<HumanComment> putInComments,
       List<HumanComment> deletedComments)
       throws IOException, ConfigInvalidException {
-    RevisionNoteMap<ChangeRevisionNote> revNotesMap =
+    RevisionNoteMap revNotesMap =
         RevisionNoteMap.parse(
             noteUtil.getChangeNoteJson(),
             reader,
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 639633e..7afb11b 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -57,7 +57,7 @@
   private final Ref ref;
 
   private ImmutableListMultimap<ObjectId, HumanComment> comments;
-  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
+  private RevisionNoteMap revisionNoteMap;
 
   @AssistedInject
   DraftCommentNotes(Args args, @Assisted Change.Id changeId, @Assisted Account.Id author) {
@@ -93,7 +93,7 @@
     }
   }
 
-  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
+  RevisionNoteMap getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
index 6f48aa0..b7cee28 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -35,7 +36,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
diff --git a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index d5a7259..6c2974d 100644
--- a/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.util.Arrays;
 import org.eclipse.jgit.revwalk.FooterKey;
@@ -21,13 +22,16 @@
 /** State of a reviewer on a change. */
 public enum ReviewerStateInternal {
   /** The user has contributed at least one nonzero vote on the change. */
-  REVIEWER("Reviewer", ReviewerState.REVIEWER),
+  REVIEWER(ReviewerState.REVIEWER),
 
   /** The reviewer was added to the change, but has not voted. */
-  CC("CC", ReviewerState.CC),
+  CC(ReviewerState.CC),
 
   /** The user was previously a reviewer on the change, but was removed. */
-  REMOVED("Removed", ReviewerState.REMOVED);
+  REMOVED(ReviewerState.REMOVED);
+
+  public static final ImmutableList<ReviewerStateInternal> ALL_STATES =
+      ImmutableList.copyOf(values());
 
   public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
     return ReviewerStateInternal.values()[state.ordinal()];
@@ -50,20 +54,34 @@
     }
   }
 
-  private final String footer;
   private final ReviewerState state;
 
-  ReviewerStateInternal(String footer, ReviewerState state) {
-    this.footer = footer;
+  ReviewerStateInternal(ReviewerState state) {
     this.state = state;
   }
 
   FooterKey getFooterKey() {
-    return new FooterKey(footer);
+    switch (this) {
+      case REVIEWER:
+        return ChangeNoteFooters.FOOTER_REVIEWER;
+      case CC:
+        return ChangeNoteFooters.FOOTER_CC;
+      case REMOVED:
+        return ChangeNoteFooters.FOOTER_REMOVED;
+    }
+    throw new IllegalStateException("unhandled state: " + this);
   }
 
   FooterKey getByEmailFooterKey() {
-    return new FooterKey(footer + "-email");
+    switch (this) {
+      case REVIEWER:
+        return ChangeNoteFooters.FOOTER_REVIEWER_EMAIL;
+      case CC:
+        return ChangeNoteFooters.FOOTER_CC_EMAIL;
+      case REMOVED:
+        return ChangeNoteFooters.FOOTER_REMOVED_EMAIL;
+    }
+    throw new IllegalStateException("unhandled state: " + this);
   }
 
   public ReviewerState asReviewerState() {
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 35a014c..c7aadeb 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -43,10 +43,10 @@
 class RevisionNoteBuilder {
   /** Construct a new RevisionNoteMap, seeding it with an existing (immutable) RevisionNoteMap */
   static class Cache {
-    private final RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap;
+    private final RevisionNoteMap revisionNoteMap;
     private final Map<ObjectId, RevisionNoteBuilder> builders;
 
-    Cache(RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap) {
+    Cache(RevisionNoteMap revisionNoteMap) {
       this.revisionNoteMap = revisionNoteMap;
       this.builders = new HashMap<>();
     }
@@ -83,15 +83,13 @@
 
   private String pushCert;
 
-  private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
+  private RevisionNoteBuilder(@Nullable ChangeRevisionNote base) {
     if (base != null) {
       baseRaw = base.getRaw();
       baseComments = base.getEntities();
       put = Maps.newHashMapWithExpectedSize(baseComments.size());
-      if (base instanceof ChangeRevisionNote) {
-        pushCert = ((ChangeRevisionNote) base).getPushCert();
-        submitRequirementResults = ((ChangeRevisionNote) base).getSubmitRequirementsResult();
-      }
+      pushCert = base.getPushCert();
+      submitRequirementResults = base.getSubmitRequirementsResult();
     } else {
       baseRaw = new byte[0];
       baseComments = Collections.emptyList();
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 9f808d3..0fa0f18 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -23,24 +23,21 @@
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 
-/**
- * A utility class that parses a NoteMap into commit => comment list data.
- *
- * @param <T> the RevisionNote for the comment type.
- */
-class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
+/** A utility class that parses a NoteMap into commit => comment list data. */
+class RevisionNoteMap {
   /** CommitID => blob ID */
   final NoteMap noteMap;
 
   /** CommitID => parsed data */
-  final ImmutableMap<ObjectId, T> revisionNotes;
+  final ImmutableMap<ObjectId, ChangeRevisionNote> revisionNotes;
 
-  private RevisionNoteMap(NoteMap noteMap, ImmutableMap<ObjectId, T> revisionNotes) {
+  private RevisionNoteMap(
+      NoteMap noteMap, ImmutableMap<ObjectId, ChangeRevisionNote> revisionNotes) {
     this.noteMap = noteMap;
     this.revisionNotes = revisionNotes;
   }
 
-  static RevisionNoteMap<ChangeRevisionNote> parse(
+  static RevisionNoteMap parse(
       ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
       throws ConfigInvalidException, IOException {
     ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
@@ -50,10 +47,10 @@
 
       result.put(note.copy(), rn);
     }
-    return new RevisionNoteMap<>(noteMap, result.build());
+    return new RevisionNoteMap(noteMap, result.buildOrThrow());
   }
 
-  static <T extends RevisionNote<? extends Comment>> RevisionNoteMap<T> emptyMap() {
-    return new RevisionNoteMap<>(NoteMap.newEmptyMap(), ImmutableMap.of());
+  static RevisionNoteMap emptyMap() {
+    return new RevisionNoteMap(NoteMap.newEmptyMap(), ImmutableMap.of());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index d9bfd13..889153c 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -64,11 +64,15 @@
         ObjectInserter ins = repo.newObjectInserter();
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
-      RevCommit current = rw.parseCommit(commitId);
-      return current.getParentCount();
+      return getNumParents(rw, commitId);
     }
   }
 
+  int getNumParents(RevWalk rw, ObjectId commitId) throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    return current.getParentCount();
+  }
+
   /**
    * Returns the base commit for the provided commit.
    *
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 9ee4bbe..7712cc4 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -202,4 +202,29 @@
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between two patchset commits with custom diff options. For
+   * deleted files, the {@code fileName} parameter should contain the old name of the file. This
+   * method will return {@link FileDiffOutput#empty(String, ObjectId, ObjectId)} if the requested
+   * file identified by {@code fileName} has unchanged content or does not exist at both commits.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
+   * @param diffOptions options controlling diff behavior such as rebase filtering.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace,
+      DiffOptions diffOptions)
+      throws DiffNotAvailableException;
 }
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 85cd030..59cdba7 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -33,6 +33,8 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
@@ -52,17 +54,24 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.function.Function;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
@@ -84,6 +93,7 @@
   private final ModifiedFilesLoader.Factory modifiedFilesLoaderFactory;
   private final FileDiffCache fileDiffCache;
   private final BaseCommitUtil baseCommitUtil;
+  private final ExperimentFeatures experimentFeatures;
 
   public static Module module() {
     return new CacheModule() {
@@ -99,19 +109,21 @@
   }
 
   @Inject
-  public DiffOperationsImpl(
+  DiffOperationsImpl(
       GitRepositoryManager repoManager,
       ModifiedFilesCache modifiedFilesCache,
       ModifiedFilesCacheImpl modifiedFilesCacheImpl,
       ModifiedFilesLoader.Factory modifiedFilesLoaderFactory,
       FileDiffCache fileDiffCache,
-      BaseCommitUtil baseCommit) {
+      BaseCommitUtil baseCommit,
+      ExperimentFeatures experimentFeatures) {
     this.repoManager = repoManager;
     this.modifiedFilesCache = modifiedFilesCache;
     this.modifiedFilesCacheImpl = modifiedFilesCacheImpl;
     this.modifiedFilesLoaderFactory = modifiedFilesLoaderFactory;
     this.fileDiffCache = fileDiffCache;
     this.baseCommitUtil = baseCommit;
+    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -145,6 +157,219 @@
     }
   }
 
+  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(
+      DiffParameters diffParams,
+      DiffOptions diffOptions,
+      @Nullable RevWalk revWalk,
+      @Nullable Config repoConfig)
+      throws DiffNotAvailableException {
+    logger.atFine().log(
+        "getModifiedFiles (diffParams: %s, diffOptions: %s)", diffParams, diffOptions);
+    try {
+      if (diffOptions.skipDiffStat()) {
+        if (revWalk != null && repoConfig != null) {
+          return computeModifiedFilesWithoutDiffStat(diffParams, diffOptions, revWalk, repoConfig);
+        }
+        try (Repository repo = repoManager.openRepository(diffParams.project());
+            RevWalk rw = new RevWalk(repo)) {
+          return computeModifiedFilesWithoutDiffStat(diffParams, diffOptions, rw, repo.getConfig());
+        }
+      }
+
+      Project.NameKey project = diffParams.project();
+      ObjectId newCommit = diffParams.newCommit();
+      ObjectId oldCommit = diffParams.baseCommit();
+      ComparisonType cmp = diffParams.comparisonType();
+
+      ImmutableList<ModifiedFile> modifiedFiles;
+      if (diffOptions.skipRebaseFiltering()) {
+        if (revWalk != null && repoConfig != null) {
+          ModifiedFilesLoader loader =
+              modifiedFilesLoaderFactory
+                  .createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache()
+                  .withSkipRebaseFiltering(true);
+          loader.withRenameDetection(RENAME_SCORE);
+          modifiedFiles = loader.load(project, repoConfig, revWalk, oldCommit, newCommit);
+        } else {
+          try (Repository repo = repoManager.openRepository(project);
+              RevWalk rw = new RevWalk(repo)) {
+            ModifiedFilesLoader loader =
+                modifiedFilesLoaderFactory
+                    .createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache()
+                    .withSkipRebaseFiltering(true);
+            loader.withRenameDetection(RENAME_SCORE);
+            modifiedFiles = loader.load(project, repo.getConfig(), rw, oldCommit, newCommit);
+          }
+        }
+      } else {
+        modifiedFiles =
+            modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
+      }
+
+      boolean useTimeout =
+          experimentFeatures.isFeatureEnabled(
+              ExperimentFeaturesConstants.TIMEOUT_FILE_DIFF_COMPUTATION, project);
+      List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
+      fileCacheKeys.add(
+          createFileDiffCacheKey(
+              project,
+              oldCommit,
+              newCommit,
+              COMMIT_MSG,
+              DEFAULT_DIFF_ALGORITHM,
+              useTimeout,
+              /* whitespace= */ null));
+
+      if (cmp.isAgainstAutoMerge()
+          || isMergeAgainstParent(cmp, project, newCommit, diffParams.numParents())) {
+        fileCacheKeys.add(
+            createFileDiffCacheKey(
+                project,
+                oldCommit,
+                newCommit,
+                MERGE_LIST,
+                DEFAULT_DIFF_ALGORITHM,
+                useTimeout,
+                /* whitespace= */ null));
+      }
+
+      if (diffParams.skipFiles() == null) {
+        modifiedFiles.stream()
+            .map(
+                entity ->
+                    createFileDiffCacheKey(
+                        project,
+                        oldCommit,
+                        newCommit,
+                        entity.newPath().isPresent()
+                            ? entity.newPath().get()
+                            : entity.oldPath().get(),
+                        DEFAULT_DIFF_ALGORITHM,
+                        useTimeout,
+                        /* whitespace= */ null))
+            .forEach(fileCacheKeys::add);
+      }
+      return getModifiedFilesForKeys(fileCacheKeys, diffOptions);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  private ImmutableMap<String, FileDiffOutput> computeModifiedFilesWithoutDiffStat(
+      DiffParameters diffParams, DiffOptions diffOptions, RevWalk rw, Config repoConfig)
+      throws IOException, DiffNotAvailableException {
+    if (Boolean.TRUE.equals(diffParams.skipFiles())) {
+      return ImmutableMap.of();
+    }
+    Project.NameKey project = diffParams.project();
+    ObjectId oldCommit = diffParams.baseCommit();
+    ObjectId newCommit = diffParams.newCommit();
+    ComparisonType cmp = diffParams.comparisonType();
+
+    ImmutableCollection<ModifiedFile> modifiedFiles;
+    if (diffOptions.skipRebaseFiltering()) {
+      ModifiedFilesLoader loader =
+          modifiedFilesLoaderFactory
+              .createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache()
+              .withSkipRebaseFiltering(true);
+      loader.withRenameDetection(RENAME_SCORE);
+      modifiedFiles = loader.load(project, repoConfig, rw, oldCommit, newCommit);
+    } else {
+      modifiedFiles =
+          loadModifiedFilesWithoutCacheIfNecessary(
+                  project, diffParams, rw, repoConfig, /* enableRenameDetection= */ true)
+              .values();
+    }
+
+    Set<String> allPaths = new HashSet<>();
+    for (ModifiedFile mf : modifiedFiles) {
+      mf.oldPath().ifPresent(allPaths::add);
+      mf.newPath().ifPresent(allPaths::add);
+    }
+    if (allPaths.isEmpty()) {
+      return ImmutableMap.of();
+    }
+
+    RevTree aTree = oldCommit.equals(ObjectId.zeroId()) ? null : rw.parseTree(oldCommit);
+    RevTree bTree = rw.parseTree(newCommit);
+    Map<String, ObjectId> oldShas = new HashMap<>();
+    Map<String, Patch.FileMode> oldModes = new HashMap<>();
+    Map<String, ObjectId> newShas = new HashMap<>();
+    Map<String, Patch.FileMode> newModes = new HashMap<>();
+
+    try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
+      tw.setRecursive(true);
+      tw.setFilter(PathFilterGroup.createFromStrings(allPaths));
+      int aIdx = aTree != null ? tw.addTree(aTree) : -1;
+      int bIdx = tw.addTree(bTree);
+      while (tw.next()) {
+        String path = tw.getPathString();
+        if (aIdx >= 0 && !tw.getFileMode(aIdx).equals(FileMode.MISSING)) {
+          oldShas.put(path, tw.getObjectId(aIdx));
+          oldModes.put(path, mapFileMode(tw.getFileMode(aIdx)));
+        }
+        if (!tw.getFileMode(bIdx).equals(FileMode.MISSING)) {
+          newShas.put(path, tw.getObjectId(bIdx));
+          newModes.put(path, mapFileMode(tw.getFileMode(bIdx)));
+        }
+      }
+    }
+
+    ImmutableMap.Builder<String, FileDiffOutput> result = ImmutableMap.builder();
+    for (ModifiedFile mf : modifiedFiles) {
+      String path = mf.getDefaultPath();
+      if (path.equals(COMMIT_MSG) || path.equals(MERGE_LIST)) {
+        continue;
+      }
+      Optional<Patch.FileMode> oldMode =
+          mf.oldPath().map(p -> oldModes.getOrDefault(p, Patch.FileMode.MISSING));
+      Optional<Patch.FileMode> newMode =
+          mf.newPath().map(p -> newModes.getOrDefault(p, Patch.FileMode.MISSING));
+      Optional<ObjectId> oldSha =
+          mf.oldPath().map(oldShas::get).filter(sha -> !sha.equals(ObjectId.zeroId()));
+      Optional<ObjectId> newSha =
+          mf.newPath().map(newShas::get).filter(sha -> !sha.equals(ObjectId.zeroId()));
+      FileDiffOutput fileDiff =
+          FileDiffOutput.builder()
+              .oldCommitId(oldCommit)
+              .newCommitId(newCommit)
+              .comparisonType(cmp)
+              .changeType(mf.changeType())
+              .patchType(Optional.of(Patch.PatchType.UNIFIED))
+              .oldPath(mf.oldPath())
+              .newPath(mf.newPath())
+              .oldMode(oldMode)
+              .newMode(newMode)
+              .oldSha(oldSha)
+              .newSha(newSha)
+              .headerLines(ImmutableList.of())
+              .edits(ImmutableList.of())
+              .size(0)
+              .sizeDelta(0)
+              .build();
+      String key = path;
+      result.put(key, fileDiff);
+    }
+    return result.buildOrThrow();
+  }
+
+  private static Patch.FileMode mapFileMode(FileMode jgitFileMode) {
+    if (jgitFileMode.equals(FileMode.TREE)) {
+      return Patch.FileMode.TREE;
+    } else if (jgitFileMode.equals(FileMode.SYMLINK)) {
+      return Patch.FileMode.SYMLINK;
+    } else if (jgitFileMode.equals(FileMode.GITLINK)) {
+      return Patch.FileMode.GITLINK;
+    } else if (jgitFileMode.equals(FileMode.REGULAR_FILE)) {
+      return Patch.FileMode.REGULAR_FILE;
+    } else if (jgitFileMode.equals(FileMode.EXECUTABLE_FILE)) {
+      return Patch.FileMode.EXECUTABLE_FILE;
+    } else if (jgitFileMode.equals(FileMode.MISSING)) {
+      return Patch.FileMode.MISSING;
+    }
+    throw new IllegalArgumentException("Unsupported type " + jgitFileMode);
+  }
+
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
       Project.NameKey project, ObjectId newCommit, int parent, DiffOptions diffOptions)
@@ -159,7 +384,7 @@
           project, newCommit.name(), ins);
 
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent, repoView, ins);
-      return getModifiedFiles(diffParams, diffOptions);
+      return getModifiedFiles(diffParams, diffOptions, revWalk, repoView.getConfig());
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -200,7 +425,7 @@
             .baseCommit(oldCommit)
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .build();
-    return getModifiedFiles(params, diffOptions);
+    return getModifiedFiles(params, diffOptions, /* revWalk= */ null, /* repoConfig= */ null);
   }
 
   @Override
@@ -247,9 +472,10 @@
               newCommit,
               fileName,
               DEFAULT_DIFF_ALGORITHM,
-              /* useTimeout= */ true,
+              experimentFeatures.isFeatureEnabled(
+                  ExperimentFeaturesConstants.TIMEOUT_FILE_DIFF_COMPUTATION, project),
               whitespace);
-      return getModifiedFileForKey(key);
+      return getModifiedFileForKey(key, DiffOptions.DEFAULTS);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -264,6 +490,19 @@
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
+    return getModifiedFile(
+        project, oldCommit, newCommit, fileName, whitespace, DiffOptions.DEFAULTS);
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace,
+      DiffOptions diffOptions)
+      throws DiffNotAvailableException {
     FileDiffCacheKey key =
         createFileDiffCacheKey(
             project,
@@ -271,73 +510,16 @@
             newCommit,
             fileName,
             DEFAULT_DIFF_ALGORITHM,
-            /* useTimeout= */ true,
+            experimentFeatures.isFeatureEnabled(
+                ExperimentFeaturesConstants.TIMEOUT_FILE_DIFF_COMPUTATION, project),
             whitespace);
-    return getModifiedFileForKey(key);
+    return getModifiedFileForKey(key, diffOptions);
   }
 
-  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(
-      DiffParameters diffParams, DiffOptions diffOptions) throws DiffNotAvailableException {
-    logger.atFine().log(
-        "getModifiedFiles (diffParams: %s, diffOptions: %s)", diffParams, diffOptions);
-    try {
-      Project.NameKey project = diffParams.project();
-      ObjectId newCommit = diffParams.newCommit();
-      ObjectId oldCommit = diffParams.baseCommit();
-      ComparisonType cmp = diffParams.comparisonType();
-
-      ImmutableList<ModifiedFile> modifiedFiles =
-          modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
-
-      List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
-      fileCacheKeys.add(
-          createFileDiffCacheKey(
-              project,
-              oldCommit,
-              newCommit,
-              COMMIT_MSG,
-              DEFAULT_DIFF_ALGORITHM,
-              /* useTimeout= */ true,
-              /* whitespace= */ null));
-
-      if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
-        fileCacheKeys.add(
-            createFileDiffCacheKey(
-                project,
-                oldCommit,
-                newCommit,
-                MERGE_LIST,
-                DEFAULT_DIFF_ALGORITHM,
-                /* useTimeout= */ true,
-                /* whitespace= */ null));
-      }
-
-      if (diffParams.skipFiles() == null) {
-        modifiedFiles.stream()
-            .map(
-                entity ->
-                    createFileDiffCacheKey(
-                        project,
-                        oldCommit,
-                        newCommit,
-                        entity.newPath().isPresent()
-                            ? entity.newPath().get()
-                            : entity.oldPath().get(),
-                        DEFAULT_DIFF_ALGORITHM,
-                        /* useTimeout= */ true,
-                        /* whitespace= */ null))
-            .forEach(fileCacheKeys::add);
-      }
-      return getModifiedFilesForKeys(fileCacheKeys, diffOptions);
-    } catch (IOException e) {
-      throw new DiffNotAvailableException(e);
-    }
-  }
-
-  private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key)
+  private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key, DiffOptions diffOptions)
       throws DiffNotAvailableException {
     ImmutableMap<String, FileDiffOutput> diffList =
-        getModifiedFilesForKeys(ImmutableList.of(key), DiffOptions.DEFAULTS);
+        getModifiedFilesForKeys(ImmutableList.of(key), diffOptions);
     return diffList.containsKey(key.newFilePath())
         ? diffList.get(key.newFilePath())
         : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
@@ -367,10 +549,9 @@
                 key.newFilePath(),
                 // Use the fallback diff algorithm
                 DiffAlgorithm.HISTOGRAM_NO_FALLBACK,
-                // We don't enforce timeouts with the fallback algorithm. Timeouts were introduced
-                // because of a bug in JGit that happens only when the histogram algorithm uses
-                // Myers as fallback. See https://issues.gerritcodereview.com/issues/40000618
-                /* useTimeout= */ false,
+                // Enforce a timeout even when falling back
+                experimentFeatures.isFeatureEnabled(
+                    ExperimentFeaturesConstants.TIMEOUT_FILE_DIFF_COMPUTATION, key.project()),
                 key.whitespace());
         logger.atFine().log(
             "fallback to computing git file diff for %s with %s as diff algorithm and no timeout",
@@ -393,7 +574,7 @@
     ImmutableMap.Builder<String, FileDiffOutput> diffs = ImmutableMap.builder();
 
     for (FileDiffOutput fileDiffOutput : fileDiffOutputs) {
-      if (fileDiffOutput.isEmpty()
+      if ((fileDiffOutput.isEmpty() && !fileDiffOutput.isNegative())
           || (diffOptions.skipFilesWithAllEditsDueToRebase() && allDueToRebase(fileDiffOutput))) {
         continue;
       }
@@ -403,7 +584,7 @@
         diffs.put(fileDiffOutput.newPath().get(), fileDiffOutput);
       }
     }
-    return diffs.build();
+    return diffs.buildOrThrow();
   }
 
   private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
@@ -412,9 +593,13 @@
             || fileDiffOutput.changeType() == ChangeType.COPIED);
   }
 
-  private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
+  private boolean isMergeAgainstParent(
+      ComparisonType cmp, Project.NameKey project, ObjectId commit, @Nullable Integer numParents)
       throws IOException {
-    return (cmp.isAgainstParent() && baseCommitUtil.getNumParents(project, commit) > 1);
+    return cmp.isAgainstParent()
+        && (numParents != null
+            ? numParents > 1
+            : baseCommitUtil.getNumParents(project, commit) > 1);
   }
 
   private static ModifiedFilesCacheKey createModifiedFilesKey(
@@ -525,6 +710,9 @@
     @Nullable
     abstract Integer parent();
 
+    @Nullable
+    abstract Integer numParents();
+
     /** Compute the diff for {@value Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} only. */
     @Nullable
     abstract Boolean skipFiles();
@@ -544,6 +732,8 @@
 
       abstract Builder parent(@Nullable Integer parent);
 
+      abstract Builder numParents(@Nullable Integer numParents);
+
       abstract Builder skipFiles(@Nullable Boolean skipFiles);
 
       abstract Builder comparisonType(ComparisonType comparisonType);
@@ -560,8 +750,13 @@
       RepoView repoView,
       ObjectInserter ins)
       throws IOException {
+    int numParents = baseCommitUtil.getNumParents(repoView.getRevWalk(), newCommit);
     DiffParameters.Builder result =
-        DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .parent(parent)
+            .numParents(numParents);
     if (parent > 0) {
       RevCommit baseCommit = baseCommitUtil.getBaseCommit(repoView, ins, newCommit, parent);
       if (baseCommit == null) {
@@ -576,7 +771,6 @@
       result.comparisonType(ComparisonType.againstParent(parent));
       return result.build();
     }
-    int numParents = baseCommitUtil.getNumParents(project, newCommit);
     if (numParents == 0) {
       result.baseCommit(ObjectId.zeroId());
       result.comparisonType(ComparisonType.againstRoot());
diff --git a/java/com/google/gerrit/server/patch/DiffOptions.java b/java/com/google/gerrit/server/patch/DiffOptions.java
index 4d54be1..d2668c2 100644
--- a/java/com/google/gerrit/server/patch/DiffOptions.java
+++ b/java/com/google/gerrit/server/patch/DiffOptions.java
@@ -19,18 +19,45 @@
 @AutoValue
 public abstract class DiffOptions {
   public static final DiffOptions DEFAULTS =
-      DiffOptions.builder().skipFilesWithAllEditsDueToRebase(true).build();
+      DiffOptions.builder()
+          .skipFilesWithAllEditsDueToRebase(true)
+          .skipRebaseFiltering(false)
+          .skipDiffStat(false)
+          .build();
 
   public abstract boolean skipFilesWithAllEditsDueToRebase();
 
+  /**
+   * Whether to skip the rebase-filtering algorithm in ModifiedFilesLoader.
+   *
+   * <p>If true, the full list of files changed between the two commits will be returned, even if
+   * they are not parent-child or do not share a common parent (e.g. general repository-level
+   * diffs).
+   */
+  public abstract boolean skipRebaseFiltering();
+
+  /**
+   * Whether to skip computing full per-file text diffs and diffstats (insertions, deletions, size,
+   * sizeDelta) and instead populate lightweight {@link
+   * com.google.gerrit.server.patch.filediff.FileDiffOutput} entries containing only file paths,
+   * change types, file modes, and blob SHAs.
+   */
+  public abstract boolean skipDiffStat();
+
+  public abstract Builder toBuilder();
+
   public static DiffOptions.Builder builder() {
-    return new AutoValue_DiffOptions.Builder();
+    return new AutoValue_DiffOptions.Builder().skipRebaseFiltering(false).skipDiffStat(false);
   }
 
   @AutoValue.Builder
   public abstract static class Builder {
     public abstract Builder skipFilesWithAllEditsDueToRebase(boolean value);
 
+    public abstract Builder skipRebaseFiltering(boolean value);
+
+    public abstract Builder skipDiffStat(boolean value);
+
     public abstract DiffOptions build();
   }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 1e7e0e8..b91b9fc 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -44,6 +44,10 @@
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
@@ -53,18 +57,18 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 
-class PatchScriptBuilder {
+public class PatchScriptBuilder {
 
   private DiffPreferencesInfo diffPrefs;
   private final FileTypeRegistry registry;
   private IntraLineDiffCalculator intralineDiffCalculator;
 
   @Inject
-  PatchScriptBuilder(FileTypeRegistry ftr) {
+  public PatchScriptBuilder(FileTypeRegistry ftr) {
     registry = ftr;
   }
 
-  void setDiffPrefs(DiffPreferencesInfo dp) {
+  public void setDiffPrefs(DiffPreferencesInfo dp) {
     diffPrefs = dp;
   }
 
@@ -73,7 +77,7 @@
   }
 
   /** Convert into {@link PatchScript} using the new diff cache output. */
-  PatchScript toPatchScript(Repository git, FileDiffOutput content) throws IOException {
+  public PatchScript toPatchScript(Repository git, FileDiffOutput content) throws IOException {
     PatchFileChange change =
         new PatchFileChange(
             content.edits().stream().map(TaggedEdit::jgitEdit).collect(toImmutableList()),
@@ -122,7 +126,7 @@
     if (a.mode == FileMode.MISSING) {
       throw new ResourceNotFoundException(String.format("File %s not found", fileName));
     }
-    FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements, true);
+    FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements, false);
     PatchSide b =
         new PatchSide(
             null,
@@ -135,9 +139,26 @@
             a.displayMethod,
             a.fileMode);
 
+    RawText aRawText = new RawText(a.src.getContent());
+    RawText bRawText = new RawText(fixResult.text.getContent());
+    RawTextComparator cmp = comparatorFor(diffPrefs != null ? diffPrefs.ignoreWhitespace : null);
+    EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
+
+    ImmutableList<Edit> finalEdits;
+    if (diffPrefs == null
+        || diffPrefs.intralineDifference == null
+        || diffPrefs.intralineDifference) {
+      IntraLineDiff intraLineDiff =
+          IntraLineLoader.compute(
+              a.src, fixResult.text, ImmutableList.copyOf(edits), ImmutableSet.of());
+      finalEdits = ImmutableList.copyOf(intraLineDiff.getEdits());
+    } else {
+      finalEdits = ImmutableList.copyOf(edits);
+    }
+
     PatchFileChange change =
         new PatchFileChange(
-            fixResult.edits,
+            finalEdits,
             ImmutableSet.of(),
             ImmutableList.of(),
             fileName,
@@ -148,6 +169,24 @@
     return build(a, b, change);
   }
 
+  private static RawTextComparator comparatorFor(
+      @Nullable DiffPreferencesInfo.Whitespace whitespace) {
+    if (whitespace == null) {
+      return RawTextComparator.DEFAULT;
+    }
+    switch (whitespace) {
+      case IGNORE_ALL:
+        return RawTextComparator.WS_IGNORE_ALL;
+      case IGNORE_TRAILING:
+        return RawTextComparator.WS_IGNORE_TRAILING;
+      case IGNORE_LEADING_AND_TRAILING:
+        return RawTextComparator.WS_IGNORE_CHANGE;
+      case IGNORE_NONE:
+      default:
+        return RawTextComparator.DEFAULT;
+    }
+  }
+
   private PatchSide resolveSideA(
       Repository git, SidesResolver sidesResolver, String path, ObjectId baseId)
       throws IOException {
diff --git a/java/com/google/gerrit/server/patch/Text.java b/java/com/google/gerrit/server/patch/Text.java
index 8a56def..a496b61 100644
--- a/java/com/google/gerrit/server/patch/Text.java
+++ b/java/com/google/gerrit/server/patch/Text.java
@@ -78,7 +78,7 @@
         // logger.atFine().log("Detected charset: %s", encoding);
         return Charset.forName(encoding);
       }
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atWarning().log("ICU4J detection error: %s", e.getMessage());
     }
     // 3) Fallback to ISO-8859-1.
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesLoader.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesLoader.java
index 30be303..1091d54 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesLoader.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesLoader.java
@@ -98,7 +98,8 @@
      * trees that are newly created or that were created in memory only. Also see the javadoc on
      * this class.
      */
-    ModifiedFilesLoader createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache() {
+    public ModifiedFilesLoader
+        createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache() {
       return new ModifiedFilesLoader(gitModifiedFilesCache);
     }
   }
@@ -107,6 +108,8 @@
 
   @Nullable private Integer renameScore = null;
 
+  private boolean skipRebaseFiltering = false;
+
   ModifiedFilesLoader(@Nullable GitModifiedFilesCache gitModifiedFilesCache) {
     this.gitModifiedFilesCache = gitModifiedFilesCache;
   }
@@ -123,6 +126,12 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
+  public ModifiedFilesLoader withSkipRebaseFiltering(boolean skipRebaseFiltering) {
+    this.skipRebaseFiltering = skipRebaseFiltering;
+    return this;
+  }
+
   /**
    * Loads the files that have been modified between {@code baseCommit} and {@code newCommit}.
    *
@@ -155,6 +164,9 @@
       if (baseCommit.equals(ObjectId.zeroId())) {
         return modifiedFiles;
       }
+      if (skipRebaseFiltering) {
+        return modifiedFiles;
+      }
       RevCommit revCommitBase = DiffUtil.getRevCommit(revWalk, baseCommit);
       RevCommit revCommitNew = DiffUtil.getRevCommit(revWalk, newCommit);
       if (DiffUtil.areRelated(revCommitBase, revCommitNew)) {
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index d417fe3..9abbe45 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -30,12 +30,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffUtil;
 import com.google.gerrit.server.patch.Text;
@@ -58,6 +61,10 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.EditList;
 import org.eclipse.jgit.diff.RawText;
@@ -146,12 +153,19 @@
   static class FileDiffLoader extends CacheLoader<FileDiffCacheKey, FileDiffOutput> {
     private final GitRepositoryManager repoManager;
     private final AllDiffsEvaluator.Factory allDiffsEvaluatorFactory;
+    private final ExecutorService diffExecutor;
+    private final ExperimentFeatures experimentFeatures;
 
     @Inject
     FileDiffLoader(
-        AllDiffsEvaluator.Factory allDiffsEvaluatorFactory, GitRepositoryManager manager) {
+        AllDiffsEvaluator.Factory allDiffsEvaluatorFactory,
+        GitRepositoryManager manager,
+        @DiffExecutor ExecutorService diffExecutor,
+        ExperimentFeatures experimentFeatures) {
       this.allDiffsEvaluatorFactory = allDiffsEvaluatorFactory;
       this.repoManager = manager;
+      this.diffExecutor = diffExecutor;
+      this.experimentFeatures = experimentFeatures;
     }
 
     @Override
@@ -191,7 +205,7 @@
                 fileKeys.add(key);
               }
             }
-            result.putAll(createFileEntries(reader, fileKeys, rw));
+            result.putAll(createFileEntries(project, reader, fileKeys, rw));
           } catch (IOException e) {
             logger.atWarning().log("Failed to open the repository %s: %s", project, e.getMessage());
           }
@@ -390,13 +404,53 @@
     }
 
     private Map<FileDiffCacheKey, FileDiffOutput> createFileEntries(
-        ObjectReader reader, List<FileDiffCacheKey> keys, RevWalk rw)
+        Project.NameKey project, ObjectReader reader, List<FileDiffCacheKey> keys, RevWalk rw)
         throws DiffNotAvailableException, IOException {
-      Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> allFileDiffs =
-          allDiffsEvaluatorFactory.create(rw).execute(wrapKeys(keys, rw));
-
+      Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> allFileDiffs = new HashMap<>();
       Map<FileDiffCacheKey, FileDiffOutput> result = new HashMap<>();
 
+      if (experimentFeatures.isFeatureEnabled(
+          ExperimentFeaturesConstants.TIMEOUT_FILE_DIFF_COMPUTATION, project)) {
+        Map<FileDiffCacheKey, Future<Map<AugmentedFileDiffCacheKey, AllFileGitDiffs>>> futures =
+            new HashMap<>();
+        for (FileDiffCacheKey key : keys) {
+          List<AugmentedFileDiffCacheKey> augmentedKeys = wrapKeys(ImmutableList.of(key), rw);
+          futures.put(
+              key,
+              diffExecutor.submit(
+                  () -> {
+                    try (Repository repo = repoManager.openRepository(project);
+                        ObjectReader innerReader = repo.newObjectReader();
+                        RevWalk innerRw = new RevWalk(innerReader)) {
+                      return allDiffsEvaluatorFactory.create(innerRw).execute(augmentedKeys);
+                    }
+                  }));
+        }
+
+        for (FileDiffCacheKey key : keys) {
+          try {
+            allFileDiffs.putAll(futures.get(key).get(1, TimeUnit.MINUTES));
+          } catch (InterruptedException | TimeoutException e) {
+            logger.atWarning().withCause(e).log(
+                "Timeout reached while computing diff for key: %s", key);
+            result.put(
+                key,
+                FileDiffOutput.createExpensive(
+                    key.newFilePath(), key.oldCommit(), key.newCommit()));
+          } catch (ExecutionException e) {
+            if (e.getCause() instanceof DiffNotAvailableException) {
+              throw (DiffNotAvailableException) e.getCause();
+            }
+            if (e.getCause() instanceof IOException) {
+              throw (IOException) e.getCause();
+            }
+            throw new DiffNotAvailableException(e);
+          }
+        }
+      } else {
+        allFileDiffs.putAll(allDiffsEvaluatorFactory.create(rw).execute(wrapKeys(keys, rw)));
+      }
+
       for (AugmentedFileDiffCacheKey augmentedKey : allFileDiffs.keySet()) {
         AllFileGitDiffs allDiffs = allFileDiffs.get(augmentedKey);
         GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
@@ -406,7 +460,7 @@
           // negative result.
           result.put(
               augmentedKey.key(),
-              FileDiffOutput.createNegative(
+              FileDiffOutput.createExpensive(
                   mainGitDiff.newPath().orElse(""),
                   augmentedKey.key().oldCommit(),
                   augmentedKey.key().newCommit()));
@@ -495,12 +549,9 @@
             result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
           }
         } catch (IOException e) {
-          logger.atWarning().log(
-              "Failed to evaluate commits relation for key "
-                  + key
-                  + ". Skipping this key: "
-                  + e.getMessage(),
-              e);
+          logger.atWarning().withCause(e).log(
+              "Failed to evaluate commits relation for key %s. Skipping this key: %s",
+              key, e.getMessage());
           result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
         }
       }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index dd6b8d9..3c24894 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -121,6 +121,12 @@
    */
   public abstract Optional<Boolean> negative();
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff, i.e. for diffs
+   * taking a very long time to compute.
+   */
+  public abstract Optional<Boolean> diffsTooExpensiveToCompute();
+
   public abstract Builder toBuilder();
 
   /** A boolean indicating if all underlying edits of the file diff are due to rebase. */
@@ -177,6 +183,17 @@
         .build();
   }
 
+  /**
+   * Create an expensive file diff. We use this to cache diffs for entries that result in timeouts.
+   */
+  public static FileDiffOutput createExpensive(
+      String filePath, ObjectId oldCommitId, ObjectId newCommitId) {
+    return empty(filePath, oldCommitId, newCommitId).toBuilder()
+        .negative(Optional.of(true))
+        .diffsTooExpensiveToCompute(Optional.of(true))
+        .build();
+  }
+
   /** Returns true if this entity represents an unchanged file between two commits. */
   public boolean isEmpty() {
     return headerLines().isEmpty() && edits().isEmpty();
@@ -190,6 +207,14 @@
     return negative().isPresent() && negative().get();
   }
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff. We cache
+   * diffsTooExpensiveToCompute result in this case.
+   */
+  public boolean isTooExpensive() {
+    return diffsTooExpensiveToCompute().isPresent() && diffsTooExpensiveToCompute().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_FileDiffOutput.Builder();
   }
@@ -217,6 +242,9 @@
     if (negative().isPresent()) {
       result += 1;
     }
+    if (diffsTooExpensiveToCompute().isPresent()) {
+      result += 1;
+    }
     return result;
   }
 
@@ -255,6 +283,12 @@
 
     public abstract Builder negative(Optional<Boolean> value);
 
+    /**
+     * Returns {@code true} if the diff computation was not able to compute a diff, i.e. for diffs
+     * taking a very long time to compute.
+     */
+    public abstract Builder diffsTooExpensiveToCompute(Optional<Boolean> value);
+
     public abstract FileDiffOutput build();
   }
 
@@ -288,6 +322,9 @@
     private static final FieldDescriptor NEW_SHA_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(16);
 
+    private static final FieldDescriptor TOO_EXPENSIVE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(17);
+
     @Override
     public byte[] serialize(FileDiffOutput fileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -332,6 +369,10 @@
         builder.setNegative(fileDiff.negative().get());
       }
 
+      if (fileDiff.diffsTooExpensiveToCompute().isPresent()) {
+        builder.setDiffsTooExpensiveToCompute(fileDiff.diffsTooExpensiveToCompute().get());
+      }
+
       if (fileDiff.oldMode().isPresent()) {
         builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.oldMode().get()));
       }
@@ -388,6 +429,9 @@
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
       }
+      if (proto.hasField(TOO_EXPENSIVE_DESCRIPTOR)) {
+        builder.diffsTooExpensiveToCompute(Optional.of(proto.getDiffsTooExpensiveToCompute()));
+      }
       if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
         builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
       }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 580aef5..23b0024 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.patch.filediff.Edit;
 import com.google.protobuf.Descriptors.FieldDescriptor;
 import java.util.Optional;
+import org.eclipse.jgit.attributes.Attribute;
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.FileMode;
@@ -66,8 +67,17 @@
    * parameters.
    */
   static GitFileDiff create(DiffEntry diffEntry, FileHeader fileHeader) {
+    Attribute diffAttr = diffEntry.getDiffAttribute();
+    // Treat the file as binary if .gitattributes explicitly unsets diffing (e.g. "-diff"
+    // or the "binary" macro which JGit expands to "-diff -merge -text").
+    boolean isBinary = diffAttr != null && diffAttr.getState() == Attribute.State.UNSET;
+
     ImmutableList<Edit> edits =
-        fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+        isBinary
+            ? ImmutableList.of()
+            : fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+
+    PatchType patchType = isBinary ? PatchType.BINARY : FileHeaderUtil.getPatchType(fileHeader);
 
     return builder()
         .edits(edits)
@@ -77,7 +87,7 @@
         .oldPath(FileHeaderUtil.getOldPath(fileHeader))
         .newPath(FileHeaderUtil.getNewPath(fileHeader))
         .changeType(FileHeaderUtil.getChangeType(fileHeader))
-        .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+        .patchType(Optional.of(patchType))
         .oldMode(Optional.of(mapFileMode(diffEntry.getOldMode())))
         .newMode(Optional.of(mapFileMode(diffEntry.getNewMode())))
         .build();
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 2d2ec18..3941975 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import javax.inject.Inject;
 
 /** Access control management for a user accessing a single change. */
 public class ChangeControl extends AbstractChangeControl {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 0113355..0e5820a 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -121,7 +121,7 @@
       return currentUser.get().isIdentifiedUser()
           ? Optional.of(currentUser.get().getAccountId())
           : Optional.empty();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atFine().withCause(e).log("Unable to get current user");
       return Optional.empty();
     }
@@ -145,7 +145,7 @@
                     projectControlFactory.create(
                         user, projectCache.get(project).orElseThrow(illegalState(project))));
         return control.asForProject();
-      } catch (Exception e) {
+      } catch (RuntimeException e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
         return FailedPermissionBackend.project(
             "project '" + project.get() + "' is unavailable", cause);
@@ -195,17 +195,17 @@
         case FLUSH_CACHES, KILL_TASK, RUN_GC, VIEW_CACHES, VIEW_QUEUE ->
             has(globalPermissionName(perm)) || can(GlobalPermission.MAINTAIN_SERVER);
         case CREATE_ACCOUNT,
-                CREATE_GROUP,
-                DELETE_GROUP,
-                CREATE_PROJECT,
-                MAINTAIN_SERVER,
-                MODIFY_ACCOUNT,
-                READ_AS,
-                STREAM_EVENTS,
-                VIEW_ACCESS,
-                VIEW_ALL_ACCOUNTS,
-                VIEW_CONNECTIONS,
-                VIEW_PLUGINS ->
+            CREATE_GROUP,
+            DELETE_GROUP,
+            CREATE_PROJECT,
+            MAINTAIN_SERVER,
+            MODIFY_ACCOUNT,
+            READ_AS,
+            STREAM_EVENTS,
+            VIEW_ACCESS,
+            VIEW_ALL_ACCOUNTS,
+            VIEW_CONNECTIONS,
+            VIEW_PLUGINS ->
             has(globalPermissionName(perm)) || isAdmin();
         case VIEW_SECONDARY_EMAILS ->
             has(globalPermissionName(perm))
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 31be280..4e156fa 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -349,7 +349,7 @@
       boolean canRead =
           permissionBackendForProject.ref(dest.branch()).test(RefPermission.READ_PRIVATE_CHANGES);
       logger.atFinest().log(
-          "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", name);
+          "Foreign change edit ref is %s: %s", canRead ? "visible" : "invisible", name);
       return canRead;
     }
 
@@ -359,7 +359,7 @@
 
   private boolean isMetadata(String name) {
     boolean isMetaData = RefNames.isRefsChanges(name) || RefNames.isRefsEdit(name);
-    logger.atFinest().log("ref %s is " + (isMetaData ? "" : "not ") + "a metadata ref", name);
+    logger.atFinest().log("ref %s is %sa metadata ref", isMetaData ? "" : "not ", name);
     return isMetaData;
   }
 
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index d8109af..e7aa70ee 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -115,7 +115,7 @@
                 var unused = cd.notes();
 
                 return cd;
-              } catch (Exception e) {
+              } catch (RuntimeException e) {
                 // We drop changes that we can't load. The repositories contain 'dead' change refs
                 // and we want to overall operation to continue.
                 logger.atFinest().withCause(e).log("Can't load Change notes for %s", id);
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index 756e922..4f7768b 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
-import javax.inject.Inject;
-import javax.inject.Singleton;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.Constants;
 
 /**
@@ -181,7 +181,7 @@
             .ref(cd.change().getDest().branch())
             .test(RefPermission.READ_PRIVATE_CHANGES);
     logger.atFinest().log(
-        "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", refName);
+        "Foreign change edit ref is %s: %s", canRead ? "visible" : "invisible", refName);
     return canRead;
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java b/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
index d592d17..20a032d 100644
--- a/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
+++ b/java/com/google/gerrit/server/plugins/AutoRegisterUtil.java
@@ -23,7 +23,7 @@
   public static Annotation calculateBindAnnotation(Class<Object> impl) {
     Annotation n = impl.getAnnotation(Export.class);
     if (n == null) {
-      n = impl.getAnnotation(javax.inject.Named.class);
+      n = impl.getAnnotation(jakarta.inject.Named.class);
     }
     if (n == null) {
       n = impl.getAnnotation(com.google.inject.name.Named.class);
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 8cfc6f3..c2ae910 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicSetsOf;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
@@ -64,7 +66,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.CopyOnWriteArrayList;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
@@ -82,9 +83,9 @@
   private final ThreadLocalRequestContext local;
   private final CopyConfigModule copyConfigModule;
   private final Set<Key<?>> copyConfigKeys;
-  private final List<StartPluginListener> onStart;
-  private final List<StopPluginListener> onStop;
-  private final List<ReloadPluginListener> onReload;
+  private final ArrayListMultimap<String, StartPluginListener> onStart;
+  private final ArrayListMultimap<String, StopPluginListener> onStop;
+  private final ArrayListMultimap<String, ReloadPluginListener> onReload;
   private final MetricMaker serverMetrics;
 
   private Module sysModule;
@@ -124,14 +125,14 @@
     this.copyConfigKeys = Guice.createInjector(ccm).getAllBindings().keySet();
     this.serverMetrics = serverMetrics;
 
-    onStart = new CopyOnWriteArrayList<>();
-    onStart.addAll(listeners(sysInjector, StartPluginListener.class));
+    onStart = ArrayListMultimap.create();
+    onStart.putAll(PluginName.GERRIT, listeners(sysInjector, StartPluginListener.class));
 
-    onStop = new CopyOnWriteArrayList<>();
-    onStop.addAll(listeners(sysInjector, StopPluginListener.class));
+    onStop = ArrayListMultimap.create();
+    onStop.putAll(PluginName.GERRIT, listeners(sysInjector, StopPluginListener.class));
 
-    onReload = new CopyOnWriteArrayList<>();
-    onReload.addAll(listeners(sysInjector, ReloadPluginListener.class));
+    onReload = ArrayListMultimap.create();
+    onReload.putAll(PluginName.GERRIT, listeners(sysInjector, ReloadPluginListener.class));
 
     sysItems = dynamicItemsOf(sysInjector);
     sysSets = dynamicSetsOf(sysInjector);
@@ -185,9 +186,7 @@
     sshItems = dynamicItemsOf(injector);
     sshSets = dynamicSetsOf(injector);
     sshMaps = dynamicMapsOf(injector);
-    onStart.addAll(listeners(injector, StartPluginListener.class));
-    onStop.addAll(listeners(injector, StopPluginListener.class));
-    onReload.addAll(listeners(injector, ReloadPluginListener.class));
+    addOnStartStopReloadListeners(PluginName.GERRIT, injector);
   }
 
   boolean hasSshModule() {
@@ -208,9 +207,19 @@
     httpItems = dynamicItemsOf(injector);
     httpSets = httpDynamicSetsOf(injector);
     httpMaps = dynamicMapsOf(injector);
-    onStart.addAll(listeners(injector, StartPluginListener.class));
-    onStop.addAll(listeners(injector, StopPluginListener.class));
-    onReload.addAll(listeners(injector, ReloadPluginListener.class));
+    addOnStartStopReloadListeners(PluginName.GERRIT, injector);
+  }
+
+  private void addOnStartStopReloadListeners(String pluginName, Injector injector) {
+    onStart.putAll(pluginName, listeners(injector, StartPluginListener.class));
+    onStop.putAll(pluginName, listeners(injector, StopPluginListener.class));
+    onReload.putAll(pluginName, listeners(injector, ReloadPluginListener.class));
+  }
+
+  private void removeOnStartStopReloadListeners(String pluginName) {
+    onStart.removeAll(pluginName);
+    onStop.removeAll(pluginName);
+    onReload.removeAll(pluginName);
   }
 
   private Map<TypeLiteral<?>, DynamicSet<?>> httpDynamicSetsOf(Injector i) {
@@ -280,9 +289,11 @@
       exit(oldContext);
     }
 
-    for (StartPluginListener l : onStart) {
+    for (StartPluginListener l : onStart.values()) {
       l.onStartPlugin(plugin);
     }
+
+    addOnStartStopReloadListeners(plugin.getName(), plugin.getSysInjector());
   }
 
   private ImmutableList<Injector> listOfInjectors(Injector... injectors) {
@@ -297,10 +308,18 @@
     return injectorsListBuilder.build();
   }
 
+  public void beforeStopPlugin(Plugin plugin) {
+    for (StopPluginListener l : onStop.values()) {
+      l.beforeStopPlugin(plugin);
+    }
+  }
+
   public void onStopPlugin(Plugin plugin) {
-    for (StopPluginListener l : onStop) {
+    for (StopPluginListener l : onStop.values()) {
       l.onStopPlugin(plugin);
     }
+
+    removeOnStartStopReloadListeners(plugin.getName());
   }
 
   private void attachItem(
@@ -376,9 +395,12 @@
       exit(oldContext);
     }
 
-    for (ReloadPluginListener l : onReload) {
+    for (ReloadPluginListener l : onReload.values()) {
       l.onReloadPlugin(oldPlugin, newPlugin);
     }
+
+    removeOnStartStopReloadListeners(oldPlugin.getName());
+    addOnStartStopReloadListeners(newPlugin.getName(), newPlugin.getSysInjector());
   }
 
   private void reattachMap(
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 7c7d8d5..b6e0c3e 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -261,6 +261,7 @@
           continue;
         }
 
+        env.beforeStopPlugin(active);
         unloadPlugin(active);
         try {
           FileSnapshot snapshot = FileSnapshot.save(off.toFile());
@@ -356,6 +357,7 @@
     srvInfoImpl.state = ServerInformation.State.SHUTDOWN;
     synchronized (this) {
       for (Plugin p : running.values()) {
+        env.beforeStopPlugin(p);
         unloadPlugin(p);
       }
       running.clear();
@@ -516,6 +518,7 @@
       name = newPlugin.getName();
       boolean reload = oldPlugin != null && oldPlugin.canReload() && newPlugin.canReload();
       if (!reload && oldPlugin != null) {
+        env.beforeStopPlugin(oldPlugin);
         unloadPlugin(oldPlugin);
       }
       if (!newPlugin.isDisabled()) {
@@ -527,6 +530,7 @@
         }
       }
       if (reload) {
+        env.beforeStopPlugin(oldPlugin);
         env.onReloadPlugin(oldPlugin, newPlugin);
         unloadPlugin(oldPlugin);
       } else if (!newPlugin.isDisabled()) {
@@ -560,6 +564,7 @@
       if (runningPlugin.getApiModule().isPresent()) {
         logger.atWarning().log("Cannot remove plugin %s as it has registered an ApiModule", name);
       } else {
+        env.beforeStopPlugin(runningPlugin);
         unloadPlugin(running.get(name));
       }
     }
diff --git a/java/com/google/gerrit/server/plugins/StopPluginListener.java b/java/com/google/gerrit/server/plugins/StopPluginListener.java
index 7ce53a9..27e0551 100644
--- a/java/com/google/gerrit/server/plugins/StopPluginListener.java
+++ b/java/com/google/gerrit/server/plugins/StopPluginListener.java
@@ -16,5 +16,19 @@
 
 /** Broadcasts event indicating a plugin was unloaded. */
 public interface StopPluginListener {
-  void onStopPlugin(Plugin plugin);
+
+  /**
+   * Called when the plugin is being stopped, but its GuiceEnvironment is still accessible.
+   *
+   * @param plugin {@link Plugin} about to be stopped
+   */
+  default void beforeStopPlugin(Plugin plugin) {}
+
+  /**
+   * Called when the plugin has been stopped, including its GuiceEnvironment.
+   *
+   * @param plugin {@link Plugin} that has been stopped
+   */
+  default void onStopPlugin(Plugin plugin) {}
+  ;
 }
diff --git a/java/com/google/gerrit/server/project/CoreLockKeys.java b/java/com/google/gerrit/server/project/CoreLockKeys.java
new file mode 100644
index 0000000..09caf2c
--- /dev/null
+++ b/java/com/google/gerrit/server/project/CoreLockKeys.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2026 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.entities.Project;
+
+/**
+ * {@link LockKey}s for locks owned by Gerrit core.
+ *
+ * <p>Centralizing them here prevents two unrelated core call sites from silently colliding on the
+ * same lock name. Plugins should use {@link LockKey#plugin(String, String, String...)} instead.
+ */
+public final class CoreLockKeys {
+  public static final LockKey CHANGE_CLEANUP = LockKey.core("change-cleanup");
+  public static final LockKey DRAFT_COMMENTS_CLEANUP = LockKey.core("draft-comments-cleanup");
+  public static final LockKey MIGRATE_PASSWORDS_TO_TOKENS =
+      LockKey.core("migrate-passwords-to-tokens");
+  public static final LockKey REDUCE_MAX_AUTH_TOKEN_LIFETIME =
+      LockKey.core("reduce-max-auth-token-lifetime");
+
+  public static LockKey createProject(Project.NameKey projectName) {
+    return LockKey.core("create-project", projectName.get());
+  }
+
+  private CoreLockKeys() {}
+}
diff --git a/java/com/google/gerrit/server/project/LockKey.java b/java/com/google/gerrit/server/project/LockKey.java
new file mode 100644
index 0000000..7f931a5
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LockKey.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2026 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.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import java.util.Objects;
+
+/**
+ * Identifies a {@link LockManager} lock: a {@code namespace}, a {@code name}, and optional {@code
+ * args} that further scope the lock (e.g. a project name).
+ */
+public record LockKey(String namespace, String name, ImmutableList<String> args) {
+  private static final String CORE_NAMESPACE = "gerrit";
+  private static final String PLUGIN_NAMESPACE_PREFIX = "plugins/";
+
+  public LockKey {
+    Objects.requireNonNull(namespace, "namespace cannot be null");
+    Objects.requireNonNull(name, "name cannot be null");
+  }
+
+  /** Returns a lock key in the given namespace. */
+  public static LockKey of(String namespace, String name, String... args) {
+    return new LockKey(namespace, name, ImmutableList.copyOf(args));
+  }
+
+  /** Returns a lock key in the Gerrit core namespace. */
+  public static LockKey core(String name, String... args) {
+    return of(CORE_NAMESPACE, name, args);
+  }
+
+  /** Returns a lock key in the plugin/<pluginName> namespace. */
+  public static LockKey plugin(String pluginName, String name, String... args) {
+    return of(PLUGIN_NAMESPACE_PREFIX + pluginName, name, args);
+  }
+
+  @Override
+  public String toString() {
+    return Joiner.on('~').join(Iterables.concat(ImmutableList.of(namespace, name), args));
+  }
+}
diff --git a/java/com/google/gerrit/server/project/LockManager.java b/java/com/google/gerrit/server/project/LockManager.java
index 8a85b32..cecea8e 100644
--- a/java/com/google/gerrit/server/project/LockManager.java
+++ b/java/com/google/gerrit/server/project/LockManager.java
@@ -28,5 +28,12 @@
  * lock manager that provides global locks.
  */
 public interface LockManager {
-  public Lock getLock(String name);
+
+  @Deprecated
+  Lock getLock(String name);
+
+  /** Returns a lock identified by {@code key}. */
+  default Lock getLock(LockKey key) {
+    return getLock(key.toString());
+  }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index b2d5025..3e80f25 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -121,6 +121,7 @@
   public static final String KEY_BRANCH = "branch";
 
   public static final String SUBMIT_REQUIREMENT = "submit-requirement";
+  public static final String SUBMIT_REQUIREMENT_TEMPLATE = "submit-requirement-template";
   public static final String KEY_SR_DESCRIPTION = "description";
   public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicableIf";
   public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
@@ -257,6 +258,7 @@
   private Map<String, NotifyConfig> notifySections;
   private Map<String, LabelType> labelSections;
   private Map<String, SubmitRequirement> submitRequirementSections;
+  private Map<String, SubmitRequirement> submitRequirementTemplateSections;
   private ConfiguredMimeTypes mimeTypes;
   private Map<Project.NameKey, SubscribeSection> subscribeSections;
   private Map<String, StoredCommentLinkInfo> commentLinkSections;
@@ -549,6 +551,11 @@
     return submitRequirementSections;
   }
 
+  /** Returns the submit requirement templates defined in config. */
+  public Map<String, SubmitRequirement> getSubmitRequirementTemplateSections() {
+    return submitRequirementTemplateSections;
+  }
+
   /** Adds or replaces the given {@link SubmitRequirement} in this config. */
   public void upsertSubmitRequirement(SubmitRequirement requirement) {
     submitRequirementSections.put(requirement.name(), requirement);
@@ -739,6 +746,7 @@
     loadNotifySections(rc);
     loadLabelSections(rc);
     loadSubmitRequirementSections(rc);
+    loadSubmitRequirementTemplateSections(rc);
     loadCommentLinkSections(rc);
     loadSubscribeSections(rc);
     mimeTypes = ConfiguredMimeTypes.create(projectName.get(), rc);
@@ -1016,52 +1024,80 @@
         continue;
       }
       lowerNames.put(lower, name);
-      String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
-      String applicabilityExpr =
-          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
-      String submittabilityExpr =
-          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
-      String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
-      boolean canInherit;
-      try {
-        canInherit =
-            rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
-      } catch (IllegalArgumentException e) {
-        String canInheritValue =
-            rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
-        error(
-            String.format(
-                "Invalid value %s.%s.%s for submit requirement '%s': %s",
-                SUBMIT_REQUIREMENT,
-                name,
-                KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
-                name,
-                canInheritValue));
-        continue;
-      }
+      readSubmitRequirement(rc, SUBMIT_REQUIREMENT, name, "submit requirement", false)
+          .ifPresent(submitRequirement -> submitRequirementSections.put(name, submitRequirement));
+    }
+  }
 
-      if (submittabilityExpr == null) {
-        error(
-            String.format(
-                "Setting a submittability expression for submit requirement '%s' is required:"
-                    + " Missing %s.%s.%s",
-                name, SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION));
-        continue;
-      }
+  /**
+   * Loads submit requirement template sections from {@code project.config}. Templates are stored
+   * under {@code [submit-requirement-template "name"]} and follow the same structure as regular
+   * submit requirements, but are only intended as pre-configured examples for project owners to
+   * select from.
+   */
+  private void loadSubmitRequirementTemplateSections(Config rc) {
+    submitRequirementTemplateSections = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(SUBMIT_REQUIREMENT_TEMPLATE)) {
+      readSubmitRequirement(
+              rc, SUBMIT_REQUIREMENT_TEMPLATE, name, "submit requirement template", true)
+          .ifPresent(template -> submitRequirementTemplateSections.put(name, template));
+    }
+  }
 
-      // The expressions are validated in SubmitRequirementConfigValidator.
+  private Optional<SubmitRequirement> readSubmitRequirement(
+      Config rc, String section, String name, String entityName, boolean validateName) {
+    String description = rc.getString(section, name, KEY_SR_DESCRIPTION);
+    String applicabilityExpr = rc.getString(section, name, KEY_SR_APPLICABILITY_EXPRESSION);
+    String submittabilityExpr = rc.getString(section, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
+    String overrideExpr = rc.getString(section, name, KEY_SR_OVERRIDE_EXPRESSION);
+    Optional<Boolean> allowOverrideInChildProjects =
+        readAllowOverrideInChildProjects(rc, section, name, entityName);
+    if (!allowOverrideInChildProjects.isPresent()) {
+      return Optional.empty();
+    }
 
-      SubmitRequirement submitRequirement =
-          SubmitRequirement.builder()
-              .setName(name)
-              .setDescription(Optional.ofNullable(description))
-              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
-              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
-              .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
-              .setAllowOverrideInChildProjects(canInherit)
-              .build();
+    if (submittabilityExpr == null) {
+      error(
+          String.format(
+              "Setting a submittability expression for %s '%s' is required: Missing %s.%s.%s",
+              entityName, name, section, name, KEY_SR_SUBMITTABILITY_EXPRESSION));
+      return Optional.empty();
+    }
 
-      submitRequirementSections.put(name, submitRequirement);
+    if (validateName && Strings.isNullOrEmpty(name)) {
+      error(String.format("Setting a Name for %s is required.", entityName));
+      return Optional.empty();
+    }
+
+    // The expressions are validated in SubmitRequirementConfigValidator.
+    return Optional.of(
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(description))
+            .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+            .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProjects.get())
+            .build());
+  }
+
+  private Optional<Boolean> readAllowOverrideInChildProjects(
+      Config rc, String section, String name, String entityName) {
+    try {
+      return Optional.of(rc.getBoolean(section, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false));
+    } catch (IllegalArgumentException e) {
+      String allowOverrideInChildProjectsValue =
+          rc.getString(section, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
+      error(
+          String.format(
+              "Invalid value %s.%s.%s for %s '%s': %s",
+              section,
+              name,
+              KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+              entityName,
+              name,
+              allowOverrideInChildProjectsValue));
+      return Optional.empty();
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index db5f8ee..06229d3 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -422,23 +423,26 @@
   /** All available label types. */
   public LabelTypes getLabelTypes() {
     Map<String, LabelType> types = new LinkedHashMap<>();
-    globalLabelTypes.forEach(e -> types.put(e.get().getName().toLowerCase(Locale.US), e.get()));
+    for (PluginSetEntryContext<LabelType> e : globalLabelTypes) {
+      LabelType t = e.get();
+      if (!t.getValues().isEmpty()) {
+        types.put(t.getName().toLowerCase(Locale.US), t);
+      }
+    }
     for (ProjectState s : treeInOrder()) {
       for (LabelType type : s.getConfig().getLabelSections().values()) {
         String lower = type.getName().toLowerCase(Locale.US);
         LabelType old = types.get(lower);
         if (old == null || old.isCanOverride()) {
-          types.put(lower, type);
+          if (type.getValues().isEmpty()) {
+            types.remove(lower);
+          } else {
+            types.put(lower, type);
+          }
         }
       }
     }
-    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
-    for (LabelType type : types.values()) {
-      if (!type.getValues().isEmpty()) {
-        all.add(type);
-      }
-    }
-    return new LabelTypes(Collections.unmodifiableList(all));
+    return new LabelTypes(types);
   }
 
   /** All available label types for this change. */
@@ -448,9 +452,21 @@
 
   /** All available label types for this branch. */
   public LabelTypes getLabelTypes(BranchNameKey destination) {
-    List<LabelType> all = getLabelTypes().getLabelTypes();
+    LabelTypes labelTypes = getLabelTypes();
+    List<LabelType> all = labelTypes.getLabelTypes();
 
-    List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
+    boolean hasRefPatterns = false;
+    for (LabelType l : all) {
+      if (l.getRefPatterns() != null) {
+        hasRefPatterns = true;
+        break;
+      }
+    }
+    if (!hasRefPatterns) {
+      return labelTypes;
+    }
+
+    ImmutableList.Builder<LabelType> r = ImmutableList.builderWithExpectedSize(all.size());
     for (LabelType l : all) {
       ImmutableList<String> refs = l.getRefPatterns();
       if (refs == null) {
@@ -473,7 +489,7 @@
       }
     }
 
-    return new LabelTypes(r);
+    return new LabelTypes(r.build());
   }
 
   public List<CommentLinkInfo> getCommentLinks() {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExecutor.java b/java/com/google/gerrit/server/project/SubmitRequirementExecutor.java
new file mode 100644
index 0000000..eee357f
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2026 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 java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@BindingAnnotation
+@Retention(RUNTIME)
+public @interface SubmitRequirementExecutor {}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementTemplateResource.java b/java/com/google/gerrit/server/project/SubmitRequirementTemplateResource.java
new file mode 100644
index 0000000..4100310
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementTemplateResource.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2026 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.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class SubmitRequirementTemplateResource implements RestResource {
+  public static final TypeLiteral<RestView<SubmitRequirementTemplateResource>>
+      SUBMIT_REQUIREMENT_TEMPLATE_KIND = new TypeLiteral<>() {};
+
+  private final ProjectResource project;
+  private final Project.NameKey sourceProject;
+  private final SubmitRequirement submitRequirementTemplate;
+
+  public SubmitRequirementTemplateResource(
+      ProjectResource project,
+      Project.NameKey sourceProject,
+      SubmitRequirement submitRequirementTemplate) {
+    this.project = project;
+    this.sourceProject = sourceProject;
+    this.submitRequirementTemplate = submitRequirementTemplate;
+  }
+
+  public ProjectResource getProject() {
+    return project;
+  }
+
+  public Project.NameKey getSourceProject() {
+    return sourceProject;
+  }
+
+  public SubmitRequirement getSubmitRequirementTemplate() {
+    return submitRequirementTemplate;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 4ec2e6e..db1a3b8 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -19,8 +19,11 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PredicateResult;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
@@ -29,7 +32,12 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.RegexQueryPermissionChecker;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -42,12 +50,22 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.OutOfScopeException;
 import com.google.inject.Provider;
+import com.google.inject.Provides;
+import com.google.inject.ProvisionException;
 import com.google.inject.Scopes;
+import com.google.inject.Singleton;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.function.Function;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Config;
@@ -62,10 +80,11 @@
   private final Config config;
   private final boolean requireOperatorForUpdate;
   private final boolean requireOperatorForEvaluation;
-  // Use a request context to execute predicates as an internal user with expanded visibility.
-  // This is so that the evaluation does not depend on who is running the current request (e.g.
-  // a "ownerin" predicate with group that is not visible to the person making this request).
+  private final ExecutorService executor;
   private final OneOffRequestContext requestContext;
+  private final Provider<CurrentUser> currentUser;
+  private final long executionTimeout;
+  private final Metrics metrics;
   private final SubmitRequirementRegexQueryPermissionChecker regexQueryPermissionChecker;
 
   public static Module module() {
@@ -78,9 +97,43 @@
 
         factory(SubmitRequirementChangeQueryBuilder.Factory.class);
       }
+
+      @Provides
+      @Singleton
+      @SubmitRequirementExecutor
+      ExecutorService provideSubmitRequirementExecutor(
+          WorkQueue workQueue, @GerritServerConfig Config config) {
+        int evaluationThreads = config.getInt("submitRequirement", null, "evaluationThreads", -1);
+
+        if (evaluationThreads < 0) {
+          return MoreExecutors.newDirectExecutorService();
+        }
+
+        if (evaluationThreads == 0) {
+          evaluationThreads = Runtime.getRuntime().availableProcessors();
+        }
+
+        return MoreExecutors.listeningDecorator(
+            workQueue.createQueue(evaluationThreads, "submit-requirement-evaluator"));
+      }
     };
   }
 
+  @Singleton
+  public static class Metrics {
+    private final Timer0 submitRequirementEvaluatorLatency;
+
+    @Inject
+    public Metrics(MetricMaker metricMaker) {
+      submitRequirementEvaluatorLatency =
+          metricMaker.newTimer(
+              "server/project/submit_requirement_evaluator_latency",
+              new Description("Latency for evaluating submit requirements")
+                  .setCumulative()
+                  .setUnit(Description.Units.MILLISECONDS));
+    }
+  }
+
   public static class SubmitRequirementRegexQueryPermissionChecker
       extends RegexQueryPermissionChecker {
     // Keep this list in sync with submit-requirement operators that always compile regexes.
@@ -102,21 +155,29 @@
   }
 
   @Inject
-  private SubmitRequirementsEvaluatorImpl(
+  public SubmitRequirementsEvaluatorImpl(
       SubmitRequirementChangeQueryBuilder.Factory queryBuilderFactory,
       ProjectCache projectCache,
       PluginSetContext<SubmitRequirement> globalSubmitRequirements,
       @GerritServerConfig Config config,
+      Provider<CurrentUser> currentUser,
       OneOffRequestContext requestContext,
+      @SubmitRequirementExecutor ExecutorService executor,
+      Metrics metrics,
       SubmitRequirementRegexQueryPermissionChecker regexQueryPermissionChecker) {
     this.queryBuilderFactory = queryBuilderFactory;
     this.projectCache = projectCache;
     this.globalSubmitRequirements = globalSubmitRequirements;
     this.config = config;
+    this.metrics = metrics;
+    this.currentUser = currentUser;
     this.requestContext = requestContext;
     this.regexQueryPermissionChecker = regexQueryPermissionChecker;
     this.requireOperatorForUpdate = requireOperatorForUpdate();
     this.requireOperatorForEvaluation = requireOperatorForEvaluation();
+    this.executor = executor;
+    this.executionTimeout =
+        config.getTimeUnit("submitRequirement", null, "executionTimeout", 0, TimeUnit.MILLISECONDS);
   }
 
   @Override
@@ -125,6 +186,10 @@
     if (!regexQueryPermissionChecker.isAllowed()) {
       regexQueryPermissionChecker.check(expression.expressionString());
     }
+
+    // Use a request context to execute predicates as an internal user with expanded visibility.
+    // This is so that the evaluation does not depend on who is running the current request (e.g.
+    // a "ownerin" predicate with group that is not visible to the person making this request).
     try (ManualRequestContext ignored = requestContext.open()) {
       @SuppressWarnings("unused")
       var unused =
@@ -147,6 +212,10 @@
   @Override
   public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
       ChangeData cd) {
+    // This method is used to return the full set of submit requirements associated with a
+    // change, therefore it must return the same information and result regardless of the
+    // user that is running the request (e.g. a "ownerin" predicate with group that is not visible
+    // to the person making this request).
     try (ManualRequestContext ignored = requestContext.open()) {
       return getRequirements(cd);
     }
@@ -154,6 +223,8 @@
 
   @Override
   public SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd) {
+    // This method is never used in Gerrit production code, however, its JavaDoc asserts that it
+    // should be executed by running inside an internal user request context.
     try (ManualRequestContext ignored = requestContext.open()) {
       return evaluateRequirementInternal(sr, cd);
     }
@@ -165,6 +236,17 @@
     if (!regexQueryPermissionChecker.isAllowed()) {
       checkRegexPermission(sr);
     }
+
+    // This method is called from the /changes/<change-id>/check.submit_requirement REST-API
+    // which is evaluating a user-crafted submit requirement against a change in Gerrit.
+    // Because of the nature of the request and the lack of trust of the remote user
+    // performing the request, executing the operation using an internal user context
+    // would represent a security risk: the code and expressions used in the submit
+    // requirement passed have not been reviewed or approved by anyone and could either
+    // cause data leak or overload to the Gerrit server.
+    //
+    // Execute the submit requirement using the current user context so that any visibility
+    // or restrictions are taken into account when evaluating it.
     return evaluateRequirementInternal(sr, cd);
   }
 
@@ -193,7 +275,9 @@
               .parse(expression.expressionString());
       PredicateResult predicateResult = changeData.evaluatePredicateTree(predicate);
       return SubmitRequirementExpressionResult.create(expression, predicateResult);
-    } catch (QueryParseException | SubmitRequirementEvaluationException e) {
+    } catch (QueryParseException
+        | SubmitRequirementEvaluationException
+        | IllegalArgumentException e) {
       logger.atWarning().withCause(e).log(
           "Failed to evaluate submit requirement expression: %s", expression.expressionString());
       return SubmitRequirementExpressionResult.error(expression, e.getMessage());
@@ -201,65 +285,151 @@
   }
 
   private SubmitRequirementResult evaluateRequirementInternal(SubmitRequirement sr, ChangeData cd) {
+    Optional<Account.Id> userAccountId = getCurrentAccountId();
     try (TraceTimer timer =
         TraceContext.newTimer(
             "Evaluate submit requirement " + sr.name(),
             Metadata.builder().changeId(cd.change().getId().get()).build())) {
-      Optional<SubmitRequirementExpressionResult> applicabilityResult =
-          sr.applicabilityExpression().isPresent()
-              ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
-              : Optional.empty();
-      Optional<SubmitRequirementExpressionResult> submittabilityResult =
-          Optional.of(
-              SubmitRequirementExpressionResult.notEvaluated(sr.submittabilityExpression()));
-      Optional<SubmitRequirementExpressionResult> overrideResult =
-          sr.overrideExpression().isPresent()
-              ? Optional.of(
-                  SubmitRequirementExpressionResult.notEvaluated(sr.overrideExpression().get()))
-              : Optional.empty();
-      if (!sr.applicabilityExpression().isPresent()
-          || SubmitRequirementResult.assertPass(applicabilityResult)) {
-        submittabilityResult = Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
-        overrideResult =
-            sr.overrideExpression().isPresent()
-                ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
-                : Optional.empty();
-      }
+      Callable<SubmitRequirementResult> task =
+          () -> {
+            try (Timer0.Context ignored = metrics.submitRequirementEvaluatorLatency.start()) {
+              try (ManualRequestContext ctx =
+                  userAccountId.map(requestContext::openAs).orElseGet(requestContext::open)) {
+                Optional<SubmitRequirementExpressionResult> applicabilityResult =
+                    sr.applicabilityExpression().isPresent()
+                        ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+                        : Optional.empty();
 
-      if (applicabilityResult.isPresent()) {
-        logger.atFine().log(
-            "Applicability expression result for SR name '%s':"
-                + " passing atoms: %s, failing atoms: %s",
-            sr.name(),
-            applicabilityResult.get().passingAtoms(),
-            applicabilityResult.get().failingAtoms());
-      }
-      if (submittabilityResult.isPresent()) {
-        logger.atFine().log(
-            "Submittability expression result for SR name '%s':"
-                + " passing atoms: %s, failing atoms: %s",
-            sr.name(),
-            submittabilityResult.get().passingAtoms(),
-            submittabilityResult.get().failingAtoms());
-      }
-      if (overrideResult.isPresent()) {
-        logger.atFine().log(
-            "Override expression result for SR name '%s':"
-                + " passing atoms: %s, failing atoms: %s",
-            sr.name(), overrideResult.get().passingAtoms(), overrideResult.get().failingAtoms());
-      }
+                Optional<SubmitRequirementExpressionResult> submittabilityResult =
+                    Optional.of(
+                        SubmitRequirementExpressionResult.notEvaluated(
+                            sr.submittabilityExpression()));
 
-      return SubmitRequirementResult.builder()
-          .legacy(Optional.of(false))
-          .submitRequirement(sr)
-          .patchSetCommitId(cd.currentPatchSet().commitId())
-          .submittabilityExpressionResult(submittabilityResult)
-          .applicabilityExpressionResult(applicabilityResult)
-          .overrideExpressionResult(overrideResult)
-          .build();
+                Optional<SubmitRequirementExpressionResult> overrideResult =
+                    sr.overrideExpression().isPresent()
+                        ? Optional.of(
+                            SubmitRequirementExpressionResult.notEvaluated(
+                                sr.overrideExpression().get()))
+                        : Optional.empty();
+
+                if (!sr.applicabilityExpression().isPresent()
+                    || SubmitRequirementResult.assertPass(applicabilityResult)) {
+                  submittabilityResult =
+                      Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
+                  overrideResult =
+                      sr.overrideExpression().isPresent()
+                          ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+                          : Optional.empty();
+                }
+
+                if (applicabilityResult.isPresent()) {
+                  logger.atFine().log(
+                      "Applicability expression result for SR name '%s':"
+                          + " passing atoms: %s, failing atoms: %s",
+                      sr.name(),
+                      applicabilityResult.get().passingAtoms(),
+                      applicabilityResult.get().failingAtoms());
+                }
+                if (submittabilityResult.isPresent()) {
+                  logger.atFine().log(
+                      "Submittability expression result for SR name '%s':"
+                          + " passing atoms: %s, failing atoms: %s",
+                      sr.name(),
+                      submittabilityResult.get().passingAtoms(),
+                      submittabilityResult.get().failingAtoms());
+                }
+                if (overrideResult.isPresent()) {
+                  logger.atFine().log(
+                      "Override expression result for SR name '%s':"
+                          + " passing atoms: %s, failing atoms: %s",
+                      sr.name(),
+                      overrideResult.get().passingAtoms(),
+                      overrideResult.get().failingAtoms());
+                }
+
+                return SubmitRequirementResult.builder()
+                    .legacy(Optional.of(false))
+                    .submitRequirement(sr)
+                    .patchSetCommitId(cd.currentPatchSet().commitId())
+                    .submittabilityExpressionResult(submittabilityResult)
+                    .applicabilityExpressionResult(applicabilityResult)
+                    .overrideExpressionResult(overrideResult)
+                    .build();
+              }
+            }
+          };
+      Future<SubmitRequirementResult> future = executor.submit(task);
+
+      try {
+        return future.get(executionTimeout, TimeUnit.MILLISECONDS);
+      } catch (TimeoutException e) {
+        future.cancel(true);
+        logger.atWarning().log("Submit requirement '%s' evaluation timed out", sr.name());
+
+        return timeoutResult(sr, cd);
+      } catch (ExecutionException | InterruptedException e) {
+        logger.atSevere().withCause(e).log("Error evaluating Submit requirement: %s", sr.name());
+        return errorResult(sr, cd, e);
+      }
     }
   }
 
+  private Optional<Account.Id> getCurrentAccountId() {
+    try {
+      CurrentUser user = currentUser.get();
+      return user.isIdentifiedUser()
+          ? Optional.of(user.asIdentifiedUser().getAccountId())
+          : Optional.empty();
+    } catch (OutOfScopeException | ProvisionException e) {
+      // Some non-request callers, such as ChangeIndexer, deliberately expose no scoped user.
+      logger.atFiner().withCause(e).log("Unable to resolve user");
+      return Optional.empty();
+    }
+  }
+
+  private SubmitRequirementResult timeoutResult(SubmitRequirement sr, ChangeData cd) {
+    SubmitRequirementExpressionResult timeout =
+        SubmitRequirementExpressionResult.create(
+            sr.submittabilityExpression(),
+            SubmitRequirementExpressionResult.Status.TIMEOUT,
+            ImmutableList.of(),
+            ImmutableList.of("Execution timeout exceeded"));
+
+    return SubmitRequirementResult.builder()
+        .legacy(Optional.of(false))
+        .submitRequirement(sr)
+        .patchSetCommitId(cd.currentPatchSet().commitId())
+        .submittabilityExpressionResult(Optional.of(timeout))
+        .applicabilityExpressionResult(
+            sr.applicabilityExpression().map(SubmitRequirementExpressionResult::notEvaluated))
+        .overrideExpressionResult(
+            sr.overrideExpression().map(SubmitRequirementExpressionResult::notEvaluated))
+        .build();
+  }
+
+  private SubmitRequirementResult errorResult(SubmitRequirement sr, ChangeData cd, Throwable e) {
+    String msg =
+        (e instanceof ExecutionException && e.getCause() != null)
+            ? e.getCause().getMessage()
+            : e.getMessage();
+    if (msg == null) {
+      msg = e.toString();
+    }
+    SubmitRequirementExpressionResult error =
+        SubmitRequirementExpressionResult.error(sr.submittabilityExpression(), msg);
+
+    return SubmitRequirementResult.builder()
+        .legacy(Optional.of(false))
+        .submitRequirement(sr)
+        .patchSetCommitId(cd.currentPatchSet().commitId())
+        .submittabilityExpressionResult(Optional.of(error))
+        .applicabilityExpressionResult(
+            sr.applicabilityExpression().map(SubmitRequirementExpressionResult::notEvaluated))
+        .overrideExpressionResult(
+            sr.overrideExpression().map(SubmitRequirementExpressionResult::notEvaluated))
+        .build();
+  }
+
   /**
    * Evaluate and return all {@link SubmitRequirement}s.
    *
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index aab1cc5..8a60a40 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
@@ -101,15 +102,22 @@
    * @param cd ChangeData to evaluate
    */
   public List<SubmitRecord> evaluate(ChangeData cd) {
+    if (cd.change() == null) {
+      throw new StorageException("Change not found");
+    }
+
+    if (!cd.change().isClosed() || !opts.recomputeOnClosedChanges()) {
+      List<SubmitRecord> cached = cd.getSubmitRecords(opts);
+      if (cached != null) {
+        return cached;
+      }
+    }
+
     try (TraceTimer timer =
             TraceContext.newTimer(
                 "Evaluate submit rules",
                 Metadata.builder().changeId(cd.change().getId().get()).build());
         Timer0.Context ignored = metrics.submitRuleEvaluationLatency.start()) {
-      if (cd.change() == null) {
-        throw new StorageException("Change not found");
-      }
-
       ProjectState projectState =
           projectCache
               .get(cd.project())
@@ -121,44 +129,50 @@
 
       if (cd.change().isClosed()
           && (!opts.recomputeOnClosedChanges() || OnlineReindexMode.isActive())) {
-        return cd.notes().getSubmitRecords().stream()
-            .map(
-                r -> {
-                  SubmitRecord record = r.deepCopy();
-                  if (record.status == SubmitRecord.Status.OK) {
-                    // Submit records that were OK when they got merged are CLOSED now.
-                    record.status = SubmitRecord.Status.CLOSED;
-                  }
-                  return record;
-                })
-            .collect(toImmutableList());
+        ImmutableList<SubmitRecord> records =
+            cd.notes().getSubmitRecords().stream()
+                .map(
+                    r -> {
+                      SubmitRecord record = r.deepCopy();
+                      if (record.status == SubmitRecord.Status.OK) {
+                        // Submit records that were OK when they got merged are CLOSED now.
+                        record.status = SubmitRecord.Status.CLOSED;
+                      }
+                      return record;
+                    })
+                .collect(toImmutableList());
+        cd.setSubmitRecords(opts, records);
+        return records;
       }
 
       // We evaluate all the plugin-defined evaluators,
       // and then we collect the results in one list.
-      return Streams.stream(submitRules)
-          // Skip evaluating the default submit rule if the project has prolog rules.
-          // Note that in this case, the prolog submit rule will handle labels for us
-          .filter(
-              projectState.hasPrologRules() && prologSubmitRuleUtil.isProjectRulesEnabled()
-                  ? rule -> !(rule.get() instanceof DefaultSubmitRule)
-                  : rule -> true)
-          .map(
-              c ->
-                  c.call(
-                      s -> {
-                        Optional<SubmitRecord> record = s.evaluate(cd);
-                        if (record.isPresent() && record.get().ruleName == null) {
-                          // Only back-fill the ruleName if it was not populated by the "submit
-                          // rule".
-                          record.get().ruleName =
-                              c.getPluginName() + "~" + s.getClass().getSimpleName();
-                        }
-                        return record;
-                      }))
-          .filter(Optional::isPresent)
-          .map(Optional::get)
-          .collect(toImmutableList());
+      ImmutableList<SubmitRecord> records =
+          Streams.stream(submitRules)
+              // Skip evaluating the default submit rule if the project has prolog rules.
+              // Note that in this case, the prolog submit rule will handle labels for us
+              .filter(
+                  projectState.hasPrologRules() && prologSubmitRuleUtil.isProjectRulesEnabled()
+                      ? rule -> !(rule.get() instanceof DefaultSubmitRule)
+                      : rule -> true)
+              .map(
+                  c ->
+                      c.call(
+                          s -> {
+                            Optional<SubmitRecord> record = s.evaluate(cd);
+                            if (record.isPresent() && record.get().ruleName == null) {
+                              // Only back-fill the ruleName if it was not populated by the "submit
+                              // rule".
+                              record.get().ruleName =
+                                  c.getPluginName() + "~" + s.getClass().getSimpleName();
+                            }
+                            return record;
+                          }))
+              .filter(Optional::isPresent)
+              .map(Optional::get)
+              .collect(toImmutableList());
+      cd.setSubmitRecords(opts, records);
+      return records;
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 8aaa5aa..119d64f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -37,7 +37,6 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -240,7 +239,6 @@
     }
 
     if (!pending.isEmpty()) {
-      ensureAllPatchSetsLoaded(pending);
       ensureMessagesLoaded(pending);
       for (ChangeData cd : pending) {
         @SuppressWarnings("unused")
@@ -444,6 +442,7 @@
       Maps.newLinkedHashMapWithExpectedSize(1);
 
   private Map<SubmitRequirement, SubmitRequirementResult> submitRequirements;
+  private Integer unsatisfiedRequirementCount;
 
   private StorageConstraint storageConstraint = StorageConstraint.NOTEDB_ONLY;
   private Change change;
@@ -813,12 +812,11 @@
       }
       if (refStates != null) {
         ImmutableSet<RefState> refs = refStates.get(project);
-        if (refs != null) {
-          String metaRef = RefNames.changeMetaRef(getId());
-          for (RefState r : refs) {
-            if (r.ref().equals(metaRef)) {
-              return Optional.of(r.id());
-            }
+        String metaRef = RefNames.changeMetaRef(getId());
+        for (RefState r : refs) {
+          if (r.ref().equals(metaRef)) {
+            metaRevision = r.id();
+            return Optional.of(metaRevision);
           }
         }
       }
@@ -861,6 +859,30 @@
   @CanIgnoreReturnValue
   public Change reloadChange() {
     metaRevision = null;
+    setPatchSets(null);
+    messages = null;
+    allApprovals = null;
+    allApprovalsWithCopied = null;
+    currentApprovals = null;
+    reviewers = null;
+    reviewersByEmail = null;
+    pendingReviewers = null;
+    pendingReviewersByEmail = null;
+    reviewerUpdates = null;
+    reviewedBy = null;
+    hashtags = null;
+    customKeyedValues = null;
+    attentionSet = null;
+    publishedComments = null;
+    usersWithDrafts = null;
+    submitRequirements = null;
+    submitRecords.clear();
+    submitTypeRecord = null;
+    mergeable = null;
+    diffSummary = null;
+    changedLines = null;
+    currentFiles = null;
+    commitData = null;
     return loadChange();
   }
 
@@ -874,7 +896,6 @@
     change = notes.getChange();
     changeServerId = notes.getServerId();
     metaRevision = null;
-    setPatchSets(null);
     return change;
   }
 
@@ -893,7 +914,9 @@
       }
       notes = notesFactory.create(project(), legacyId, metaRevision);
       change = notes.getChange();
-      setPatchSets(null);
+      if (changeServerId == null) {
+        changeServerId = notes.getServerId();
+      }
     }
     return notes;
   }
@@ -1107,6 +1130,15 @@
     return allApprovalsWithCopied;
   }
 
+  public void setAllApprovals(ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    this.allApprovals = allApprovals;
+  }
+
+  public void setAllApprovalsWithCopied(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovalsWithCopied) {
+    this.allApprovalsWithCopied = allApprovalsWithCopied;
+  }
+
   /**
    * Get legacy submit ('SUBM') approval label
    *
@@ -1223,6 +1255,10 @@
     return publishedComments;
   }
 
+  public void setPublishedComments(List<HumanComment> comments) {
+    this.publishedComments = comments;
+  }
+
   public ImmutableSet<String> getCommentsForIndex() {
     return publishedComments().stream()
         .map(c -> c.message)
@@ -1280,9 +1316,7 @@
       if (!lazyload()) {
         return null;
       }
-
-      // Fail on overflow.
-      totalCommentCount = Ints.checkedCast((long) publishedComments().size());
+      totalCommentCount = publishedComments().size();
     }
     return totalCommentCount;
   }
@@ -1301,6 +1335,10 @@
     return messages;
   }
 
+  public void setMessages(List<ChangeMessage> messages) {
+    this.messages = messages;
+  }
+
   /**
    * Similar to {@link #submitRequirements()}, except that it also converts submit records resulting
    * from the evaluation of legacy submit rules to submit requirements.
@@ -1313,6 +1351,14 @@
         projectConfigReqs, legacyReqs, this);
   }
 
+  public Integer unsatisfiedRequirementCount() {
+    return unsatisfiedRequirementCount;
+  }
+
+  public void setUnsatisfiedRequirementCount(Integer count) {
+    this.unsatisfiedRequirementCount = count;
+  }
+
   /**
    * Get all evaluated submit requirements for this change, including those from parent projects.
    * For closed changes, submit requirements are read from the change notes. For active changes,
@@ -1358,12 +1404,31 @@
     this.submitRequirements = submitRequirements;
   }
 
+  @Nullable
+  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
+    List<SubmitRecord> records = submitRecords.get(options);
+    if (records == null) {
+      Change c = change();
+      if (c != null && !c.isClosed()) {
+        SubmitRuleOptions other =
+            options.toBuilder()
+                .recomputeOnClosedChanges(!options.recomputeOnClosedChanges())
+                .build();
+        records = submitRecords.get(other);
+        if (records != null) {
+          submitRecords.put(options, records);
+        }
+      }
+    }
+    return records;
+  }
+
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
     // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
     // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
     // that with status=CLOSED. The latter is cheap to evaluate as we don't have to run any actual
     // evaluation.
-    List<SubmitRecord> records = submitRecords.get(options);
+    List<SubmitRecord> records = getSubmitRecords(options);
     if (records == null) {
       if (storageConstraint != StorageConstraint.NOTEDB_ONLY) {
         // Submit requirements are expensive. We allow loading them only if this change did not
@@ -1377,21 +1442,19 @@
         return notes().getSubmitRecords();
       }
       records = submitRuleEvaluatorFactory.create(options).evaluate(this);
-      submitRecords.put(options, records);
-      if (!change().isClosed() && submitRecords.size() == 1) {
-        // Cache the SubmitRecord with allowClosed = !allowClosed as the SubmitRecord are the same.
-        submitRecords.put(
-            options.toBuilder()
-                .recomputeOnClosedChanges(!options.recomputeOnClosedChanges())
-                .build(),
-            records);
-      }
+      setSubmitRecords(options, records);
     }
     return records;
   }
 
   public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
     submitRecords.put(options, records);
+    Change c = change();
+    if (c != null && !c.isClosed()) {
+      SubmitRuleOptions other =
+          options.toBuilder().recomputeOnClosedChanges(!options.recomputeOnClosedChanges()).build();
+      submitRecords.put(other, records);
+    }
   }
 
   public SubmitTypeRecord submitTypeRecord() {
@@ -1620,7 +1683,8 @@
    */
   @Nullable
   public Boolean isPureRevert() {
-    if (change().getRevertOf() == null) {
+    Change c = change();
+    if (c == null || c.getRevertOf() == null) {
       return null;
     }
     try {
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index d5f9c5b..bb73016 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -17,10 +17,13 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
@@ -242,6 +245,12 @@
         ChangeField.HASHTAG_SPEC, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
   }
 
+  /** Returns a predicate that matches changes that have an unmet submit requirement. */
+  public static Predicate<ChangeData> unmetRequirement(String requirementName) {
+    return new ChangeIndexPredicate(
+        ChangeField.UNMET_REQUIREMENT_SPEC, requirementName.toLowerCase(Locale.US));
+  }
+
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
   public static Predicate<ChangeData> fuzzyHashtag(String hashtag) {
     // Use toLowerCase without locale to match behavior in ChangeField.
@@ -249,6 +258,11 @@
         ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
   }
 
+  /** Returns a predicate that matches changes that are tagged with at least one hashtag. */
+  public static Predicate<ChangeData> hasHashtag() {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_HASHTAG, "");
+  }
+
   /**
    * Returns a predicate that matches changes in the provided {@code hashtag}. Used with prefixes
    */
@@ -268,6 +282,52 @@
   }
 
   /**
+   * Returns a predicate that matches changes that affect exactly the given number of files, or a
+   * range of file counts, in their latest patch set. The {@code count} parameter may be a plain
+   * integer (exact match) or a range expression such as {@code >2} or {@code <10}.
+   */
+  public static Predicate<ChangeData> filecount(String count) throws QueryParseException {
+    return new FileCountPredicate(count);
+  }
+
+  /**
+   * Returns a predicate that matches changes whose set of real (non-magic) files is exactly the
+   * comma-separated list of paths provided.
+   *
+   * <p>When {@code hasFileCountField} is true, builds {@code AND(path:f1, path:f2, …, filecount:N)}
+   * entirely from index predicates: each {@code path:} clause ensures the file is present, and
+   * {@code filecount:N} ensures no extra files exist.
+   *
+   * <p>When {@code hasFileCountField} is false (older schema versions that lack the {@code
+   * filecount:} field), falls back to {@code AND(path:f1, path:f2, …) +
+   * OnlyPathsPostFilterPredicate}.
+   */
+  public static Predicate<ChangeData> onlyPaths(String paths, boolean hasFileCountField) {
+    ImmutableSet<String> files =
+        Splitter.on(',')
+            .trimResults()
+            .omitEmptyStrings()
+            .splitToStream(paths)
+            .filter(f -> !Patch.isMagic(f))
+            .collect(toImmutableSet());
+
+    ImmutableList.Builder<Predicate<ChangeData>> clauses = ImmutableList.builder();
+    files.forEach(f -> clauses.add(path(f)));
+
+    if (hasFileCountField) {
+      try {
+        clauses.add(new FileCountPredicate(String.valueOf(files.size())));
+      } catch (QueryParseException e) {
+        throw new IllegalStateException("unreachable: files.size() is always a valid integer", e);
+      }
+    } else {
+      clauses.add(new OnlyPathsPostFilterPredicate(files));
+    }
+
+    return Predicate.and(clauses.build());
+  }
+
+  /**
    * Returns a predicate that matches changes with the provided {@code footer} in their commit
    * message.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 16c0658..6030dba 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -182,6 +182,7 @@
   public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
   public static final String FIELD_EXTENSION = "extension";
   public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
+  public static final String FIELD_ONLY_PATHS = "onlypaths";
   public static final String FIELD_FOOTER = "footer";
   public static final String FIELD_FOOTER_NAME = "footernames";
   public static final String FIELD_CONFLICTS = "conflicts";
@@ -193,6 +194,7 @@
   public static final String FIELD_EXACTCOMMIT = "exactcommit";
   public static final String FIELD_FILE = "file";
   public static final String FIELD_FILEPART = "filepart";
+  public static final String FIELD_FILE_COUNT = "filecount";
   public static final String FIELD_GROUP = "group";
   public static final String FIELD_HASHTAG = "hashtag";
   public static final String FIELD_LABEL = "label";
@@ -735,6 +737,11 @@
       return new IsUnresolvedPredicate();
     }
 
+    if ("hashtag".equalsIgnoreCase(value)) {
+      checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "has:hashtag");
+      return ChangePredicates.hasHashtag();
+    }
+
     // for plugins the value will be operandName_pluginName
     List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
@@ -1052,6 +1059,11 @@
   }
 
   @Operator
+  public Predicate<ChangeData> filecount(String count) throws QueryParseException {
+    return ChangePredicates.filecount(count);
+  }
+
+  @Operator
   public Predicate<ChangeData> ext(String ext) {
     return extension(ext);
   }
@@ -1072,6 +1084,15 @@
   }
 
   @Operator
+  public Predicate<ChangeData> onlypaths(String value) {
+    if (value.startsWith("^")) {
+      return new RegexOnlyPathsPredicate(value, args.regexCompiler);
+    }
+    return ChangePredicates.onlyPaths(
+        value, args.getSchema() != null && args.getSchema().hasField(ChangeField.FILE_COUNT_SPEC));
+  }
+
+  @Operator
   public Predicate<ChangeData> footer(String footer) {
     return ChangePredicates.footer(footer);
   }
@@ -1471,6 +1492,21 @@
   }
 
   @Operator
+  public Predicate<ChangeData> unmet_requirement(String requirementName)
+      throws QueryParseException {
+    checkFieldAvailable(ChangeField.UNMET_REQUIREMENT_SPEC, "unmet_requirement");
+    return ChangePredicates.unmetRequirement(requirementName);
+  }
+
+  @Operator
+  public Predicate<ChangeData> unsatisfied_requirement_count(String value)
+      throws QueryParseException {
+    checkFieldAvailable(
+        ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC, "unsatisfied_requirement_count");
+    return new UnsatisfiedRequirementCountPredicate(value);
+  }
+
+  @Operator
   public Predicate<ChangeData> cc(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     return reviewerByState(who, ReviewerStateInternal.CC, false);
diff --git a/java/com/google/gerrit/server/query/change/FileCountPredicate.java b/java/com/google/gerrit/server/query/change/FileCountPredicate.java
new file mode 100644
index 0000000..ed3b07f
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FileCountPredicate.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2026 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.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class FileCountPredicate extends IntegerRangeChangePredicate {
+  public FileCountPredicate(String value) throws QueryParseException {
+    super(ChangeField.FILE_COUNT_SPEC, value);
+  }
+
+  @Override
+  protected Integer getValueInt(ChangeData changeData) {
+    return ChangeField.FILE_COUNT_SPEC.get(changeData);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6c1f35e..ea833dc 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -129,11 +129,38 @@
 
   @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
+    if (ids.isEmpty()) {
+      return Collections.emptyList();
+    }
+    int batchSize = indexConfig.maxTerms();
+    if (ids.size() <= batchSize) {
+      return query(byLegacyChangeIdsPredicate(indexConfig, ids));
+    }
+    List<Predicate<ChangeData>> queries = new ArrayList<>();
+    for (List<Change.Id> part : Iterables.partition(ids, batchSize)) {
+      queries.add(byLegacyChangeIdsPredicate(indexConfig, part));
+    }
+    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(ids.size());
+    ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
+    for (List<ChangeData> cds : query(queries)) {
+      for (ChangeData cd : cds) {
+        if (seen.add(cd.virtualId())) {
+          result.add(cd);
+        }
+      }
+    }
+    return result.build();
+  }
+
+  private static Predicate<ChangeData> byLegacyChangeIdsPredicate(
+      IndexConfig indexConfig, Collection<Change.Id> ids) {
+    int n = indexConfig.maxTerms();
+    checkArgument(ids.size() <= n, "cannot exceed %s change IDs", n);
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       preds.add(ChangePredicates.idStr(id));
     }
-    return query(or(preds));
+    return or(preds);
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
@@ -258,10 +285,35 @@
     return query(and(project(project), commit(hash)));
   }
 
-  public List<ChangeData> byProjectCommits(Project.NameKey project, List<String> hashes) {
+  public List<ChangeData> byProjectCommits(Project.NameKey project, Collection<String> hashes) {
+    if (hashes.isEmpty()) {
+      return Collections.emptyList();
+    }
+    int batchSize = indexConfig.maxTerms() - 1;
+    if (hashes.size() <= batchSize) {
+      return query(byProjectCommitsPredicate(indexConfig, project, hashes));
+    }
+    List<Predicate<ChangeData>> queries = new ArrayList<>();
+    for (List<String> part : Iterables.partition(hashes, batchSize)) {
+      queries.add(byProjectCommitsPredicate(indexConfig, project, part));
+    }
+    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(hashes.size());
+    ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
+    for (List<ChangeData> cds : query(queries)) {
+      for (ChangeData cd : cds) {
+        if (seen.add(cd.virtualId())) {
+          result.add(cd);
+        }
+      }
+    }
+    return result.build();
+  }
+
+  private static Predicate<ChangeData> byProjectCommitsPredicate(
+      IndexConfig indexConfig, Project.NameKey project, Collection<String> hashes) {
     int n = indexConfig.maxTerms() - 1;
     checkArgument(hashes.size() <= n, "cannot exceed %s commits", n);
-    return query(and(project(project), or(commits(hashes))));
+    return and(project(project), or(commits(hashes)));
   }
 
   public List<ChangeData> byBranchCommit(String project, String branch, String hash) {
diff --git a/java/com/google/gerrit/server/query/change/OnlyPathsPostFilterPredicate.java b/java/com/google/gerrit/server/query/change/OnlyPathsPostFilterPredicate.java
new file mode 100644
index 0000000..33f7281
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/OnlyPathsPostFilterPredicate.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2026 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.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.index.query.PostFilterPredicate;
+
+/**
+ * Post-filter that passes only when the change's set of real (non-magic) files is exactly the
+ * expected list (sorted, deduplicated).
+ *
+ * <p>Used as a fallback when the {@code filecount:} index field is not available in the current
+ * schema version.
+ */
+@Deprecated
+class OnlyPathsPostFilterPredicate extends PostFilterPredicate<ChangeData> {
+  private final ImmutableSet<String> expectedFiles;
+
+  OnlyPathsPostFilterPredicate(ImmutableSet<String> files) {
+    super(ChangeQueryBuilder.FIELD_ONLY_PATHS, String.join(",", files));
+    this.expectedFiles = files;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    ImmutableSet<String> realFiles =
+        cd.currentFilePaths().stream().filter(p -> !Patch.isMagic(p)).collect(toImmutableSet());
+    return realFiles.equals(expectedFiles);
+  }
+
+  @Override
+  public int getCost() {
+    return 3;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 9df96a0..cc186c3 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -30,13 +30,14 @@
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.account.AccountAttributeLoader;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
 import com.google.gerrit.server.data.QueryStatsAttribute;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
@@ -88,7 +89,6 @@
   private final ChangeQueryProcessor queryProcessor;
   private final EventFactory eventFactory;
   private final TrackingFooters trackingFooters;
-  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
   private final AccountAttributeLoader.Factory accountAttributeLoaderFactory;
 
   private OutputFormat outputFormat = OutputFormat.TEXT;
@@ -115,14 +115,12 @@
       ChangeQueryProcessor queryProcessor,
       EventFactory eventFactory,
       TrackingFooters trackingFooters,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
       AccountAttributeLoader.Factory accountAttributeLoaderFactory) {
     this.repoManager = repoManager;
     this.queryBuilder = queryBuilder;
     this.queryProcessor = queryProcessor;
     this.eventFactory = eventFactory;
     this.trackingFooters = trackingFooters;
-    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
     this.accountAttributeLoaderFactory = accountAttributeLoaderFactory;
   }
 
@@ -225,6 +223,10 @@
           AccountAttributeLoader accountLoader = accountAttributeLoaderFactory.create();
           List<ChangeAttribute> changeAttributes = new ArrayList<>();
           for (ChangeData d : results.entities()) {
+            if (Thread.currentThread().isInterrupted()) {
+              throw new RequestCancelledException(
+                  RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, null);
+            }
             changeAttributes.add(
                 buildChangeAttribute(d, repos, revWalks, accountLoader, attributesNodeProviders));
           }
@@ -281,8 +283,7 @@
     if (includeSubmitRecords) {
       SubmitRuleOptions options =
           SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
-      eventFactory.addSubmitRecords(
-          c, submitRuleEvaluatorFactory.create(options).evaluate(d), accountLoader);
+      eventFactory.addSubmitRecords(c, d.submitRecords(options), accountLoader);
     }
 
     if (includeCommitMessage) {
diff --git a/java/com/google/gerrit/server/query/change/RegexOnlyPathsPredicate.java b/java/com/google/gerrit/server/query/change/RegexOnlyPathsPredicate.java
new file mode 100644
index 0000000..ca7aa32
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexOnlyPathsPredicate.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2026 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.entities.Patch;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.server.ioutil.RegexCompiler;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
+import java.util.List;
+
+/**
+ * Predicate matching changes where <em>every</em> real (non-magic) file matches the supplied regex
+ * — and no real files fall outside it.
+ *
+ * <p>Magic files ({@code /COMMIT_MSG}, {@code /MERGE_LIST}, {@code /PATCHSET_LEVEL}) are excluded
+ * from matching so that callers never need to mention them in queries.
+ *
+ * <p>Usage: {@code onlypaths:^src/.*\.java$}
+ */
+public class RegexOnlyPathsPredicate extends PostFilterPredicate<ChangeData> {
+  private final RegexListSearcher<String> searcher;
+
+  public RegexOnlyPathsPredicate(String re, RegexCompiler regexCompiler) {
+    super(ChangeQueryBuilder.FIELD_ONLY_PATHS, re);
+    this.searcher = RegexListSearcher.ofStrings(re, regexCompiler);
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    List<String> realFiles =
+        cd.currentFilePaths().stream().filter(p -> !Patch.isMagic(p)).sorted().toList();
+
+    if (realFiles.isEmpty()) {
+      return false;
+    }
+
+    // Every real file must match; a single mismatch disqualifies the change.
+    // RegexListSearcher expects a sorted list — realFiles is already sorted.
+    return realFiles.stream()
+        .allMatch(path -> searcher.search(List.of(path)).findAny().isPresent());
+  }
+
+  @Override
+  public int getCost() {
+    return 3;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 4011117..dbc85ba 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -131,6 +131,21 @@
   }
 
   @Override
+  public Predicate<ChangeData> unmet_requirement(String requirementName)
+      throws QueryParseException {
+    throw new QueryParseException(
+        "Operator 'unmet_requirement' cannot be used in submit requirement expressions.");
+  }
+
+  @Override
+  public Predicate<ChangeData> unsatisfied_requirement_count(String value)
+      throws QueryParseException {
+    throw new QueryParseException(
+        "Operator 'unsatisfied_requirement_count' cannot be used in submit requirement"
+            + " expressions.");
+  }
+
+  @Override
   public Predicate<ChangeData> has(String value) throws QueryParseException {
     if (value.toLowerCase(Locale.US).startsWith(SUBMODULE_UPDATE_HAS_ARG)) {
       List<String> args = SUBMODULE_UPDATE_SPLITTER.splitToList(value);
diff --git a/java/com/google/gerrit/server/query/change/UnsatisfiedRequirementCountPredicate.java b/java/com/google/gerrit/server/query/change/UnsatisfiedRequirementCountPredicate.java
new file mode 100644
index 0000000..827558e
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/UnsatisfiedRequirementCountPredicate.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2026 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.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class UnsatisfiedRequirementCountPredicate extends IntegerRangeChangePredicate {
+  public UnsatisfiedRequirementCountPredicate(String value) throws QueryParseException {
+    super(ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC, value);
+  }
+
+  @Override
+  protected Integer getValueInt(ChangeData changeData) {
+    Integer count = changeData.unsatisfiedRequirementCount();
+    if (count != null) {
+      return count;
+    }
+    return ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC.get(changeData);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index 29163a4..4782318 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
@@ -29,12 +30,13 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.stream.Collectors;
 
 /**
  * Query wrapper for the group index.
@@ -59,38 +61,162 @@
     return getOnlyGroup(GroupPredicates.id(groupId), "group id '" + groupId + "'");
   }
 
-  public List<InternalGroup> byMember(Account.Id memberId) {
+  public Optional<InternalGroup> byUUID(AccountGroup.UUID uuid) {
+    return getOnlyGroup(GroupPredicates.uuid(uuid), "group UUID '" + uuid + "'");
+  }
+
+  public ImmutableList<InternalGroup> byUUIDs(Collection<AccountGroup.UUID> uuids) {
+    if (uuids.isEmpty()) {
+      return ImmutableList.of();
+    }
+    if (uuids.size() == 1) {
+      return query(GroupPredicates.uuid(uuids.iterator().next()));
+    }
+    int batchSize = Math.max(1, indexConfig.maxTerms() - 1);
+    if (uuids.size() <= batchSize) {
+      List<Predicate<InternalGroup>> predicates = new ArrayList<>(uuids.size());
+      for (AccountGroup.UUID uuid : uuids) {
+        predicates.add(GroupPredicates.uuid(uuid));
+      }
+      return query(Predicate.or(predicates));
+    }
+    List<Predicate<InternalGroup>> batchPredicates = new ArrayList<>();
+    for (List<AccountGroup.UUID> partition : Iterables.partition(uuids, batchSize)) {
+      if (partition.size() == 1) {
+        batchPredicates.add(GroupPredicates.uuid(partition.get(0)));
+      } else {
+        List<Predicate<InternalGroup>> predicates = new ArrayList<>(partition.size());
+        for (AccountGroup.UUID uuid : partition) {
+          predicates.add(GroupPredicates.uuid(uuid));
+        }
+        batchPredicates.add(Predicate.or(predicates));
+      }
+    }
+    ImmutableList.Builder<InternalGroup> result = ImmutableList.builder();
+    Set<AccountGroup.UUID> seen = new HashSet<>();
+    for (List<InternalGroup> batchResult : query(batchPredicates)) {
+      for (InternalGroup group : batchResult) {
+        if (seen.add(group.getGroupUUID())) {
+          result.add(group);
+        }
+      }
+    }
+    return result.build();
+  }
+
+  public ImmutableList<InternalGroup> byMember(Account.Id memberId) {
     return query(GroupPredicates.member(memberId));
   }
 
+  public ImmutableList<InternalGroup> byMembers(Collection<Account.Id> memberIds) {
+    if (memberIds.isEmpty()) {
+      return ImmutableList.of();
+    }
+    if (memberIds.size() == 1) {
+      return byMember(memberIds.iterator().next());
+    }
+    int batchSize = Math.max(1, indexConfig.maxTerms() - 1);
+    if (memberIds.size() <= batchSize) {
+      List<Predicate<InternalGroup>> predicates = new ArrayList<>(memberIds.size());
+      for (Account.Id id : memberIds) {
+        predicates.add(GroupPredicates.member(id));
+      }
+      return query(Predicate.or(predicates));
+    }
+    List<Predicate<InternalGroup>> batchPredicates = new ArrayList<>();
+    for (List<Account.Id> partition : Iterables.partition(memberIds, batchSize)) {
+      if (partition.size() == 1) {
+        batchPredicates.add(GroupPredicates.member(partition.get(0)));
+      } else {
+        List<Predicate<InternalGroup>> predicates = new ArrayList<>(partition.size());
+        for (Account.Id id : partition) {
+          predicates.add(GroupPredicates.member(id));
+        }
+        batchPredicates.add(Predicate.or(predicates));
+      }
+    }
+    ImmutableList.Builder<InternalGroup> result = ImmutableList.builder();
+    Set<AccountGroup.UUID> seen = new HashSet<>();
+    for (List<InternalGroup> batchResult : query(batchPredicates)) {
+      for (InternalGroup group : batchResult) {
+        if (seen.add(group.getGroupUUID())) {
+          result.add(group);
+        }
+      }
+    }
+    return result.build();
+  }
+
   /**
    * Get all immediate parents of the provided {@code subgroupIds}.
    *
    * @return map pointing from children to list of its immediate parents
    */
-  public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups(
+  public ImmutableMap<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups(
       ImmutableSet<AccountGroup.UUID> subgroupIds) {
-    List<Predicate<InternalGroup>> predicates =
-        subgroupIds.stream().map(e -> GroupPredicates.subgroup(e)).collect(Collectors.toList());
-    ImmutableList<InternalGroup> groups = query(Predicate.or(predicates));
+    if (subgroupIds.isEmpty()) {
+      return ImmutableMap.of();
+    }
+
+    ImmutableList<InternalGroup> groups;
+    int batchSize = Math.max(1, indexConfig.maxTerms() - 1);
+    if (subgroupIds.size() == 1) {
+      groups = query(GroupPredicates.subgroup(subgroupIds.iterator().next()));
+    } else if (subgroupIds.size() <= batchSize) {
+      List<Predicate<InternalGroup>> predicates = new ArrayList<>(subgroupIds.size());
+      for (AccountGroup.UUID e : subgroupIds) {
+        predicates.add(GroupPredicates.subgroup(e));
+      }
+      groups = query(Predicate.or(predicates));
+    } else {
+      List<Predicate<InternalGroup>> batchPredicates = new ArrayList<>();
+      for (List<AccountGroup.UUID> partition : Iterables.partition(subgroupIds, batchSize)) {
+        if (partition.size() == 1) {
+          batchPredicates.add(GroupPredicates.subgroup(partition.get(0)));
+        } else {
+          List<Predicate<InternalGroup>> predicates = new ArrayList<>(partition.size());
+          for (AccountGroup.UUID e : partition) {
+            predicates.add(GroupPredicates.subgroup(e));
+          }
+          batchPredicates.add(Predicate.or(predicates));
+        }
+      }
+      ImmutableList.Builder<InternalGroup> result = ImmutableList.builder();
+      Set<AccountGroup.UUID> seen = new HashSet<>();
+      for (List<InternalGroup> batchResult : query(batchPredicates)) {
+        for (InternalGroup g : batchResult) {
+          if (seen.add(g.getGroupUUID())) {
+            result.add(g);
+          }
+        }
+      }
+      groups = result.build();
+    }
 
     Map<AccountGroup.UUID, Set<AccountGroup.UUID>> parentsByChild =
-        Maps.newHashMapWithExpectedSize(groups.size());
-    subgroupIds.stream().forEach(c -> parentsByChild.put(c, new HashSet<>()));
+        Maps.newHashMapWithExpectedSize(subgroupIds.size());
+    for (AccountGroup.UUID c : subgroupIds) {
+      parentsByChild.put(c, new HashSet<>());
+    }
     for (InternalGroup parent : groups) {
       for (AccountGroup.UUID child : parent.getSubgroups()) {
-        if (subgroupIds.contains(child)) {
-          parentsByChild.get(child).add(parent.getGroupUUID());
+        Set<AccountGroup.UUID> parents = parentsByChild.get(child);
+        if (parents != null) {
+          parents.add(parent.getGroupUUID());
         }
       }
     }
-    return parentsByChild.entrySet().stream()
-        .collect(Collectors.toMap(Map.Entry::getKey, e -> ImmutableSet.copyOf(e.getValue())));
+    ImmutableMap.Builder<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> result =
+        ImmutableMap.builderWithExpectedSize(subgroupIds.size());
+    for (Map.Entry<AccountGroup.UUID, Set<AccountGroup.UUID>> entry : parentsByChild.entrySet()) {
+      result.put(entry.getKey(), ImmutableSet.copyOf(entry.getValue()));
+    }
+    return result.build();
   }
 
   private Optional<InternalGroup> getOnlyGroup(
       Predicate<InternalGroup> predicate, String groupDescription) {
-    ImmutableList<InternalGroup> groups = query(predicate);
+    ImmutableList<InternalGroup> groups = setLimit(2).query(predicate);
     if (groups.isEmpty()) {
       return Optional.empty();
     }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 5979571..6913da7 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -32,6 +32,7 @@
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/util/crypto",
         "//lib:args4j",
         "//lib:blame-cache",
         "//lib:gson",
diff --git a/java/com/google/gerrit/server/restapi/account/CreateToken.java b/java/com/google/gerrit/server/restapi/account/CreateToken.java
index da83a03..e9934da 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateToken.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateToken.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.gerrit.server.mail.EmailFactories.AUTH_TOKEN_UPDATED;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
@@ -43,12 +42,11 @@
 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.crypto.SecureRandomUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
 import java.sql.Timestamp;
 import java.time.Duration;
 import java.time.Instant;
@@ -72,15 +70,6 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final int LEN = 31;
-  private static final SecureRandom rng;
-
-  static {
-    try {
-      rng = SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException("Cannot create RNG for password generator", e);
-    }
-  }
 
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
@@ -198,17 +187,6 @@
 
   @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
   public static String generate() {
-    byte[] rand = new byte[LEN];
-    rng.nextBytes(rand);
-
-    byte[] enc = BaseEncoding.base64().encode(rand).getBytes(UTF_8);
-    StringBuilder r = new StringBuilder(enc.length);
-    for (int i = 0; i < enc.length; i++) {
-      if (enc[i] == '=') {
-        break;
-      }
-      r.append((char) enc[i]);
-    }
-    return r.toString();
+    return BaseEncoding.base64().omitPadding().encode(SecureRandomUtil.newBytes(LEN));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 8966ec4..05ac95e 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
@@ -69,6 +70,9 @@
   private final Provider<AccountQueryProcessor> queryProcessorProvider;
   private final boolean suggestConfig;
   private final int suggestFrom;
+  private final AccountControl.Factory accountControlFactory;
+
+  private final AccountVisibility accountVisibility;
 
   private AccountLoader accountLoader;
   private boolean suggest;
@@ -134,11 +138,15 @@
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder queryBuilder,
       Provider<AccountQueryProcessor> queryProcessorProvider,
-      @GerritServerConfig Config cfg) {
+      @GerritServerConfig Config cfg,
+      AccountControl.Factory accountControlFactory,
+      AccountVisibility accountVisibility) {
     this.permissionBackend = permissionBackend;
     this.accountLoaderFactory = accountLoaderFactory;
     this.queryBuilder = queryBuilder;
     this.queryProcessorProvider = queryProcessorProvider;
+    this.accountControlFactory = accountControlFactory;
+    this.accountVisibility = accountVisibility;
     this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
     this.options = EnumSet.noneOf(ListAccountsOption.class);
 
@@ -167,8 +175,14 @@
       return Response.ok(Collections.emptyList());
     }
 
+    AccountControl accountControl = accountControlFactory.get();
+    boolean canSeeDetails =
+        accountVisibility == AccountVisibility.ALL
+            || accountControl.getUser().isIdentifiedUser()
+            || accountControl.canViewAll();
+
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.ID);
-    if (options.contains(ListAccountsOption.DETAILS)) {
+    if (options.contains(ListAccountsOption.DETAILS) && canSeeDetails) {
       fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
     }
     boolean modifyAccountCapabilityChecked = false;
@@ -179,13 +193,12 @@
       fillOptions.add(FillOptions.SECONDARY_EMAILS);
     }
     if (suggest) {
-      fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-      fillOptions.add(FillOptions.EMAIL);
+      if (canSeeDetails) {
+        fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+        fillOptions.add(FillOptions.EMAIL);
 
-      if (modifyAccountCapabilityChecked) {
-        fillOptions.add(FillOptions.SECONDARY_EMAILS);
-      } else {
-        if (permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+        if (modifyAccountCapabilityChecked
+            || permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
           fillOptions.add(FillOptions.SECONDARY_EMAILS);
         }
       }
@@ -197,6 +210,7 @@
       throw new MethodNotAllowedException("query disabled");
     }
 
+    queryProcessor.enforceVisibility(true);
     queryProcessor.setUserProvidedLimit(limit != null ? limit : 0, /* applyDefaultLimit */ true);
 
     if (start != null) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index aa9b363..102e1b3 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.CommentsUtil.COMMENT_INFO_ORDER;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
@@ -118,66 +115,85 @@
       AccountLoader loader = fillAccounts ? accountLoaderFactory.get().create(true) : null;
 
       Map<String, List<T>> out = new TreeMap<>();
+      int estimatedSize = (comments instanceof Collection) ? ((Collection<?>) comments).size() : 16;
+      List<T> allComments = fillCommentContext ? new ArrayList<>(estimatedSize) : null;
 
       for (F c : comments) {
         T o = toInfo(c, loader);
-        List<T> list = out.get(o.path);
-        if (list == null) {
-          list = new ArrayList<>();
-          out.put(o.path, list);
+        out.computeIfAbsent(o.path, k -> new ArrayList<>()).add(o);
+        if (fillCommentContext) {
+          allComments.add(o);
         }
-        list.add(o);
       }
 
-      out.values().forEach(l -> l.sort(COMMENT_INFO_ORDER));
+      for (List<T> list : out.values()) {
+        list.sort(COMMENT_INFO_ORDER);
+      }
 
       if (loader != null) {
         loader.fill();
       }
 
-      List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
-      if (fillCommentContext) {
+      if (fillCommentContext && allComments != null && !allComments.isEmpty()) {
         addCommentContext(allComments);
       }
-      allComments.forEach(c -> c.path = null); // we don't need path since it exists in the map keys
+      for (List<T> list : out.values()) {
+        for (T c : list) {
+          c.path = null; // we don't need path since it exists in the map keys
+        }
+      }
       return out;
     }
 
     public ImmutableList<T> formatAsList(Iterable<F> comments) throws PermissionBackendException {
       AccountLoader loader = fillAccounts ? accountLoaderFactory.get().create(true) : null;
 
-      ImmutableList<T> out =
-          Streams.stream(comments)
-              .map(c -> toInfo(c, loader))
-              .sorted(COMMENT_INFO_ORDER)
-              .collect(toImmutableList());
+      int estimatedSize = (comments instanceof Collection) ? ((Collection<?>) comments).size() : 16;
+      List<T> outList = new ArrayList<>(estimatedSize);
+      for (F c : comments) {
+        outList.add(toInfo(c, loader));
+      }
+      outList.sort(COMMENT_INFO_ORDER);
 
       if (loader != null) {
         loader.fill();
       }
 
-      if (fillCommentContext) {
-        addCommentContext(out);
+      if (fillCommentContext && !outList.isEmpty()) {
+        addCommentContext(outList);
       }
 
-      return out;
+      return ImmutableList.copyOf(outList);
     }
 
     protected void addCommentContext(List<T> allComments) {
-      List<CommentContextKey> keys =
-          allComments.stream().map(this::createCommentContextKey).collect(toList());
+      if (allComments.isEmpty()) {
+        return;
+      }
+      List<CommentContextKey> keys = new ArrayList<>(allComments.size());
+      for (T c : allComments) {
+        keys.add(createCommentContextKey(c));
+      }
       ImmutableMap<CommentContextKey, CommentContext> allContext =
           commentContextCache.get().getAll(keys);
-      for (T c : allComments) {
-        CommentContextKey contextKey = createCommentContextKey(c);
+      for (int i = 0; i < allComments.size(); i++) {
+        T c = allComments.get(i);
+        CommentContextKey contextKey = keys.get(i);
         CommentContext commentContext = allContext.get(contextKey);
-        c.contextLines = toContextLineInfoList(commentContext);
-        c.sourceContentType = commentContext.contentType();
+        if (commentContext != null) {
+          c.contextLines = toContextLineInfoList(commentContext);
+          c.sourceContentType = commentContext.contentType();
+        }
       }
     }
 
     protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
-      List<ContextLineInfo> result = new ArrayList<>();
+      if (commentContext == null
+          || commentContext.lines() == null
+          || commentContext.lines().isEmpty()) {
+        return new ArrayList<>();
+      }
+      List<ContextLineInfo> result = new ArrayList<>(commentContext.lines().size());
       for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
         result.add(new ContextLineInfo(e.getKey(), e.getValue()));
       }
@@ -217,6 +233,7 @@
       r.updated = c.writtenOn;
       r.range = toRange(c.range);
       r.tag = c.tag;
+      r.isAi = c.isAi;
       if (loader != null) {
         r.author = loader.get(c.author.getId());
       }
@@ -243,15 +260,24 @@
         return null;
       }
 
-      return fixSuggestions.stream().map(this::toFixSuggestionInfo).collect(toList());
+      List<FixSuggestionInfo> result = new ArrayList<>(fixSuggestions.size());
+      for (FixSuggestion fixSuggestion : fixSuggestions) {
+        result.add(toFixSuggestionInfo(fixSuggestion));
+      }
+      return result;
     }
 
     private FixSuggestionInfo toFixSuggestionInfo(FixSuggestion fixSuggestion) {
       FixSuggestionInfo fixSuggestionInfo = new FixSuggestionInfo();
       fixSuggestionInfo.fixId = fixSuggestion.fixId;
       fixSuggestionInfo.description = fixSuggestion.description;
-      fixSuggestionInfo.replacements =
-          fixSuggestion.replacements.stream().map(this::toFixReplacementInfo).collect(toList());
+      if (fixSuggestion.replacements != null) {
+        List<FixReplacementInfo> replacements = new ArrayList<>(fixSuggestion.replacements.size());
+        for (FixReplacement fixReplacement : fixSuggestion.replacements) {
+          replacements.add(toFixReplacementInfo(fixReplacement));
+        }
+        fixSuggestionInfo.replacements = replacements;
+      }
       return fixSuggestionInfo;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index fa4296b..9205a46 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -559,11 +559,18 @@
           ins.setCustomKeyedValues(customKeyedValues.build());
         }
 
+        // Default to NotifyHandling.OWNER for WIP changes to avoid emailing project watchers
+        // before the change is marked ready for review (matching ReceiveCommits and CommitUtil).
+        // Private changes do not change default notify handling; they are filtered by visibility
+        // ACLs.
+        boolean isWorkInProgress =
+            Boolean.TRUE.equals(input.workInProgress) || !c.getFilesWithGitConflicts().isEmpty();
+        NotifyHandling defaultNotify = isWorkInProgress ? NotifyHandling.OWNER : NotifyHandling.ALL;
         try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
           bu.setRepository(git, rw, oi);
           bu.setNotify(
               notifyResolver.resolve(
-                  firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
+                  firstNonNull(input.notify, defaultNotify), input.notifyDetails));
           bu.insertChange(ins);
           bu.execute();
         }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 998a09e..0b14916 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -66,7 +66,6 @@
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final ChangeNotes.Factory changeNotesFactory;
   private final PluginSetContext<CommentValidator> commentValidators;
 
   @Inject
@@ -75,13 +74,11 @@
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      ChangeNotes.Factory changeNotesFactory,
       PluginSetContext<CommentValidator> commentValidators) {
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.changeNotesFactory = changeNotesFactory;
     this.commentValidators = commentValidators;
   }
 
@@ -99,12 +96,8 @@
       throw new BadRequestException("line must be >= 0");
     } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
       throw new BadRequestException("range endLine must be on the same line as the comment");
-    } else if (in.inReplyTo != null
-        && !commentsUtil.getPublishedHumanComment(rsrc.getNotes(), in.inReplyTo).isPresent()) {
-      throw new BadRequestException(
-          String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
-    validateDraftComment(rsrc, in, changeNotesFactory, commentValidators, commentsUtil);
+    validateDraftComment(rsrc, in, commentValidators, commentsUtil);
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (BatchUpdate bu =
           updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
@@ -120,13 +113,14 @@
   static void validateDraftComment(
       RevisionResource rsrc,
       DraftInput in,
-      ChangeNotes.Factory changeNotesFactory,
       PluginSetContext<CommentValidator> commentValidators,
       CommentsUtil commentsUtil)
       throws BadRequestException {
+    commentsUtil.ensureValidInReplyTo(rsrc.getNotes(), rsrc.getPatchSet().id(), in.inReplyTo);
+
     HumanComment comment =
         createDraftComment(
-            changeNotesFactory.create(rsrc.getProject(), rsrc.getChange().getId()),
+            rsrc.getNotes(),
             rsrc.getUser(),
             TimeUtil.now(),
             in,
@@ -188,6 +182,7 @@
             CommentsUtil.createFixSuggestionsFromInput(draftInput.fixSuggestions));
     comment.setLineNbrAndRange(draftInput.line, draftInput.range);
     comment.tag = draftInput.tag;
+    comment.isAi = draftInput.isAi;
 
     commentsUtil.setCommentCommitId(comment, change, ps);
     return comment;
diff --git a/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java b/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java
index 3a26be2..c3c398f 100644
--- a/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java
+++ b/java/com/google/gerrit/server/restapi/change/EvaluateChangeQueryExpression.java
@@ -84,10 +84,14 @@
       // index, including submit requirement results.
       List<ChangeData> changeDatas =
           internalChangeQuery.get().byProjectChangeNumber(rsrc.getProject(), rsrc.getId());
+      if (changeDatas.isEmpty()) {
+        logger.atFine().log("Change %s not found in index; falling back to NoteDb", rsrc.getId());
+        return rsrc.getChangeData();
+      }
       checkState(
           changeDatas.size() == 1,
           "Got %s matches for change %s, expected 1",
-          changeDatas.size() == 1,
+          changeDatas.size(),
           rsrc.getId());
       return Iterables.getOnlyElement(changeDatas);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
index 7b7ee13..88ec9c9 100644
--- a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
+++ b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
@@ -44,14 +44,14 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
 import java.util.Optional;
-import javax.inject.Inject;
-import javax.inject.Provider;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 8af3739..0bcd356 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -467,6 +467,7 @@
         commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
         comment.setLineNbrAndRange(inputComment.line, inputComment.range);
         comment.tag = in.tag;
+        comment.isAi = inputComment.isAi;
 
         if (existingComments.contains(CommentSetEntry.create(comment))) {
           continue;
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 07db841..cb3adb3 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -58,7 +57,6 @@
   private final DraftCommentsReader draftCommentsReader;
   private final PatchSetUtil psUtil;
   private final Provider<CommentJson> commentJson;
-  private final ChangeNotes.Factory changeNotesFactory;
   private final PluginSetContext<CommentValidator> commentValidators;
 
   @Inject
@@ -69,7 +67,6 @@
       DraftCommentsReader draftCommentsReader,
       PatchSetUtil psUtil,
       Provider<CommentJson> commentJson,
-      ChangeNotes.Factory changeNotesFactory,
       PluginSetContext<CommentValidator> commentValidators) {
     this.updateFactory = updateFactory;
     this.delete = delete;
@@ -77,7 +74,6 @@
     this.draftCommentsReader = draftCommentsReader;
     this.psUtil = psUtil;
     this.commentJson = commentJson;
-    this.changeNotesFactory = changeNotesFactory;
     this.commentValidators = commentValidators;
   }
 
@@ -95,13 +91,9 @@
       throw new BadRequestException("patchset-level comments can't have side, range, or line");
     } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
       throw new BadRequestException("range endLine must be on the same line as the comment");
-    } else if (in.inReplyTo != null
-        && !commentsUtil.getPublishedHumanComment(rsrc.getNotes(), in.inReplyTo).isPresent()) {
-      throw new BadRequestException(
-          String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
     CreateDraftComment.validateDraftComment(
-        rsrc.getRevisionResource(), in, changeNotesFactory, commentValidators, commentsUtil);
+        rsrc.getRevisionResource(), in, commentValidators, commentsUtil);
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (BatchUpdate bu =
           updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
@@ -180,6 +172,9 @@
     if (in.unresolved != null) {
       e.unresolved = in.unresolved;
     }
+    if (in.isAi != null) {
+      e.isAi = in.isAi;
+    }
     if (in.fixSuggestions != null) {
       e.fixSuggestions = CommentsUtil.createFixSuggestionsFromInput(in.fixSuggestions);
     } else {
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 3c2ef66..243c765 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -490,21 +491,23 @@
                 and(
                     change.isMerged()
                         && change.getSubmissionId() != null
-                        && isChangePartOfSubmission(change.getSubmissionId())
                         && projectStatePermitsWrite,
                     permissionBackend
                         .user(rsrc.getUser())
                         .ref(change.getDest())
                         .testCond(CREATE_CHANGE)),
-                permissionBackend.user(rsrc.getUser()).change(rsrc.getNotes()).testCond(REVERT)));
+                and(
+                    permissionBackend.user(rsrc.getUser()).change(rsrc.getNotes()).testCond(REVERT),
+                    BooleanCondition.lazy(
+                        () -> isChangePartOfSubmission(change.getSubmissionId())))));
   }
 
   /**
    * @param submissionId the submission id of the change.
    * @return True if the submission has more than one change, false otherwise.
    */
-  private Boolean isChangePartOfSubmission(String submissionId) {
-    return (queryProvider.get().setLimit(2).bySubmissionId(submissionId).size() > 1);
+  private boolean isChangePartOfSubmission(String submissionId) {
+    return queryProvider.get().setLimit(2).noFields().bySubmissionId(submissionId).size() > 1;
   }
 
   private class CreateCherryPickOp implements BatchUpdateOp {
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 44abc4f..6056d01 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.MoreObjects;
@@ -29,7 +30,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -51,13 +51,16 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtilFactory;
 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.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.submit.ChangeSet;
@@ -73,6 +76,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -80,6 +84,7 @@
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 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.RevObject;
@@ -122,9 +127,11 @@
   private final ParameterizedString submitTopicTooltip;
   private final boolean submitWholeTopic;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final PatchSetUtil psUtil;
   private final ChangeJson.Factory json;
   private final ChangeData.Factory changeDataFactory;
+  private final ProjectCache projectCache;
+  private final MergeUtilFactory mergeUtilFactory;
+  private final MergeabilityCache mergeabilityCache;
 
   private final boolean useMergeabilityCheck;
 
@@ -137,9 +144,11 @@
       AccountResolver accountResolver,
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
-      PatchSetUtil psUtil,
       ChangeJson.Factory json,
-      ChangeData.Factory changeDataFactory) {
+      ChangeData.Factory changeDataFactory,
+      ProjectCache projectCache,
+      MergeUtilFactory mergeUtilFactory,
+      MergeabilityCache mergeabilityCache) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.mergeOpProvider = mergeOpProvider;
@@ -171,9 +180,11 @@
             MoreObjects.firstNonNull(
                 cfg.getString("change", null, "submitTopicTooltip"), DEFAULT_TOPIC_TOOLTIP));
     this.queryProvider = queryProvider;
-    this.psUtil = psUtil;
     this.json = json;
     this.changeDataFactory = changeDataFactory;
+    this.projectCache = projectCache;
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.mergeabilityCache = mergeabilityCache;
     this.useMergeabilityCheck = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
   }
 
@@ -365,62 +376,141 @@
 
   @Nullable
   public Collection<ChangeData> getUnmergeableChanges(ChangeSet cs) throws IOException {
-    Set<ChangeData> unmergeableChanges = new HashSet<>();
-    Set<ObjectId> outDatedPatchSets = new HashSet<>();
-    for (ChangeData change : cs.changes()) {
-      unmergeableChanges.add(change);
-      addAllOutdatedPatchSets(outDatedPatchSets, change);
+    Set<ChangeData> unmergeableChanges = new HashSet<>(cs.changes());
+    Map<ObjectId, ChangeData> currentCommitsToChange = new HashMap<>();
+    for (ChangeData cd : cs.changes()) {
+      PatchSet ps = cd.currentPatchSet();
+      if (ps != null) {
+        currentCommitsToChange.put(ps.commitId(), cd);
+      }
     }
+
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
     for (BranchNameKey branch : cbb.keySet()) {
       List<ChangeData> targetBranch = cbb.get(branch);
-      HashMap<Change.Id, RevCommit> commits = mapToCommits(targetBranch, branch.project());
-      Set<ObjectId> allParents =
-          commits.values().stream()
-              .flatMap(c -> Arrays.stream(c.getParents()))
-              .map(RevObject::getId)
-              .collect(Collectors.toSet());
-      for (ChangeData change : targetBranch) {
-        RevCommit commit = commits.get(change.getId());
-        boolean isMergeCommit = commit.getParentCount() > 1;
-        boolean isLastInChain = !allParents.contains(commit.getId());
-        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchSets.contains(c.getId()))
-            && !isCherryPickSubmit(change)) {
-          // Found a parent that depends on an outdated patchset and the submit strategy is not
-          // cherry-pick.
-          continue;
+      try (Repository repo = repoManager.openRepository(branch.project());
+          RevWalk walk = new RevWalk(repo)) {
+        Ref destRef = repo.getRefDatabase().exactRef(branch.branch());
+        String mergeStrategy =
+            mergeUtilFactory
+                .create(
+                    projectCache.get(branch.project()).orElseThrow(illegalState(branch.project())))
+                .mergeStrategyName();
+
+        Map<Change.Id, RevCommit> commits = new HashMap<>();
+        for (ChangeData change : targetBranch) {
+          PatchSet ps = change.currentPatchSet();
+          if (ps == null) {
+            return null;
+          }
+          RevCommit commit = walk.parseCommit(ps.commitId());
+          commits.put(change.getId(), commit);
         }
-        // Recheck mergeability rather than using value stored in the index,
-        // which may be stale.
-        // TODO(dborowitz): This is ugly; consider providing a way to not read
-        // stored fields from the index in the first place.
-        change.setMergeable(null);
-        Boolean mergeable = change.isMergeable();
-        if (mergeable == null) {
-          // Skip whole check, cannot determine if mergeable
-          return null;
-        }
-        if (mergeable) {
-          unmergeableChanges.remove(change);
-        }
-        if (isLastInChain && isMergeCommit && mergeable) {
-          targetBranch.stream().forEach(unmergeableChanges::remove);
-          break;
+
+        Set<ObjectId> allParents =
+            commits.values().stream()
+                .flatMap(c -> Arrays.stream(c.getParents()))
+                .map(RevObject::getId)
+                .collect(Collectors.toSet());
+
+        for (ChangeData change : targetBranch) {
+          RevCommit commit = commits.get(change.getId());
+          boolean isMergeCommit = commit.getParentCount() > 1;
+          boolean isLastInChain = !allParents.contains(commit.getId());
+
+          if (dependsOnOutdatedPatchSet(commit, currentCommitsToChange, cs, destRef)
+              && !isCherryPickSubmit(change)) {
+            // Found a parent that depends on an outdated patchset and the submit strategy is not
+            // cherry-pick.
+            continue;
+          }
+
+          Boolean mergeable = isChangeMergeable(change, destRef, mergeStrategy, branch, repo);
+          if (mergeable == null) {
+            // Skip whole check, cannot determine if mergeable
+            return null;
+          }
+          if (mergeable) {
+            unmergeableChanges.remove(change);
+          }
+          if (isLastInChain && isMergeCommit && mergeable) {
+            targetBranch.stream().forEach(unmergeableChanges::remove);
+            break;
+          }
         }
       }
     }
     return unmergeableChanges;
   }
 
+  @Nullable
+  private Boolean isChangeMergeable(
+      ChangeData change,
+      @Nullable Ref destRef,
+      String mergeStrategy,
+      BranchNameKey branch,
+      Repository repo) {
+    Change c = change.change();
+    if (c == null) {
+      return null;
+    }
+    if (c.isMerged()) {
+      change.setMergeable(true);
+      return true;
+    } else if (c.isAbandoned()) {
+      return null;
+    }
+    if (!change.lazyload()) {
+      return null;
+    }
+    PatchSet ps = change.currentPatchSet();
+    if (ps == null) {
+      return null;
+    }
+
+    SubmitTypeRecord str = change.submitTypeRecord();
+    if (!str.isOk()) {
+      change.setMergeable(false);
+      return false;
+    }
+
+    boolean mergeable =
+        mergeabilityCache.get(ps.commitId(), destRef, str.type, mergeStrategy, branch, repo);
+    change.setMergeable(mergeable);
+    return mergeable;
+  }
+
   /**
-   * Add all outdated patch-sets (non-last patch-sets) to the output set {@code outdatedPatchSets}.
+   * Fast bounded check: determines if any parent of {@code commit} points to an outdated patchset
+   * of another change in the same changeset, without loading all historical patchsets upfront.
    */
-  private static void addAllOutdatedPatchSets(Set<ObjectId> outdatedPatchSets, ChangeData cd) {
-    outdatedPatchSets.addAll(
-        cd.notes().getPatchSets().values().stream()
-            .map(p -> p.commitId())
-            .collect(Collectors.toSet()));
-    outdatedPatchSets.remove(cd.currentPatchSet().commitId());
+  private static boolean dependsOnOutdatedPatchSet(
+      RevCommit commit,
+      Map<ObjectId, ChangeData> currentCommitsToChange,
+      ChangeSet cs,
+      @Nullable Ref destRef) {
+    for (RevCommit parent : commit.getParents()) {
+      ObjectId parentId = parent.getId();
+      // If the parent is the current patchset of a change in the changeset, or points directly to
+      // the destination branch tip, it is up to date.
+      if (currentCommitsToChange.containsKey(parentId)
+          || (destRef != null && parentId.equals(destRef.getObjectId()))) {
+        continue;
+      }
+      // Check if parentId is known to belong to any change in the changeset as a non-current
+      // patchset.
+      for (ChangeData cd : cs.changes()) {
+        PatchSet currentPs = cd.currentPatchSet();
+        if (currentPs != null && !currentPs.commitId().equals(parentId)) {
+          if (currentPs.number() > 1) {
+            if (cd.patchSets().stream().anyMatch(ps -> ps.commitId().equals(parentId))) {
+              return true;
+            }
+          }
+        }
+      }
+    }
+    return false;
   }
 
   private boolean isCherryPickSubmit(ChangeData changeData) {
@@ -428,20 +518,6 @@
     return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
   }
 
-  /** Map input {@code changes} to the commit SHA-1 of their latest patch-set. */
-  private HashMap<Change.Id, RevCommit> mapToCommits(
-      Collection<ChangeData> changes, Project.NameKey project) throws IOException {
-    HashMap<Change.Id, RevCommit> commits = new HashMap<>();
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk walk = new RevWalk(repo)) {
-      for (ChangeData change : changes) {
-        RevCommit commit = walk.parseCommit(psUtil.current(change.notes()).commitId());
-        commits.put(change.getId(), commit);
-      }
-    }
-    return commits;
-  }
-
   private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
       throws AuthException,
           UnprocessableEntityException,
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 7e9bdb3..32b940c 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -44,9 +45,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import org.kohsuke.args4j.Option;
@@ -131,7 +132,7 @@
       throws AuthException, IOException, PermissionBackendException {
     Change c = resource.getChange();
     try {
-      List<ChangeData> cds;
+      ImmutableList<ChangeData> cds;
       int hidden;
 
       if (c.isNew()) {
@@ -139,8 +140,15 @@
             mergeSuperSet
                 .get()
                 .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
-        cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
+
+        if (cs.size() <= 1 && hidden == 0) {
+          SubmittedTogetherInfo info = new SubmittedTogetherInfo();
+          info.changes = ImmutableList.of();
+          info.nonVisibleChanges = 0;
+          return info;
+        }
+        cds = cs.changes().asList();
       } else if (c.isMerged()) {
         List<ChangeData> submittedChanges = queryProvider.get().bySubmissionId(c.getSubmissionId());
         ChangeIsVisibleToPredicate visibleToUser =
@@ -155,9 +163,17 @@
           }
         }
         cds = visibleSubmittedChanges.build();
+        if (cds.size() <= 1 && hidden == 0) {
+          SubmittedTogetherInfo info = new SubmittedTogetherInfo();
+          info.changes = ImmutableList.of();
+          info.nonVisibleChanges = 0;
+          return info;
+        }
       } else {
-        cds = Collections.emptyList();
-        hidden = 0;
+        SubmittedTogetherInfo info = new SubmittedTogetherInfo();
+        info.changes = ImmutableList.of();
+        info.nonVisibleChanges = 0;
+        return info;
       }
 
       if (hidden != 0 && !options.contains(NON_VISIBLE_CHANGES)) {
@@ -181,11 +197,19 @@
       // repo just to fill out the commit field in PatchSetData.
       return ImmutableList.of();
     }
+    if (cds.size() <= 1) {
+      return ImmutableList.copyOf(cds);
+    }
 
-    long numProjectsDistinct = cds.stream().map(ChangeData::project).distinct().count();
-    long numProjects = cds.stream().map(ChangeData::project).count();
+    Set<Project.NameKey> projects = new HashSet<>();
+    boolean hasDuplicateProject = false;
+    for (ChangeData cd : cds) {
+      if (!projects.add(cd.project())) {
+        hasDuplicateProject = true;
+      }
+    }
 
-    if (numProjects == numProjectsDistinct || numProjectsDistinct > 5) {
+    if (!hasDuplicateProject || projects.size() > 5) {
       // We either have only a single change per project which means that WalkSorter won't make a
       // difference compared to our index-backed sort, or we are looking at more than 5 projects
       // which would make WalkSorter too expensive for this call.
@@ -199,20 +223,4 @@
     }
     return sorted.build();
   }
-
-  private static List<ChangeData> ensureRequiredDataIsLoaded(List<ChangeData> cds) {
-    // TODO(hiesel): Instead of calling these manually, either implement a helper that brings a
-    // database-backed change on-par with an index-backed change in terms of the populated fields in
-    // ChangeData or check if any of the ChangeDatas was loaded from the database and allow
-    // lazyloading if so.
-    for (ChangeData cd : cds) {
-      @SuppressWarnings("unused")
-      var unused = cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
-      unused = cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_STRICT);
-
-      @SuppressWarnings("unused")
-      var unused2 = cd.currentPatchSet();
-    }
-    return cds;
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetCache.java b/java/com/google/gerrit/server/restapi/config/GetCache.java
index 23615fa..0f06128 100644
--- a/java/com/google/gerrit/server/restapi/config/GetCache.java
+++ b/java/com/google/gerrit/server/restapi/config/GetCache.java
@@ -26,6 +26,6 @@
 
   @Override
   public Response<CacheInfo> apply(CacheResource rsrc) {
-    return Response.ok(CacheInfoFactory.create(rsrc.getName(), rsrc.getCache()));
+    return Response.ok(CacheInfoFactory.create(rsrc.getName(), rsrc.getCache(), true));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 9129bfb..f8c066d 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -302,6 +302,7 @@
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
     info.instanceId = instanceId;
     info.defaultBranch = config.getString("gerrit", null, "defaultBranch");
+    info.submitCommitUrl = config.getString("gerrit", null, "submitCommitUrl");
     info.projectStatePredicateEnabled =
         config.getBoolean("gerrit", null, "projectStatePredicateEnabled", true);
     return info;
diff --git a/java/com/google/gerrit/server/restapi/config/IndexChanges.java b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
index caca5bc..c011b56 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
@@ -14,73 +14,130 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.common.base.Preconditions;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.config.IndexChanges.Input;
 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.List;
-import java.util.Optional;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class IndexChanges implements RestModifyView<ConfigResource, Input> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final Pattern PROJECT_WITH_CHANGE_NUM_REGEX = Pattern.compile("^([^~]+)~(\\d+)$");
 
-  public static class Input {
-    public Set<String> changes;
-    boolean deleteMissing;
+  public record Input(Set<String> changes, boolean deleteMissing) {
+
+    public Input() {
+      this(Collections.emptySet(), false);
+    }
   }
 
   private final ChangeFinder changeFinder;
   private final ChangeData.Factory changeDataFactory;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeNotes.Factory notesFactory;
   private final ChangeIndexer indexer;
 
   @Inject
   IndexChanges(
-      ChangeFinder changeFinder, ChangeData.Factory changeDataFactory, ChangeIndexer indexer) {
+      ChangeFinder changeFinder,
+      ChangeData.Factory changeDataFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeNotes.Factory notesFactory,
+      ChangeIndexer indexer) {
     this.changeFinder = changeFinder;
     this.changeDataFactory = changeDataFactory;
+    this.queryProvider = queryProvider;
+    this.notesFactory = notesFactory;
     this.indexer = indexer;
   }
 
   @Override
-  public Response<String> apply(ConfigResource resource, Input input) {
+  public Response<String> apply(ConfigResource resource, Input input) throws Exception {
     if (input == null || input.changes == null) {
       return Response.ok("Nothing to index");
     }
 
-    for (String id : input.changes) {
-      List<ChangeNotes> notes = changeFinder.find(id);
+    if (input.deleteMissing) {
+      List<ProjectWithChangeNumTuple> changeIds = new ArrayList<>();
+      for (String id : input.changes) {
+        changeIds.add(parseIntoProjectWithChangeNumTuple(id));
+      }
 
-      if (notes.isEmpty()) {
-        logger.atWarning().log("Change %s missing in NoteDb", id);
-        if (input.deleteMissing) {
-          Optional<Change.Id> changeId = Change.Id.tryParse(id);
-          if (changeId.isPresent()) {
-            logger.atWarning().log("Deleting change %s from index", changeId.get());
-            indexer.delete(changeId.get());
+      for (ProjectWithChangeNumTuple changeInfo : changeIds) {
+        List<ChangeData> changes =
+            queryProvider.get().byProjectChangeNumber(changeInfo.project(), changeInfo.changeId());
+        Preconditions.checkState(
+            changes.size() <= 1,
+            "Ambiguous change ID %s in project %s",
+            changeInfo.changeId(),
+            changeInfo.project());
+
+        if (!changes.isEmpty()) {
+          try {
+            // Probe NoteDb: NoSuchChangeException means the change is in the index
+            // but absent from disk, so it should be deleted.
+            ChangeNotes unused = notesFactory.create(changeInfo.project(), changeInfo.changeId());
+          } catch (NoSuchChangeException e) {
+            logger.atWarning().log(
+                "Change %s~%s missing in NoteDb", changeInfo.project(), changeInfo.changeId());
+            ChangeData cd = changes.getFirst();
+            logger.atWarning().log(
+                "Deleting change %s~%s from index", cd.project(), cd.change().getChangeId());
+            indexer.delete(cd.project(), cd.virtualId());
+            continue;
           }
         }
-        continue;
-      }
 
-      for (ChangeNotes n : notes) {
-        indexer.index(changeDataFactory.create(n));
-        logger.atFine().log("Indexed change %s", id);
+        indexer.index(changeInfo.project, changeInfo.changeId);
+        logger.atFine().log("Indexed change %s:%s", changeInfo.project, changeInfo.changeId);
       }
+    } else {
+      input.changes.stream()
+          .flatMap(cid -> changeFinder.find(cid).stream())
+          .map(changeDataFactory::create)
+          .forEach(
+              cd -> {
+                indexer.index(cd);
+                logger.atFine().log("Indexed change %s:%s", cd.project(), cd.getId());
+              });
     }
 
     return Response.ok("Indexed changes " + input.changes);
   }
+
+  record ProjectWithChangeNumTuple(Project.NameKey project, Change.Id changeId) {}
+
+  ProjectWithChangeNumTuple parseIntoProjectWithChangeNumTuple(String id)
+      throws BadRequestException {
+    Matcher projectWithChangeNumMatcher = PROJECT_WITH_CHANGE_NUM_REGEX.matcher(id);
+    if (projectWithChangeNumMatcher.matches()) {
+      return new ProjectWithChangeNumTuple(
+          Project.nameKey(projectWithChangeNumMatcher.group(1)),
+          Change.id(Integer.parseInt(projectWithChangeNumMatcher.group(2))));
+    }
+    throw new BadRequestException("Change ID must be in project~changeNumber format: " + id);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
index 6dc17ce..6411615 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -35,6 +35,7 @@
 import com.google.inject.Inject;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
 
@@ -50,22 +51,34 @@
   @Option(name = "--format", usage = "output format")
   private OutputFormat format;
 
+  @Option(
+      name = "--include-diskstats",
+      usage = "if set, disk stat collection is included for persistent caches")
+  private boolean includeDiskStats;
+
   public ListCaches setFormat(OutputFormat format) {
     this.format = format;
     return this;
   }
 
+  public ListCaches setIncludeDiskStats(boolean includeDiskStats) {
+    this.includeDiskStats = includeDiskStats;
+    return this;
+  }
+
   @Inject
   public ListCaches(DynamicMap<Cache<?, ?>> cacheMap) {
     this.cacheMap = cacheMap;
   }
 
-  public Map<String, CacheInfo> getCacheInfos() {
+  public Map<String, CacheInfo> getCacheInfos(
+      Predicate<String> nameFilter, boolean includeDiskStats) {
     Map<String, CacheInfo> cacheInfos = new TreeMap<>();
     for (Extension<Cache<?, ?>> e : cacheMap) {
-      cacheInfos.put(
-          cacheNameOf(e.getPluginName(), e.getExportName()),
-          CacheInfoFactory.create(e.getProvider().get()));
+      String name = cacheNameOf(e.getPluginName(), e.getExportName());
+      if (nameFilter.test(name)) {
+        cacheInfos.put(name, CacheInfoFactory.create(e.getProvider().get(), includeDiskStats));
+      }
     }
     return cacheInfos;
   }
@@ -73,7 +86,7 @@
   @Override
   public Response<Object> apply(ConfigResource rsrc) {
     if (format == null) {
-      return Response.ok(getCacheInfos());
+      return Response.ok(getCacheInfos(name -> true, includeDiskStats));
     }
     Stream<String> cacheNames =
         Streams.stream(cacheMap)
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index 8ada657..73c762e 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -17,6 +17,8 @@
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Ascii;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -35,22 +37,40 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.EnumOptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
 
-@Singleton
 public class ListTasks implements RestReadView<ConfigResource> {
   private final PermissionBackend permissionBackend;
   private final WorkQueue workQueue;
   private final Provider<CurrentUser> self;
   private final ProjectCache projectCache;
 
+  private final EnumSet<Task.State> states = EnumSet.noneOf(Task.State.class);
+
+  @Option(
+      name = "--state",
+      aliases = {"-s"},
+      handler = StateHandler.class,
+      usage = "only show tasks in the specified state")
+  void addState(Task.State state) {
+    states.add(state);
+  }
+
   @Inject
   public ListTasks(
       PermissionBackend permissionBackend,
@@ -104,7 +124,8 @@
   }
 
   private List<TaskInfo> getTasks() {
-    return workQueue.getTaskInfos(TaskInfo::new).stream()
+    Predicate<Task<?>> filter = states.isEmpty() ? t -> true : t -> states.contains(t.getState());
+    return workQueue.getTaskInfos(filter, TaskInfo::new).stream()
         .sorted(
             comparing((TaskInfo t) -> t.state.ordinal())
                 .thenComparing(t -> t.delay)
@@ -140,4 +161,30 @@
       }
     }
   }
+
+  public static class StateHandler extends EnumOptionHandler<Task.State> {
+    private static final ImmutableMap<String, Task.State> ALIASES =
+        ImmutableMap.of(
+            "WAITING", Task.State.READY,
+            "KILLED", Task.State.CANCELLED);
+
+    public StateHandler(CmdLineParser parser, OptionDef option, Setter<? super Task.State> setter) {
+      super(parser, option, setter, Task.State.class);
+    }
+
+    @Override
+    public int parseArguments(Parameters params) throws CmdLineException {
+      String param = params.getParameter(0);
+      Task.State alias = ALIASES.get(Ascii.toUpperCase(param));
+      if (alias != null) {
+        setter.addValue(alias);
+        return 1;
+      }
+      try {
+        return super.parseArguments(params);
+      } catch (CmdLineException e) {
+        throw new CmdLineException(owner, String.format("%s is not a valid task state", param), e);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
index 2694cce..0e220d1 100644
--- a/java/com/google/gerrit/server/restapi/config/TasksCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -42,7 +42,7 @@
 @Singleton
 public class TasksCollection implements ChildCollection<ConfigResource, TaskResource> {
   private final DynamicMap<RestView<TaskResource>> views;
-  private final ListTasks list;
+  private final Provider<ListTasks> list;
   private final WorkQueue workQueue;
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
@@ -51,7 +51,7 @@
   @Inject
   TasksCollection(
       DynamicMap<RestView<TaskResource>> views,
-      ListTasks list,
+      Provider<ListTasks> list,
       WorkQueue workQueue,
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
@@ -66,7 +66,7 @@
 
   @Override
   public RestView<ConfigResource> list() {
-    return list;
+    return list.get();
   }
 
   @Override
@@ -90,14 +90,16 @@
     Task<?> task = workQueue.getTask(taskId);
     if (task instanceof ProjectTask) {
       Project.NameKey nameKey = ((ProjectTask<?>) task).getProjectNameKey();
-      Optional<ProjectState> state = projectCache.get(nameKey);
-      if (!state.isPresent()) {
-        throw new ResourceNotFoundException(String.format("project %s not found", nameKey));
-      }
+      if (nameKey != null) {
+        Optional<ProjectState> state = projectCache.get(nameKey);
+        if (!state.isPresent()) {
+          throw new ResourceNotFoundException(String.format("project %s not found", nameKey));
+        }
 
-      state.get().checkStatePermitsRead();
-      if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
-        return new TaskResource(task);
+        state.get().checkStatePermitsRead();
+        if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
+          return new TaskResource(task);
+        }
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/project/BranchCommitBuilder.java b/java/com/google/gerrit/server/restapi/project/BranchCommitBuilder.java
new file mode 100644
index 0000000..082d448
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/BranchCommitBuilder.java
@@ -0,0 +1,361 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput.FileChange;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
+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.BranchResource;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.FileMode;
+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.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.ReceiveCommand;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Applies a {@link CreateCommitInput} (a set of file writes/deletes/renames) directly to a branch
+ * as a single commit. Backs the {@link CreateCommit} REST view.
+ *
+ * <p>The reusable {@link CreateCommitInput}-to-{@link TreeModification} conversion lives in {@link
+ * CommitFileModifications}; this class owns the branch ref update, reusing {@link
+ * ChangeEditModifier#createNewTree} for the tree and {@link CommitUtil} for the commit.
+ */
+@Singleton
+class BranchCommitBuilder {
+  private final GitRepositoryManager repoManager;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final Provider<PersonIdent> serverIdent;
+  private final PermissionBackend permissionBackend;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RefValidationHelper refUpdateValidator;
+  private final CommitValidators.Factory commitValidatorsFactory;
+  private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
+
+  @Inject
+  BranchCommitBuilder(
+      GitRepositoryManager repoManager,
+      Provider<IdentifiedUser> identifiedUser,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      PermissionBackend permissionBackend,
+      GitReferenceUpdated referenceUpdated,
+      RefValidationHelper.Factory refValidationHelperFactory,
+      CommitValidators.Factory commitValidatorsFactory,
+      DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory) {
+    this.repoManager = repoManager;
+    this.identifiedUser = identifiedUser;
+    this.serverIdent = serverIdent;
+    this.permissionBackend = permissionBackend;
+    this.referenceUpdated = referenceUpdated;
+    this.refUpdateValidator = refValidationHelperFactory.create(ReceiveCommand.Type.UPDATE);
+    this.commitValidatorsFactory = commitValidatorsFactory;
+    this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
+  }
+
+  /** Commits the file operations directly to the branch. Requires {@link RefPermission#UPDATE}. */
+  CommitInfo createCommit(BranchResource rsrc, CreateCommitInput input)
+      throws RestApiException, PermissionBackendException, IOException {
+    requireInput(input);
+    BranchNameKey branch = rsrc.getBranchKey();
+    checkWritableBranch(rsrc, branch);
+    permissionBackend
+        .currentUser()
+        .project(branch.project())
+        .ref(branch.branch())
+        .check(RefPermission.UPDATE);
+
+    String message = commitMessage(input);
+    ImmutableList<TreeModification> modifications = CommitFileModifications.fromInput(input);
+    ImmutableListMultimap<String, String> validationOptions =
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions);
+
+    try (Repository repo = repoManager.openRepository(branch.project());
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      Ref ref = requireBranchRef(repo, branch);
+      ObjectId expectedOld = resolveExpectedOldObjectId(input, ref, branch);
+      RevCommit base = rw.parseCommit(ref.getObjectId());
+      requireSourcePathsExist(reader, base, input);
+      ObjectId treeId = buildTree(repo, base, modifications);
+      ObjectId newCommitId = insertCommit(oi, base, treeId, message);
+      validateCommit(rsrc, repo, rw, oi, ref.getObjectId(), newCommitId, validationOptions);
+
+      try (RefUpdateContext refCtx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+        RefUpdate u = repo.updateRef(branch.branch());
+        u.setExpectedOldObjectId(expectedOld);
+        u.setNewObjectId(newCommitId);
+        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
+        u.setRefLogMessage("commit files via REST", false);
+        refUpdateValidator.validateRefOperation(
+            branch.project().get(), identifiedUser.get(), u, validationOptions);
+        RefUpdate.Result result = u.update(rw);
+        switch (result) {
+          case FAST_FORWARD:
+          case NEW:
+          case NO_CHANGE:
+            referenceUpdated.fire(
+                branch.project(), u, ReceiveCommand.Type.UPDATE, identifiedUser.get().state());
+            break;
+          case LOCK_FAILURE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+            throw new ResourceConflictException(
+                "branch \""
+                    + branch.branch()
+                    + "\" changed concurrently or base_revision is stale");
+          case FORCED:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case RENAMED:
+          default:
+            throw new IOException("Failed to update " + branch.branch() + ": " + result.name());
+        }
+        return CommitUtil.toCommitInfo(rw.parseCommit(newCommitId), rw);
+      }
+    }
+  }
+
+  /**
+   * Inserts a commit with {@code treeId} on top of {@code base}. Per Gerrit convention for
+   * server-created commits, both the author and the committer are the calling user (full name and
+   * preferred email, or a generic {@code username@host} identity if no preferred email is set),
+   * consistent with a change edit publish. Author and committer share the server's timestamp and
+   * time zone, i.e. the server time at which the commit is created; the identities cannot be
+   * overridden via {@link CreateCommitInput}.
+   */
+  private ObjectId insertCommit(ObjectInserter oi, RevCommit base, ObjectId treeId, String message)
+      throws IOException {
+    PersonIdent committer = identifiedUser.get().newCommitterIdent(serverIdent.get());
+    ObjectId commitId =
+        CommitUtil.createCommitWithTree(
+            oi, committer, committer, ImmutableList.of(base), message, treeId);
+    oi.flush();
+    return commitId;
+  }
+
+  /**
+   * Runs Gerrit's commit validators on the new commit, the same validation applied to
+   * server-created commits (e.g. the create-a-change path). This ensures the direct-commit endpoint
+   * does not bypass commit-content policy (file-count limits, config validation, plugin
+   * commit-validation listeners, etc.). Change-Id enforcement does not apply here because the
+   * target is a branch ref, not a magic/change ref.
+   */
+  private void validateCommit(
+      BranchResource rsrc,
+      Repository repo,
+      RevWalk rw,
+      ObjectInserter oi,
+      ObjectId oldId,
+      ObjectId newCommitId,
+      ImmutableListMultimap<String, String> validationOptions)
+      throws ResourceConflictException, IOException {
+    BranchNameKey branch = rsrc.getBranchKey();
+    ReceiveCommand cmd = new ReceiveCommand(oldId, newCommitId, branch.branch());
+    try (RepoView repoView = new RepoView(repo, rw, oi);
+        CommitReceivedEvent event =
+            new CommitReceivedEvent(
+                cmd,
+                rsrc.getProjectState().getProject(),
+                branch.branch(),
+                validationOptions,
+                repo.getConfig(),
+                rw.getObjectReader(),
+                newCommitId,
+                identifiedUser.get(),
+                /* cherryPickOf= */ null,
+                diffOperationsForCommitValidationFactory.create(repoView, oi))) {
+      commitValidatorsFactory
+          .forGerritCommits(
+              permissionBackend.currentUser().project(branch.project()),
+              branch,
+              identifiedUser.get(),
+              rw,
+              /* change= */ null)
+          .validate(event);
+    } catch (CommitValidationException e) {
+      throw new ResourceConflictException(e.getFullMessage());
+    }
+  }
+
+  private static ObjectId buildTree(
+      Repository repo, RevCommit base, List<TreeModification> modifications)
+      throws BadRequestException, IOException {
+    try {
+      return ChangeEditModifier.createNewTree(repo, base, modifications);
+    } catch (InvalidChangeOperationException e) {
+      // Raised when the result tree is identical to the base tree (no effective change).
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
+  private String commitMessage(CreateCommitInput input) throws BadRequestException {
+    String message = input.commitMessage == null ? "" : input.commitMessage.trim();
+    if (message.isEmpty()) {
+      throw new BadRequestException("commit message must be non-empty");
+    }
+    if (!message.endsWith("\n")) {
+      message = message + "\n";
+    }
+    return message;
+  }
+
+  /**
+   * Rejects operations whose source path is absent from the base tree. A delete of a missing path
+   * or a rename from a missing source would otherwise be silently dropped (see {@link
+   * com.google.gerrit.server.edit.tree.DeleteFileModification} / {@link
+   * com.google.gerrit.server.edit.tree.RenameFileModification}) while the surrounding commit still
+   * succeeds. This needs the base tree, so it runs here rather than in {@link
+   * CommitFileModifications}.
+   */
+  private static void requireSourcePathsExist(
+      ObjectReader reader, RevCommit base, CreateCommitInput input)
+      throws BadRequestException, IOException {
+    for (Map.Entry<String, FileChange> entry : input.files.entrySet()) {
+      FileChange change = entry.getValue();
+      if (change.delete) {
+        requireFileExists(reader, base, entry.getKey());
+      } else if (change.renameFrom != null) {
+        requireFileExists(reader, base, change.renameFrom);
+      }
+    }
+  }
+
+  /**
+   * Rejects {@code path} unless it resolves to an existing file (blob), not a missing path or a
+   * directory.
+   */
+  private static void requireFileExists(ObjectReader reader, RevCommit base, String path)
+      throws BadRequestException, IOException {
+    try (TreeWalk tw = TreeWalk.forPath(reader, path, base.getTree())) {
+      if (tw == null) {
+        throw new BadRequestException("path does not exist: " + path);
+      }
+      if (tw.getFileMode(0) == FileMode.TREE) {
+        throw new BadRequestException("path is a directory, not a file: " + path);
+      }
+    }
+  }
+
+  private ObjectId parseBaseRevision(String baseRevision) throws BadRequestException {
+    if (!ObjectId.isId(baseRevision)) {
+      throw new BadRequestException("base_revision must be a full 40-character SHA-1");
+    }
+    return ObjectId.fromString(baseRevision);
+  }
+
+  /** Loads the target branch ref, rejecting with a 409 if it is missing. */
+  private Ref requireBranchRef(Repository repo, BranchNameKey branch)
+      throws ResourceConflictException, IOException {
+    Ref ref = repo.exactRef(branch.branch());
+    if (ref == null || ref.getObjectId() == null) {
+      throw new ResourceConflictException("branch \"" + branch.branch() + "\" does not exist");
+    }
+    return ref;
+  }
+
+  /**
+   * Resolves the expected old object id: the caller-provided {@code base_revision} when set,
+   * otherwise the current branch tip.
+   *
+   * <p>If {@code base_revision} is set, it must match the current branch tip. The final ref update
+   * still performs the same compare-and-swap check to protect against races.
+   */
+  private ObjectId resolveExpectedOldObjectId(
+      CreateCommitInput input, Ref ref, BranchNameKey branch)
+      throws BadRequestException, ResourceConflictException {
+    ObjectId currentTip = ref.getObjectId();
+    if (input.baseRevision == null) {
+      return currentTip;
+    }
+    ObjectId expectedOld = parseBaseRevision(input.baseRevision);
+    if (!expectedOld.equals(currentTip)) {
+      throw new ResourceConflictException(
+          "branch \"" + branch.branch() + "\" changed concurrently or base_revision is stale");
+    }
+    return expectedOld;
+  }
+
+  private static void requireInput(@Nullable CreateCommitInput input) throws BadRequestException {
+    if (input == null) {
+      throw new BadRequestException("input is required");
+    }
+  }
+
+  /**
+   * Rejects branches this endpoint must not write to: {@code HEAD} (a symbolic ref, not an ordinary
+   * branch), read-only projects, Gerrit-internal refs, tags, and the {@code refs/meta/*} namespace
+   * (project config, schema version, dashboards, etc.).
+   */
+  private void checkWritableBranch(BranchResource rsrc, BranchNameKey branch)
+      throws MethodNotAllowedException, ResourceConflictException, AuthException {
+    if (RefNames.HEAD.equals(branch.branch())) {
+      throw new MethodNotAllowedException("not allowed to write to HEAD");
+    }
+    if (!rsrc.getProjectState().statePermitsWrite()) {
+      throw new ResourceConflictException("project state does not permit write");
+    }
+    if (RefNames.isGerritRef(branch.branch())
+        || branch.branch().startsWith(RefNames.REFS_TAGS)
+        || branch.branch().startsWith(RefNames.REFS_META)) {
+      throw new AuthException("not allowed to write to " + branch.branch() + " via this endpoint");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CommitFileModifications.java b/java/com/google/gerrit/server/restapi/project/CommitFileModifications.java
new file mode 100644
index 0000000..7d4af46
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CommitFileModifications.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput.FileChange;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.util.Base64;
+
+/**
+ * Converts the file operations of a {@link CreateCommitInput} into {@link TreeModification}s.
+ *
+ * <p>This is the reusable request-to-tree-modification step shared between the direct branch-commit
+ * path and any future review path: it validates the caller-supplied input and builds the {@link
+ * TreeModification}s that {@link com.google.gerrit.server.edit.ChangeEditModifier#createNewTree}
+ * (or any other {@code TreeCreator} caller) applies. Keeping it separate lets {@link
+ * BranchCommitBuilder} focus on the branch ref update.
+ */
+final class CommitFileModifications {
+  private CommitFileModifications() {}
+
+  /**
+   * Translates the requested file operations into {@link TreeModification}s while validating the
+   * caller-supplied input, so that malformed requests fail with {@code 400 Bad Request} rather than
+   * leaking through as a server error.
+   */
+  static ImmutableList<TreeModification> fromInput(CreateCommitInput input)
+      throws BadRequestException {
+    if (input.files == null || input.files.isEmpty()) {
+      throw new BadRequestException("files is required");
+    }
+    ImmutableList.Builder<TreeModification> modifications =
+        ImmutableList.builderWithExpectedSize(input.files.size());
+    // Two operations that touch the same path (including a rename's source path) cannot be applied
+    // together; detect that here instead of letting TreeCreator throw an IllegalStateException that
+    // would surface as a 500.
+    Set<String> touchedPaths = new HashSet<>();
+    for (Map.Entry<String, FileChange> entry : input.files.entrySet()) {
+      String path = entry.getKey();
+      if (Strings.isNullOrEmpty(path)) {
+        throw new BadRequestException("file path must not be empty");
+      }
+      FileChange change = entry.getValue();
+      if (change == null) {
+        throw new BadRequestException("no operation given for " + path);
+      }
+      int ops =
+          (change.content != null ? 1 : 0)
+              + (change.delete ? 1 : 0)
+              + (change.renameFrom != null ? 1 : 0);
+      if (ops != 1) {
+        throw new BadRequestException(
+            "exactly one of content, delete, or rename_from is required for " + path);
+      }
+      if (change.fileMode != null && change.content == null) {
+        throw new BadRequestException("file_mode is only valid with content for " + path);
+      }
+      // Gitlink entries would reinterpret `content` as a 40-character SHA-1 rather than file bytes;
+      // that second meaning is out of scope for this endpoint.
+      if (change.fileMode != null && change.fileMode == 160000) {
+        throw new BadRequestException("file_mode 160000 (gitlink) is not supported for " + path);
+      }
+      TreeModification modification;
+      if (change.delete) {
+        modification = new DeleteFileModification(path);
+      } else if (change.renameFrom != null) {
+        if (change.renameFrom.isEmpty()) {
+          throw new BadRequestException("rename_from must not be empty for " + path);
+        }
+        if (change.renameFrom.equals(path)) {
+          throw new BadRequestException("rename_from must differ from the target path " + path);
+        }
+        modification = new RenameFileModification(change.renameFrom, path);
+      } else {
+        modification =
+            new ChangeFileContentModification(
+                path,
+                RawInputUtil.create(decodeBase64(change.content, path)),
+                octalToBits(change.fileMode));
+      }
+      for (String touched : modification.getFilePaths()) {
+        if (!touchedPaths.add(touched)) {
+          throw new BadRequestException("multiple operations affect the same path: " + touched);
+        }
+      }
+      modifications.add(modification);
+    }
+    return modifications.build();
+  }
+
+  private static byte[] decodeBase64(String content, String path) throws BadRequestException {
+    try {
+      return Base64.decode(content);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("content for " + path + " is not valid base64", e);
+    }
+  }
+
+  @Nullable
+  private static Integer octalToBits(@Nullable Integer octalFileMode) throws BadRequestException {
+    if (octalFileMode == null) {
+      return null;
+    }
+    try {
+      return Integer.parseInt(Integer.toString(octalFileMode), 8);
+    } catch (NumberFormatException e) {
+      throw new BadRequestException("invalid file_mode: " + octalFileMode, e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateCommit.java b/java/com/google/gerrit/server/restapi/project/CreateCommit.java
new file mode 100644
index 0000000..86db1dd
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateCommit.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.common.CommitInfo;
+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.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+/**
+ * Commits a set of file operations (create/update, delete, rename) directly to a branch as a single
+ * commit. Requires push access. Delegates to {@link BranchCommitBuilder}.
+ */
+@Singleton
+public class CreateCommit implements RestModifyView<BranchResource, CreateCommitInput> {
+  private final BranchCommitBuilder branchCommitBuilder;
+
+  @Inject
+  CreateCommit(BranchCommitBuilder branchCommitBuilder) {
+    this.branchCommitBuilder = branchCommitBuilder;
+  }
+
+  @Override
+  public Response<CommitInfo> apply(BranchResource rsrc, CreateCommitInput input)
+      throws RestApiException, PermissionBackendException, IOException {
+    return Response.ok(branchCommitBuilder.createCommit(rsrc, input));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 053ac3c..ef59fa2 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -42,8 +43,8 @@
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.CoreLockKeys;
 import com.google.gerrit.server.project.CreateProjectArgs;
 import com.google.gerrit.server.project.LockManager;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -78,7 +79,7 @@
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
-  private final PluginItemContext<LockManager> lockManager;
+  private final DynamicItem<LockManager> lockManager;
   private final ProjectCreator projectCreator;
 
   private final Config gerritConfig;
@@ -94,7 +95,7 @@
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      PluginItemContext<LockManager> lockManager,
+      DynamicItem<LockManager> lockManager,
       @GerritServerConfig Config gerritConfig) {
     this.projectsCollection = projectsCollection;
     this.projectCreator = projectCreator;
@@ -168,7 +169,7 @@
     }
     args.initOnly = input.initOnly;
 
-    Lock nameLock = lockManager.call(lockManager -> lockManager.getLock(args.getProject().get()));
+    Lock nameLock = lockManager.get().getLock(CoreLockKeys.createProject(args.getProject()));
     nameLock.lock();
     try {
       try {
diff --git a/java/com/google/gerrit/server/restapi/project/GetDiffFile.java b/java/com/google/gerrit/server/restapi/project/GetDiffFile.java
new file mode 100644
index 0000000..70f22be
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetDiffFile.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+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.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.diff.DiffInfoCreator;
+import com.google.gerrit.server.diff.DiffSide;
+import com.google.gerrit.server.diff.DiffWebLinksProvider;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.PatchScriptBuilder;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.project.FileResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+/**
+ * Gets the diff for a specific file between two commits.
+ *
+ * <p>GET /projects/{project}/commits/{commit}/files/{file}/diff?base={sha1}
+ *
+ * <p>Returns the same format as /changes/{id}/revisions/{rev}/files/{file}/diff to ensure identical
+ * output.
+ */
+public class GetDiffFile implements RestReadView<FileResource> {
+
+  private final GitRepositoryManager repoManager;
+  private final DiffOperations diffOperations;
+  private final Provider<PatchScriptBuilder> patchScriptBuilderProvider;
+  private final ProjectDiffUtils diffUtils;
+
+  @Option(name = "--base", metaVar = "SHA1", usage = "base commit SHA1 (40 characters)")
+  private String baseSha;
+
+  @Option(name = "--whitespace")
+  private Whitespace whitespace;
+
+  @Option(name = "--intraline")
+  private boolean intraline;
+
+  public GetDiffFile setBase(String baseSha) {
+    this.baseSha = baseSha;
+    return this;
+  }
+
+  @Inject
+  GetDiffFile(
+      GitRepositoryManager repoManager,
+      DiffOperations diffOperations,
+      Provider<PatchScriptBuilder> patchScriptBuilderProvider,
+      ProjectDiffUtils diffUtils) {
+    this.repoManager = repoManager;
+    this.diffOperations = diffOperations;
+    this.patchScriptBuilderProvider = patchScriptBuilderProvider;
+    this.diffUtils = diffUtils;
+  }
+
+  @Override
+  public Response<DiffInfo> apply(FileResource rsrc)
+      throws RestApiException,
+          BadRequestException,
+          ResourceNotFoundException,
+          ResourceConflictException,
+          IOException {
+    diffUtils.validateSha1(baseSha, "base");
+
+    ProjectState projectState = rsrc.getProjectState();
+    projectState.checkStatePermitsRead();
+    Project.NameKey project = projectState.getNameKey();
+    String filePath = rsrc.getPath();
+
+    ObjectId baseCommitId = diffUtils.parseObjectId(baseSha, "base");
+    ObjectId newCommitId = rsrc.getRev();
+
+    Whitespace ws = whitespace != null ? whitespace : Whitespace.IGNORE_LEADING_AND_TRAILING;
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit baseCommit = diffUtils.parseCommit(rw, baseCommitId, "base");
+      RevCommit newCommit = rw.parseCommit(newCommitId);
+
+      // Validate that commits are in ancestor/descendant relationship
+      diffUtils.validateAncestorRelationship(rw, baseCommit, newCommit);
+
+      // Verify visibility of all commits in the path
+      diffUtils.verifyPathVisibility(projectState, repo, rw, baseCommit, newCommit);
+
+      // Get the file diff
+      // Don't skip files due to rebase - this is a direct commit comparison, not patchset
+      // comparison
+      DiffOptions diffOptions =
+          DiffOptions.builder().skipFilesWithAllEditsDueToRebase(false).build();
+      FileDiffOutput fileDiffOutput =
+          diffOperations.getModifiedFile(
+              project, baseCommitId, newCommitId, filePath, ws, diffOptions);
+
+      // Convert to PatchScript
+      DiffPreferencesInfo prefs = new DiffPreferencesInfo();
+      prefs.ignoreWhitespace = ws;
+      prefs.intralineDifference = intraline;
+
+      PatchScriptBuilder builder = patchScriptBuilderProvider.get();
+      builder.setDiffPrefs(prefs);
+      PatchScript ps = builder.toPatchScript(repo, fileDiffOutput);
+
+      // Create DiffInfo
+      // For project-level diff, we don't have change context for web links
+      DiffWebLinksProvider emptyLinksProvider = new EmptyDiffWebLinksProvider();
+      DiffInfoCreator diffInfoCreator =
+          new DiffInfoCreator(projectState, emptyLinksProvider, intraline);
+
+      DiffSide sideA =
+          DiffSide.create(
+              ps.getFileInfoA(),
+              MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
+              DiffSide.Type.SIDE_A);
+      DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
+
+      DiffInfo result = diffInfoCreator.create(ps, sideA, sideB);
+      return Response.ok(result);
+    } catch (DiffNotAvailableException e) {
+      throw diffUtils.mapDiffException(e);
+    }
+  }
+
+  /** Empty implementation since project-level diff has no change context for web links. */
+  private static class EmptyDiffWebLinksProvider implements DiffWebLinksProvider {
+    @Override
+    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/GetSubmitRequirementTemplate.java b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirementTemplate.java
new file mode 100644
index 0000000..23acc34
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirementTemplate.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementTemplateResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetSubmitRequirementTemplate
+    implements RestReadView<SubmitRequirementTemplateResource> {
+  @Override
+  public Response<SubmitRequirementInfo> apply(SubmitRequirementTemplateResource rsrc) {
+    return Response.ok(
+        SubmitRequirementJson.format(rsrc.getSourceProject(), rsrc.getSubmitRequirementTemplate()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
index 11d8b19..abbe50d 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
@@ -77,8 +77,8 @@
       if (newBranch.isEmpty()) {
         continue;
       }
-      if (!RefPattern.isRE(newBranch) && !newBranch.startsWith(RefNames.REFS)) {
-        newBranch = RefNames.REFS_HEADS + newBranch;
+      if (!RefPattern.isRE(newBranch)) {
+        newBranch = RefNames.fullName(newBranch);
       }
       try {
         RefPattern.validate(newBranch);
diff --git a/java/com/google/gerrit/server/restapi/project/ListDiffFiles.java b/java/com/google/gerrit/server/restapi/project/ListDiffFiles.java
new file mode 100644
index 0000000..0a2925a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListDiffFiles.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.FileInfo;
+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.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+/**
+ * Lists files that differ between two commits in a project.
+ *
+ * <p>GET /projects/{project}/commits/{commit}/diff?base={sha1}
+ *
+ * <p>Returns the same format as /changes/{id}/revisions/{rev}/files to ensure identical output.
+ */
+public class ListDiffFiles implements RestReadView<CommitResource> {
+  private final GitRepositoryManager repoManager;
+  private final DiffOperations diffOperations;
+  private final ProjectDiffUtils diffUtils;
+
+  @Option(name = "--base", metaVar = "SHA1", usage = "base commit SHA1 (40 characters)")
+  private String baseSha;
+
+  @Option(name = "--name-only", usage = "return only the list of files")
+  private boolean nameOnly;
+
+  @CanIgnoreReturnValue
+  public ListDiffFiles setBase(String baseSha) {
+    this.baseSha = baseSha;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
+  public ListDiffFiles setNameOnly(boolean nameOnly) {
+    this.nameOnly = nameOnly;
+    return this;
+  }
+
+  @Inject
+  ListDiffFiles(
+      GitRepositoryManager repoManager, DiffOperations diffOperations, ProjectDiffUtils diffUtils) {
+    this.repoManager = repoManager;
+    this.diffOperations = diffOperations;
+    this.diffUtils = diffUtils;
+  }
+
+  @Override
+  public Response<Map<String, FileInfo>> apply(CommitResource rsrc)
+      throws RestApiException,
+          BadRequestException,
+          ResourceNotFoundException,
+          ResourceConflictException,
+          IOException {
+    if (!nameOnly) {
+      throw new BadRequestException("name-only parameter is required for listing diff files");
+    }
+
+    diffUtils.validateSha1(baseSha, "base");
+
+    rsrc.getProjectState().checkStatePermitsRead();
+    Project.NameKey project = rsrc.getProjectState().getNameKey();
+    ProjectState projectState = rsrc.getProjectState();
+
+    ObjectId baseCommitId = diffUtils.parseObjectId(baseSha, "base");
+    ObjectId newCommitId = rsrc.getCommit();
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit baseCommit = diffUtils.parseCommit(rw, baseCommitId, "base");
+      RevCommit newCommit = rw.parseCommit(newCommitId);
+
+      // Validate that commits are in ancestor/descendant relationship
+      diffUtils.validateAncestorRelationship(rw, baseCommit, newCommit);
+
+      // Walk all commits in path and verify visibility for each (critical for private changes)
+      diffUtils.verifyPathVisibility(projectState, repo, rw, baseCommit, newCommit);
+
+      // Compute the diff
+      // Don't skip files due to rebase - this is a direct commit comparison, not patchset
+      // comparison
+      DiffOptions diffOptions =
+          DiffOptions.builder()
+              .skipFilesWithAllEditsDueToRebase(false)
+              .skipRebaseFiltering(true)
+              .build();
+      Map<String, FileDiffOutput> fileDiffs =
+          diffOperations.listModifiedFiles(project, baseCommitId, newCommitId, diffOptions);
+
+      return Response.ok(asFileInfo(fileDiffs));
+    } catch (DiffNotAvailableException e) {
+      throw diffUtils.mapDiffException(e);
+    }
+  }
+
+  /**
+   * Converts FileDiffOutput map to FileInfo map.
+   *
+   * <p>This is the same conversion logic as FileInfoJsonImpl.asFileInfo() to ensure identical
+   * output format.
+   */
+  private Map<String, FileInfo> asFileInfo(Map<String, FileDiffOutput> fileDiffs) {
+    Map<String, FileInfo> result = new HashMap<>();
+    for (String path : fileDiffs.keySet()) {
+      FileDiffOutput fileDiff = fileDiffs.get(path);
+      FileInfo fileInfo = new FileInfo();
+      fileInfo.status =
+          fileDiff.changeType() != Patch.ChangeType.MODIFIED
+              ? fileDiff.changeType().getCode()
+              : null;
+      fileInfo.oldPath = FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType());
+      fileInfo.sizeDelta = fileDiff.sizeDelta();
+      fileInfo.size = fileDiff.size();
+      fileInfo.oldMode =
+          fileDiff.oldMode().isPresent() && !fileDiff.oldMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.oldMode().get().getMode()
+              : null;
+      fileInfo.newMode =
+          fileDiff.newMode().isPresent() && !fileDiff.newMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.newMode().get().getMode()
+              : null;
+      fileDiff.oldSha().ifPresent(sha -> fileInfo.oldSha = sha.name());
+      fileDiff.newSha().ifPresent(sha -> fileInfo.newSha = sha.name());
+
+      if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
+        fileInfo.binary = true;
+      } else {
+        fileInfo.linesInserted = fileDiff.insertions() > 0 ? fileDiff.insertions() : null;
+        fileInfo.linesDeleted = fileDiff.deletions() > 0 ? fileDiff.deletions() : null;
+      }
+      result.put(path, fileInfo);
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListSubmitRequirementTemplates.java b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirementTemplates.java
new file mode 100644
index 0000000..6f7ad81
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirementTemplates.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementTemplateResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A rest reads submit requirement template stored in project.config for current project and its
+ * parents.
+ */
+@Singleton
+public class ListSubmitRequirementTemplates implements RestReadView<ProjectResource> {
+  private final SubmitRequirementTemplateLoader templateLoader;
+
+  @Inject
+  public ListSubmitRequirementTemplates(SubmitRequirementTemplateLoader templateLoader) {
+    this.templateLoader = templateLoader;
+  }
+
+  @Override
+  public Response<List<SubmitRequirementInfo>> apply(ProjectResource rsrc)
+      throws AuthException, PermissionBackendException, IOException, ConfigInvalidException {
+    return Response.ok(
+        templateLoader.load(rsrc).values().stream()
+            .map(this::format)
+            .collect(ImmutableList.toImmutableList()));
+  }
+
+  private SubmitRequirementInfo format(SubmitRequirementTemplateResource resource) {
+    return SubmitRequirementJson.format(
+        resource.getSourceProject(), resource.getSubmitRequirementTemplate());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
index 44f7ba2..110032a 100644
--- a/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
@@ -37,6 +37,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -251,12 +252,29 @@
               String.join(
                   " OR ",
                   lt.getRefPatterns().stream()
-                      .map(b -> "branch:\\\"" + b + "\\\"")
+                      .map(MigrateLabelFunctionsToSubmitRequirement::toApplicableIfExpression)
                       .collect(Collectors.toList()))));
     }
     return builder.build();
   }
 
+  private static String toApplicableIfExpression(String branchRef) {
+    // Reqex -> migrate as it is.
+    if (branchRef.startsWith("^")) {
+      return "branch:" + branchRef;
+    }
+    // Wildcard -> convert into gerrit regex
+    if (branchRef.endsWith("/*")) {
+      String prefix = branchRef.substring(0, branchRef.length() - 1);
+      String regex = "^" + Pattern.quote(prefix) + ".*";
+      return "branch:" + regex;
+    }
+    // branch name contains quote character -> escape quote
+    branchRef = branchRef.replace("\"", "\\\"");
+    // Other cases e.g. branch name containing # or " -> needs to be quoted
+    return "branch:\"" + branchRef + "\"";
+  }
+
   private static boolean isBlockingOrRequiredLabel(LabelType lt) {
     return switch (lt.getFunction()) {
       case ANY_WITH_BLOCK, MAX_WITH_BLOCK, MAX_NO_BLOCK -> true;
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
index 4e9d432..177f26d 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
@@ -27,9 +27,9 @@
 import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Map;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
index 71080a5..2c7bfce 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
-import javax.inject.Inject;
-import javax.inject.Singleton;
+import com.google.inject.Singleton;
 
 @Singleton
 public class PostSubmitRequirements
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
index f0a371a..c9485fc 100644
--- a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectDiffUtils.java b/java/com/google/gerrit/server/restapi/project/ProjectDiffUtils.java
new file mode 100644
index 0000000..36c0999
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ProjectDiffUtils.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2026 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.restapi.project;
+
+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.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Utility class for common diff operations in the project REST API. */
+@Singleton
+public class ProjectDiffUtils {
+  private static final int SHA1_LENGTH = 40;
+
+  private final CommitsCollection commitsCollection;
+
+  @Inject
+  ProjectDiffUtils(CommitsCollection commitsCollection) {
+    this.commitsCollection = commitsCollection;
+  }
+
+  public void validateSha1(String sha, String paramName) throws BadRequestException {
+    if (sha == null || sha.isEmpty()) {
+      throw new BadRequestException("Missing required parameter: " + paramName);
+    }
+    if (sha.length() != SHA1_LENGTH) {
+      throw new BadRequestException(
+          String.format(
+              "Parameter '%s' must be a 40-character SHA1, got %d characters",
+              paramName, sha.length()));
+    }
+    if (!sha.matches("[0-9a-fA-F]+")) {
+      throw new BadRequestException(
+          String.format("Parameter '%s' must be a valid hexadecimal SHA1", paramName));
+    }
+  }
+
+  public ObjectId parseObjectId(String sha, String paramName) throws BadRequestException {
+    try {
+      return ObjectId.fromString(sha);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("Invalid SHA1 for " + paramName + ": " + sha, e);
+    }
+  }
+
+  public RevCommit parseCommit(RevWalk rw, ObjectId commitId, String paramName)
+      throws ResourceNotFoundException, IOException {
+    try {
+      return rw.parseCommit(commitId);
+    } catch (MissingObjectException e) {
+      throw new ResourceNotFoundException(
+          String.format("Commit '%s' (%s) not found", paramName, commitId.name()), e);
+    } catch (IncorrectObjectTypeException e) {
+      throw new ResourceNotFoundException(
+          String.format("Object '%s' (%s) is not a commit", paramName, commitId.name()), e);
+    }
+  }
+
+  public void validateAncestorRelationship(RevWalk rw, RevCommit oldCommit, RevCommit newCommit)
+      throws BadRequestException, IOException {
+    boolean oldIsAncestorOfNew = rw.isMergedInto(oldCommit, newCommit);
+    boolean newIsAncestorOfOld = rw.isMergedInto(newCommit, oldCommit);
+
+    if (!oldIsAncestorOfNew && !newIsAncestorOfOld) {
+      throw new BadRequestException(
+          String.format(
+              "Commits %s and %s are not in ancestor/descendant relationship",
+              oldCommit.name(), newCommit.name()));
+    }
+  }
+
+  public void verifyPathVisibility(
+      ProjectState projectState,
+      Repository repo,
+      RevWalk rw,
+      RevCommit oldCommit,
+      RevCommit newCommit)
+      throws ResourceNotFoundException, IOException {
+    // Check visibility of both endpoints
+    if (!commitsCollection.canRead(projectState, repo, oldCommit)
+        || !commitsCollection.canRead(projectState, repo, newCommit)) {
+      throw new ResourceNotFoundException("Commit not visible");
+    }
+
+    // Determine topological order to walk from descendant to ancestor
+    rw.reset();
+    boolean oldIsAncestorOfNew = rw.isMergedInto(oldCommit, newCommit);
+    boolean newIsAncestorOfOld = rw.isMergedInto(newCommit, oldCommit);
+
+    RevCommit descendant;
+    RevCommit ancestor;
+    if (oldIsAncestorOfNew) {
+      descendant = newCommit;
+      ancestor = oldCommit;
+    } else if (newIsAncestorOfOld) {
+      descendant = oldCommit;
+      ancestor = newCommit;
+    } else {
+      // Commits are not related, visibility check covers endpoints but no path exists.
+      // This is usually caught by validateAncestorRelationship.
+      return;
+    }
+
+    // Walk all commits between descendant and ancestor and check visibility
+    rw.reset();
+    rw.markStart(descendant);
+    rw.markUninteresting(ancestor);
+
+    for (RevCommit commit : rw) {
+      if (!commitsCollection.canRead(projectState, repo, commit)) {
+        throw new ResourceNotFoundException("Commit not visible");
+      }
+    }
+  }
+
+  /** Maps {@link DiffNotAvailableException} to appropriate {@link RestApiException}. */
+  public RestApiException mapDiffException(DiffNotAvailableException e) {
+    Throwable cause = e.getCause();
+    if (cause != null && !(cause instanceof NoMergeBaseException)) {
+      cause = cause.getCause();
+    }
+    if (cause instanceof NoMergeBaseException) {
+      return new ResourceConflictException(
+          String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+    }
+    return new ResourceNotFoundException("Cannot compute diff: " + e.getMessage(), e);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index adba60e..e0fb642 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
 import static com.google.gerrit.server.project.SubmitRequirementResource.SUBMIT_REQUIREMENT_KIND;
+import static com.google.gerrit.server.project.SubmitRequirementTemplateResource.SUBMIT_REQUIREMENT_TEMPLATE_KIND;
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -35,6 +36,7 @@
     bind(ProjectsCollection.class);
     bind(ListProjects.class).to(ListProjectsImpl.class);
     bind(DashboardsCollection.class);
+    bind(ProjectDiffUtils.class);
 
     DynamicMap.mapOf(binder(), BRANCH_KIND);
     DynamicMap.mapOf(binder(), CHILD_PROJECT_KIND);
@@ -44,6 +46,7 @@
     DynamicMap.mapOf(binder(), LABEL_KIND);
     DynamicMap.mapOf(binder(), PROJECT_KIND);
     DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
+    DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_TEMPLATE_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
 
     create(PROJECT_KIND).to(CreateProject.class);
@@ -63,6 +66,8 @@
     child(BRANCH_KIND, "files").to(FilesCollection.class);
     get(FILE_KIND, "content").to(GetContent.class);
 
+    post(BRANCH_KIND, "commit").to(CreateCommit.class);
+
     get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
     get(BRANCH_KIND, "reflog").to(GetReflog.class);
     get(BRANCH_KIND, "suggest_reviewers").to(SuggestBranchReviewers.class);
@@ -80,9 +85,12 @@
     post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
     child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
     get(COMMIT_KIND, "in").to(CommitIncludedIn.class);
+    get(COMMIT_KIND, "diff").to(ListDiffFiles.class);
 
     get(PROJECT_KIND, "commits:in").to(CommitsIncludedInRefs.class);
 
+    get(FILE_KIND, "diff").to(GetDiffFile.class);
+
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
     put(PROJECT_KIND, "config:review").to(PutConfigReview.class);
@@ -124,6 +132,10 @@
     postOnCollection(SUBMIT_REQUIREMENT_KIND).to(PostSubmitRequirements.class);
     post(PROJECT_KIND, "submit_requirements:review").to(PostSubmitRequirementsReview.class);
 
+    child(PROJECT_KIND, "submit_requirements_templates")
+        .to(SubmitRequirementTemplatesCollection.class);
+    get(SUBMIT_REQUIREMENT_TEMPLATE_KIND).to(GetSubmitRequirementTemplate.class);
+
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     create(TAG_KIND).to(CreateTag.class);
     get(TAG_KIND).to(GetTag.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
index 7e6cc19..eda92bb 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import javax.inject.Inject;
-import javax.inject.Singleton;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
index c931203..89985fd 100644
--- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -50,10 +50,10 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
-import javax.inject.Inject;
-import javax.inject.Provider;
-import javax.inject.Singleton;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/server/restapi/project/SubmitRequirementTemplateLoader.java b/java/com/google/gerrit/server/restapi/project/SubmitRequirementTemplateLoader.java
new file mode 100644
index 0000000..a7d41e5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SubmitRequirementTemplateLoader.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementTemplateResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+class SubmitRequirementTemplateLoader {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
+
+  @Inject
+  SubmitRequirementTemplateLoader(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
+  }
+
+  LinkedHashMap<String, SubmitRequirementTemplateResource> load(ProjectResource project)
+      throws AuthException, PermissionBackendException, IOException, ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    LinkedHashMap<String, SubmitRequirementTemplateResource> templates = new LinkedHashMap<>();
+    ProjectState currentProjectState = project.getProjectState();
+    for (ProjectState projectState : project.getProjectState().treeInOrder()) {
+      try {
+        checkCanReadConfig(projectState);
+      } catch (AuthException e) {
+        if (projectState.getNameKey().equals(currentProjectState.getNameKey())) {
+          throw e;
+        }
+        continue;
+      }
+
+      for (SubmitRequirement submitRequirement : listTemplates(projectState)) {
+        String lowerName = submitRequirement.name().toLowerCase(Locale.US);
+        SubmitRequirementTemplateResource old = templates.get(lowerName);
+        if (old == null || old.getSubmitRequirementTemplate().allowOverrideInChildProjects()) {
+          templates.put(
+              lowerName,
+              new SubmitRequirementTemplateResource(
+                  project, projectState.getNameKey(), submitRequirement));
+        }
+      }
+    }
+    return templates;
+  }
+
+  private void checkCanReadConfig(ProjectState projectState)
+      throws AuthException, PermissionBackendException {
+    try {
+      permissionBackend
+          .currentUser()
+          .project(projectState.getNameKey())
+          .check(ProjectPermission.READ_CONFIG);
+    } catch (AuthException e) {
+      throw new AuthException(projectState.getNameKey() + ": " + e.getMessage(), e);
+    }
+  }
+
+  private Iterable<SubmitRequirement> listTemplates(ProjectState projectState)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(projectState.getNameKey());
+    try (Repository repo = repoManager.openRepository(projectState.getNameKey())) {
+      projectConfig.load(repo);
+    }
+    return projectConfig.getSubmitRequirementTemplateSections().values();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SubmitRequirementTemplatesCollection.java b/java/com/google/gerrit/server/restapi/project/SubmitRequirementTemplatesCollection.java
new file mode 100644
index 0000000..59c3dc2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SubmitRequirementTemplatesCollection.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2026 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.restapi.project;
+
+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;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementTemplateResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Locale;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class SubmitRequirementTemplatesCollection
+    implements ChildCollection<ProjectResource, SubmitRequirementTemplateResource> {
+  private final SubmitRequirementTemplateLoader templateLoader;
+  private final DynamicMap<RestView<SubmitRequirementTemplateResource>> views;
+  private final Provider<ListSubmitRequirementTemplates> list;
+
+  @Inject
+  SubmitRequirementTemplatesCollection(
+      SubmitRequirementTemplateLoader templateLoader,
+      DynamicMap<RestView<SubmitRequirementTemplateResource>> views,
+      Provider<ListSubmitRequirementTemplates> list) {
+    this.templateLoader = templateLoader;
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public SubmitRequirementTemplateResource parse(ProjectResource parent, IdString id)
+      throws AuthException,
+          ResourceNotFoundException,
+          PermissionBackendException,
+          IOException,
+          ConfigInvalidException {
+    SubmitRequirementTemplateResource resource =
+        templateLoader.load(parent).get(id.get().toLowerCase(Locale.US));
+    if (resource == null) {
+      throw new ResourceNotFoundException(
+          String.format("Submit requirement template '%s' does not exist", id));
+    }
+    return resource;
+  }
+
+  @Override
+  public DynamicMap<RestView<SubmitRequirementTemplateResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java b/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
index 3610c93..8c70a38 100644
--- a/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
@@ -143,7 +143,7 @@
     for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
       try {
         i.next().run();
-      } catch (Exception err) {
+      } catch (RuntimeException err) {
         logger.atSevere().withCause(err).log("Failed to execute cleanup for PrologEnvironment");
       }
       i.remove();
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
index 6be71f8..4cc30ec 100644
--- a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import javax.inject.Inject;
 
 /** Implementation of {@link PrologSubmitRuleUtil}. */
 @Singleton
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 514b993..0a458d7 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -166,8 +166,10 @@
     config.upsertAccessSection(
         AccessSection.HEADS,
         heads -> {
-          initDefaultAclsForAnonymousUsers(heads, config);
-          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+          initDefaultAclsForDefaultReaders(
+              heads, config, input.defaultReadersGroup().orElse(anonymous));
+          initDefaultAclsForDefaultUsers(
+              heads, codeReviewLabel, config, input.defaultUsersGroup().orElse(registered));
         });
 
     config.upsertAccessSection(
@@ -200,25 +202,34 @@
             .build());
   }
 
-  private void initDefaultAclsForAnonymousUsers(AccessSection.Builder heads, ProjectConfig config) {
-    grant(config, heads, Permission.READ, anonymous);
+  private void initDefaultAclsForDefaultReaders(
+      AccessSection.Builder heads, ProjectConfig config, GroupReference defaultReadersGroup) {
+    grant(config, heads, Permission.READ, defaultReadersGroup);
 
     config.upsertAccessSection(
-        "refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
+        "refs/meta/version",
+        version -> grant(config, version, Permission.READ, defaultReadersGroup));
   }
 
-  private void initDefaultAclsForRegisteredUsers(
-      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
-    grant(config, heads, codeReviewLabel, -1, 1, registered);
-    grant(config, heads, Permission.FORGE_AUTHOR, registered);
+  private void initDefaultAclsForDefaultUsers(
+      AccessSection.Builder heads,
+      LabelType codeReviewLabel,
+      ProjectConfig config,
+      GroupReference defaultUsersGroup) {
+    grant(config, heads, codeReviewLabel, -1, 1, defaultUsersGroup);
+    grant(config, heads, Permission.FORGE_AUTHOR, defaultUsersGroup);
 
     config.upsertAccessSection(
         "refs/for/*",
         refsFor -> {
-          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
-          grant(config, refsFor, Permission.PUSH, registered);
-          grant(config, refsFor, Permission.PUSH_MERGE, registered);
+          grant(config, refsFor, Permission.ADD_PATCH_SET, defaultUsersGroup);
+          grant(config, refsFor, Permission.PUSH, defaultUsersGroup);
+          grant(config, refsFor, Permission.PUSH_MERGE, defaultUsersGroup);
         });
+
+    config.upsertAccessSection(
+        AccessSection.ALL,
+        all -> grant(config, all, Permission.POST_REVIEW_COMMENT, defaultUsersGroup));
   }
 
   private void initDefaultAclsForServiceUsers(
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 3b61bee..3d6031f 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -86,6 +86,12 @@
         .build();
   }
 
+  /** The default reader group which gets read permissions granted. */
+  public abstract Optional<GroupReference> defaultReadersGroup();
+
+  /** The default user group which gets default permissions granted. */
+  public abstract Optional<GroupReference> defaultUsersGroup();
+
   /** The administrator group which gets default permissions granted. */
   public abstract Optional<GroupReference> administratorsGroup();
 
@@ -143,6 +149,10 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    public abstract Builder defaultReadersGroup(GroupReference defaultReadersGroup);
+
+    public abstract Builder defaultUsersGroup(GroupReference defaultUsersGroup);
+
     public abstract Builder administratorsGroup(GroupReference adminGroup);
 
     public abstract Builder serviceUsersGroup(GroupReference serviceUsersGroup);
diff --git a/java/com/google/gerrit/server/schema/GrantPostReviewCommentPermission.java b/java/com/google/gerrit/server/schema/GrantPostReviewCommentPermission.java
new file mode 100644
index 0000000..e9f34fd
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/GrantPostReviewCommentPermission.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+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.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class adds the "postReviewComment" permission to all hosts that call this method with the
+ * relevant projectName. This class should be called with AllProjects as the project, by all hosts
+ * before enabling the "postReviewComment" permission.
+ */
+public class GrantPostReviewCommentPermission {
+
+  private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SystemGroupBackend systemGroupBackend;
+  private final PersonIdent serverUser;
+
+  @Inject
+  public GrantPostReviewCommentPermission(
+      GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
+    this.systemGroupBackend = systemGroupBackend;
+    this.serverUser = serverUser;
+  }
+
+  public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    try (Repository repo = repoManager.openRepository(projectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+
+      AtomicBoolean shouldExit = new AtomicBoolean(false);
+      projectConfig.upsertAccessSection(
+          AccessSection.ALL,
+          all -> {
+            Permission permissionOnRefsStar =
+                all.build().getPermission(Permission.POST_REVIEW_COMMENT);
+            if (permissionOnRefsStar != null) {
+              if (permissionOnRefsStar.getRule(registeredUsers) == null) {
+                // If admins already changed the permission, don't do anything.
+                shouldExit.set(true);
+                return;
+              }
+              // permission already exists on refs/* for Registered Users, don't do anything.
+              return;
+            }
+            // If the permission doesn't exist on refs/* for Registered Users, grant it.
+            grant(projectConfig, all, Permission.POST_REVIEW_COMMENT, registeredUsers);
+          });
+
+      if (shouldExit.get()) {
+        return;
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage("Add Post Review Comment permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
index c820e5a..944fb2c 100644
--- a/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
@@ -35,6 +35,10 @@
     super(cfg, sitePaths, threadSettingsConfig);
   }
 
+  protected H2AccountPatchReviewStore() {
+    super();
+  }
+
   @Override
   public StorageException convertError(String op, SQLException err) {
     switch (getSQLStateInt(err)) {
diff --git a/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java
new file mode 100644
index 0000000..35cd0dd
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java
@@ -0,0 +1,249 @@
+// Copyright (C) 2026 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.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.SitePaths;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.ArrayDeque;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Abstract base for H2 stores that replace H2's built-in file locking with a custom mechanism.
+ *
+ * <p>H2 is opened with {@code FILE_LOCK=NO}; subclasses implement {@link #newLock()}, returning a
+ * raw {@link Lock} with a working {@link Lock#tryLock()} and {@link Lock#unlock()}. This class adds
+ * retry-with-backoff (up to {@code h2LockTimeout}) and batching: up to {@code h2LockBatchSize}
+ * in-process callers can share one held lock at a time instead of each acquiring/releasing
+ * separately.
+ */
+abstract class H2CustomLockAccountPatchReviewStore extends H2AccountPatchReviewStore {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final long DEFAULT_LOCK_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(30);
+  private static final int DEFAULT_LOCK_BATCH_SIZE = 32;
+  private static final long INITIAL_BACKOFF_MS = 1;
+  private static final long MAX_BACKOFF_MS = 500;
+
+  private final String url;
+  private final long lockTimeoutMs;
+  private final int lockBatchSize;
+  private Lock lockInstance;
+
+  protected H2CustomLockAccountPatchReviewStore(Config cfg, SitePaths sitePaths) {
+    super();
+    url = JdbcAccountPatchReviewStore.getUrl(cfg, sitePaths) + ";FILE_LOCK=NO;DB_CLOSE_DELAY=0";
+    lockTimeoutMs =
+        ConfigUtil.getTimeUnit(
+            cfg,
+            JdbcAccountPatchReviewStore.ACCOUNT_PATCH_REVIEW_DB,
+            null,
+            "h2LockTimeout",
+            DEFAULT_LOCK_TIMEOUT_MS,
+            TimeUnit.MILLISECONDS);
+    lockBatchSize =
+        Math.max(
+            1,
+            cfg.getInt(
+                JdbcAccountPatchReviewStore.ACCOUNT_PATCH_REVIEW_DB,
+                "h2LockBatchSize",
+                DEFAULT_LOCK_BATCH_SIZE));
+  }
+
+  protected long getLockTimeoutMs() {
+    return lockTimeoutMs;
+  }
+
+  protected int getLockBatchSize() {
+    return lockBatchSize;
+  }
+
+  /** Creates a new, not-yet-acquired raw {@link Lock}; only tryLock()/unlock() are used. */
+  protected abstract Lock newLock();
+
+  private synchronized Lock lock() {
+    if (lockInstance == null) {
+      lockInstance = newBatchingLock(newLock(), lockBatchSize);
+    }
+    return lockInstance;
+  }
+
+  /** Wraps {@code raw} with retry-with-backoff and fixed-size batching. */
+  static Lock newBatchingLock(Lock raw, int lockBatchSize) {
+    return new Lock() {
+      // Threads not yet admitted, in the order they arrived.
+      private final Queue<Thread> waiting = new ArrayDeque<>();
+      // Threads currently holding the lock.
+      private final Set<Thread> acquired = new HashSet<>();
+      private final int maxActive = Math.max(1, lockBatchSize);
+
+      @Override
+      public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
+        long backoffMs = INITIAL_BACKOFF_MS;
+        long deadline = System.nanoTime() + unit.toNanos(time);
+        Thread thread = Thread.currentThread();
+        synchronized (this) {
+          waiting.offer(thread);
+          while (true) {
+            if (acquired.contains(thread)) {
+              return true;
+            }
+            if (acquired.isEmpty() && isTaskedToTryRaw(thread)) {
+              if (tryAcquireRaw()) {
+                return true;
+              }
+            }
+            try {
+              long remainingNanos = deadline - System.nanoTime();
+              if (remainingNanos <= 0) {
+                waiting.remove(thread);
+                return false;
+              }
+              long waitMs = Math.clamp(TimeUnit.NANOSECONDS.toMillis(remainingNanos), 1, backoffMs);
+              logger.atFine().log("H2 lock held by another process, retrying in %d ms", waitMs);
+              wait(waitMs);
+              backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
+            } catch (InterruptedException | RuntimeException e) {
+              unlock(thread);
+              throw e;
+            }
+          }
+        }
+      }
+
+      @Override
+      public synchronized void unlock() {
+        unlock(Thread.currentThread());
+      }
+
+      private void unlock(Thread thread) {
+        acquired.remove(thread);
+        try {
+          if (acquired.isEmpty()) {
+            raw.unlock();
+          }
+        } finally {
+          notifyAll();
+        }
+      }
+
+      private boolean tryAcquireRaw() {
+        boolean rawAcquired = false;
+        try {
+          rawAcquired = raw.tryLock();
+        } catch (RuntimeException e) {
+          logger.atSevere().withCause(e).log(
+              "Exception while trying to lock for AccountPatchReviewStore");
+        }
+        try {
+          if (rawAcquired) {
+            admitBatch();
+          }
+        } catch (RuntimeException e) {
+          logger.atSevere().withCause(e).log(
+              "Exception while allowing next batch of waiting threads");
+        }
+        return rawAcquired;
+      }
+
+      private boolean isTaskedToTryRaw(Thread thread) {
+        // Only the head thread tries the raw lock. If it times out or is interrupted,
+        // stopWaiting() removes it and the next thread becomes tasked to try.
+        return waiting.peek() == thread;
+      }
+
+      private void admitBatch() {
+        Iterator<Thread> it = waiting.iterator();
+        int localAdmitted = 0;
+        while (it.hasNext() && localAdmitted++ < maxActive) {
+          acquired.add(it.next());
+          it.remove();
+        }
+        // This wakes up all the threads waiting in the queue so that they loop
+        // to check their admitted status.
+        notifyAll();
+      }
+
+      @Override
+      public void lock() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void lockInterruptibly() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public boolean tryLock() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public Condition newCondition() {
+        throw new UnsupportedOperationException();
+      }
+    };
+  }
+
+  @Override
+  public Connection getConnection() throws SQLException {
+    Lock lock = lock();
+    try {
+      if (!lock.tryLock(lockTimeoutMs, TimeUnit.MILLISECONDS)) {
+        throw new SQLException("Could not acquire H2 lock within " + lockTimeoutMs + " ms");
+      }
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new SQLException("Interrupted while waiting for H2 lock", e);
+    }
+
+    try {
+      return lockingConnection(DriverManager.getConnection(url), lock);
+    } catch (SQLException e) {
+      lock.unlock();
+      throw e;
+    }
+  }
+
+  private static Connection lockingConnection(Connection con, Lock lock) {
+    return (Connection)
+        Proxy.newProxyInstance(
+            Connection.class.getClassLoader(),
+            new Class<?>[] {Connection.class},
+            (proxy, method, args) -> {
+              try {
+                return method.invoke(con, args);
+              } catch (InvocationTargetException e) {
+                throw e.getCause();
+              } finally {
+                if ("close".equals(method.getName())) {
+                  lock.unlock();
+                }
+              }
+            });
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
new file mode 100644
index 0000000..c66b3b4
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2026 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.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.internal.storage.file.LockFile;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * H2 store using jgit-style {@link LockFile} locking for inter-process mutual exclusion.
+ *
+ * <p>Activated by setting {@code accountPatchReviewDb.h2LockType = jgit}. Each call to {@link
+ * #getConnection()} atomically creates a {@code .lock} sidecar file before opening H2 and deletes
+ * it when the connection is closed.
+ */
+@Singleton
+public class H2JGitLockAccountPatchReviewStore extends H2CustomLockAccountPatchReviewStore {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String H2_DB_URL_PREFIX = "jdbc:h2:file:";
+  private final File lockTarget;
+
+  @Inject
+  H2JGitLockAccountPatchReviewStore(@GerritServerConfig Config cfg, SitePaths sitePaths) {
+    super(cfg, sitePaths);
+    this.lockTarget = lockTargetFromUrl(JdbcAccountPatchReviewStore.getUrl(cfg, sitePaths));
+  }
+
+  @VisibleForTesting
+  static File lockTargetFromUrl(String h2Url) {
+    if (!h2Url.startsWith(H2_DB_URL_PREFIX)) {
+      throw new IllegalArgumentException("Not a valid H2 file URL: " + h2Url);
+    }
+
+    // URL format: "jdbc:h2:file:/path/to/db" - where ";" in the path is escaped as "\;"
+    String path = h2Url.substring(H2_DB_URL_PREFIX.length());
+
+    // Split on first unescaped ";" to drop options, then unescape "\;" in the path
+    return new File(
+        Iterables.get(Splitter.on(Pattern.compile("(?<!\\\\);")).split(path), 0)
+            .replace("\\;", ";"));
+  }
+
+  @Override
+  public void start() {
+    super.start();
+    logger.atInfo().log(
+        "AccountPatchReviewStore using H2 with jgit-style locking (h2LockType=jgit)."
+            + " lockFile=%s lockTimeout=%d ms",
+        lockTarget, getLockTimeoutMs());
+  }
+
+  /**
+   * Creates a {@link Lock} whose {@link Lock#tryLock(long, TimeUnit)} creates and acquires a
+   * jgit-style {@link LockFile}, retrying with backoff until the given wait time elapses.
+   */
+  @Override
+  protected Lock newLock() {
+    return new Lock() {
+      private LockFile lockFile;
+
+      @Override
+      public synchronized boolean tryLock() {
+        if (lockFile != null) {
+          return false;
+        }
+        try {
+          LockFile currLock = new LockFile(lockTarget);
+          if (currLock.lock()) {
+            lockFile = currLock;
+            return true;
+          }
+        } catch (IOException e) {
+          logger.atInfo().withCause(e).log("Failed to acquire jgit-style lock for H2 database");
+        }
+        return false;
+      }
+
+      @Override
+      public synchronized void unlock() {
+        lockFile.unlock();
+        lockFile = null;
+      }
+
+      @Override
+      public void lock() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void lockInterruptibly() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public boolean tryLock(long time, TimeUnit unit) {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public Condition newCondition() {
+        throw new UnsupportedOperationException();
+      }
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 9079836..fa53fe7 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -60,13 +60,15 @@
   public static final String TEST_IN_MEMORY_URL =
       "jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1";
 
-  private static final String ACCOUNT_PATCH_REVIEW_DB = "accountPatchReviewDb";
+  static final String ACCOUNT_PATCH_REVIEW_DB = "accountPatchReviewDb";
   private static final String H2_DB = "h2";
   private static final String MARIADB = "mariadb";
   private static final String MYSQL = "mysql";
   private static final String POSTGRESQL = "postgresql";
   private static final String CLOUDSPANNER = "cloudspanner";
   private static final String URL = "url";
+  private static final String H2_LOCK_TYPE = "h2LockType";
+  static final String H2_LOCK_TYPE_JGIT = "jgit";
 
   public static class JdbcAccountPatchReviewStoreModule extends LifecycleModule {
     private final Config cfg;
@@ -80,7 +82,18 @@
       Class<? extends JdbcAccountPatchReviewStore> impl;
       String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
       if (url == null || url.contains(H2_DB)) {
-        impl = H2AccountPatchReviewStore.class;
+        String lockType = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, H2_LOCK_TYPE);
+        switch (lockType != null ? lockType : "") {
+          case "":
+            impl = H2AccountPatchReviewStore.class;
+            break;
+          case H2_LOCK_TYPE_JGIT:
+            impl = H2JGitLockAccountPatchReviewStore.class;
+            break;
+          default:
+            throw new IllegalArgumentException(
+                "Invalid accountPatchReviewDb.h2LockType value: " + lockType);
+        }
       } else if (url.contains(POSTGRESQL)) {
         impl = PostgresqlAccountPatchReviewStore.class;
       } else if (url.contains(MYSQL)) {
@@ -104,7 +117,16 @@
       Config cfg, SitePaths sitePaths, ThreadSettingsConfig threadSettingsConfig) {
     String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
     if (url == null || url.contains(H2_DB)) {
-      return new H2AccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+      String lockType = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, H2_LOCK_TYPE);
+      switch (lockType != null ? lockType : "") {
+        case "":
+          return new H2AccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+        case H2_LOCK_TYPE_JGIT:
+          return new H2JGitLockAccountPatchReviewStore(cfg, sitePaths);
+        default:
+          throw new IllegalArgumentException(
+              "Invalid accountPatchReviewDb.h2LockType value: " + lockType);
+      }
     }
     if (url.contains(POSTGRESQL)) {
       return new PostgresqlAccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
@@ -127,7 +149,12 @@
     this.ds = createDataSource(cfg, sitePaths, threadSettingsConfig);
   }
 
-  private static String getUrl(@GerritServerConfig Config cfg, SitePaths sitePaths) {
+  // Used by subclasses that manage their own connections without a pool.
+  protected JdbcAccountPatchReviewStore() {
+    this.ds = null;
+  }
+
+  static String getUrl(@GerritServerConfig Config cfg, SitePaths sitePaths) {
     String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
     if (url == null) {
       return createH2Url(sitePaths.db_dir.resolve("account_patch_reviews"));
@@ -191,7 +218,7 @@
   }
 
   public void createTableIfNotExists() {
-    try (Connection con = ds.getConnection();
+    try (Connection con = getConnection();
         Statement stmt = con.createStatement()) {
       doCreateTable(stmt);
     } catch (SQLException e) {
@@ -212,7 +239,7 @@
   }
 
   public void dropTableIfExists() {
-    try (Connection con = ds.getConnection();
+    try (Connection con = getConnection();
         Statement stmt = con.createStatement()) {
       stmt.executeUpdate("DROP TABLE IF EXISTS account_patch_reviews");
     } catch (SQLException e) {
@@ -233,7 +260,7 @@
                     .accountId(accountId.get())
                     .filePath(path)
                     .build());
-        Connection con = ds.getConnection();
+        Connection con = getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "INSERT INTO account_patch_reviews "
@@ -268,7 +295,7 @@
                     .accountId(accountId.get())
                     .resourceCount(paths.size())
                     .build());
-        Connection con = ds.getConnection();
+        Connection con = getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "INSERT INTO account_patch_reviews "
@@ -301,7 +328,7 @@
                     .accountId(accountId.get())
                     .filePath(path)
                     .build());
-        Connection con = ds.getConnection();
+        Connection con = getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "DELETE FROM account_patch_reviews "
@@ -323,7 +350,7 @@
             TraceContext.newTimer(
                 "Clear all reviewed flags of patch set",
                 Metadata.builder().patchSetId(psId.get()).build());
-        Connection con = ds.getConnection();
+        Connection con = getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "DELETE FROM account_patch_reviews "
@@ -342,7 +369,7 @@
             TraceContext.newTimer(
                 "Clear all reviewed flags of change",
                 Metadata.builder().changeId(changeId.get()).build());
-        Connection con = ds.getConnection();
+        Connection con = getConnection();
         PreparedStatement stmt =
             con.prepareStatement("DELETE FROM account_patch_reviews WHERE change_id = ?")) {
       stmt.setInt(1, changeId.get());
@@ -358,7 +385,7 @@
             TraceContext.newTimer(
                 "Clear all reviewed flags by user",
                 Metadata.builder().accountId(accountId.get()).build());
-        Connection con = ds.getConnection();
+        Connection con = getConnection();
         PreparedStatement stmt =
             con.prepareStatement("DELETE FROM account_patch_reviews WHERE account_id = ?")) {
       stmt.setInt(1, accountId.get());
@@ -374,7 +401,7 @@
             TraceContext.newTimer(
                 "Find reviewed flags",
                 Metadata.builder().patchSetId(psId.get()).accountId(accountId.get()).build());
-        Connection con = ds.getConnection();
+        Connection con = getConnection();
         PreparedStatement stmt =
             con.prepareStatement(
                 "SELECT patch_set_id, file_name FROM account_patch_reviews APR1 "
@@ -431,7 +458,12 @@
     return 0;
   }
 
-  private static String createH2Url(Path path) {
-    return new StringBuilder().append("jdbc:h2:").append(path.toUri().toString()).toString();
+  @VisibleForTesting
+  static String createH2Url(Path path) {
+    return new StringBuilder()
+        .append("jdbc:h2:file:")
+        .append(path.toAbsolutePath().toString())
+        .toString()
+        .replace(";", "\\;");
   }
 }
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index 06c2037..305e476 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -40,6 +40,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -329,12 +330,29 @@
               String.join(
                   " OR ",
                   attributes.refPatterns().stream()
-                      .map(b -> "branch:\\\"" + b + "\\\"")
+                      .map(MigrateLabelFunctionsToSubmitRequirement::toApplicableIfExpression)
                       .collect(Collectors.toList()))));
     }
     return builder.build();
   }
 
+  private static String toApplicableIfExpression(String branchRef) {
+    // Reqex -> migrate as it is.
+    if (branchRef.startsWith("^")) {
+      return "branch:" + branchRef;
+    }
+    // Wildcard -> needs to converted into gerrit regex.
+    if (branchRef.endsWith("/*")) {
+      String prefix = branchRef.substring(0, branchRef.length() - 1);
+      String regex = "^" + Pattern.quote(prefix) + ".*";
+      return "branch:" + regex;
+    }
+    // If branch with " -> need to escape "
+    branchRef = branchRef.replace("\"", "\\\"");
+    // Other cases e.g. branch with # or " -> needs to be quoted
+    return "branch:\"" + branchRef + "\"";
+  }
+
   private static boolean isBlockingOrRequiredLabel(String function) {
     return function.equals("AnyWithBlock")
         || function.equals("MaxWithBlock")
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
index d84ae60..69e6fb3 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -34,7 +34,8 @@
               Schema_182.class,
               Schema_183.class,
               Schema_184.class,
-              Schema_185.class)
+              Schema_185.class,
+              Schema_186.class)
           .collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
 
   public static final int FIRST = ALL.firstKey();
diff --git a/java/com/google/gerrit/server/schema/Schema_186.java b/java/com/google/gerrit/server/schema/Schema_186.java
new file mode 100644
index 0000000..de1e315
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_186.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+public class Schema_186 implements NoteDbSchemaVersion {
+  @Override
+  public void upgrade(NoteDbSchemaVersion.Arguments args, UpdateUI ui) throws Exception {
+    new GrantPostReviewCommentPermission(
+            args.repoManager, args.projectConfigFactory, args.systemGroupBackend, args.serverUser)
+        .execute(args.allProjects);
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 5c3bcc1..188649f 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -50,50 +50,57 @@
           "  administrateServer = group Administrators",
           "  priority = batch group Service Users",
           "  streamEvents = group Service Users");
+
+  private static ImmutableList<String> getAllProjectsAccessSection(
+      String readersGroupName, String usersGroupName) {
+    return ImmutableList.of(
+        "[access \"refs/*\"]",
+        "  read = group Administrators",
+        "  postReviewComment = group " + usersGroupName,
+        "  read = block group Blocked Users",
+        "[access \"refs/for/*\"]",
+        "  addPatchSet = group " + usersGroupName,
+        "  push = group " + usersGroupName,
+        "  pushMerge = group " + usersGroupName,
+        "[access \"refs/heads/*\"]",
+        "  read = group " + readersGroupName,
+        "  revert = group Administrators",
+        "  revert = group Project Owners",
+        "  create = group Administrators",
+        "  create = group Project Owners",
+        "  editTopicName = +force group Administrators",
+        "  editTopicName = +force group Project Owners",
+        "  forgeAuthor = group " + usersGroupName,
+        "  forgeCommitter = group Administrators",
+        "  forgeCommitter = group Project Owners",
+        "  label-Code-Review = -2..+2 group Administrators",
+        "  label-Code-Review = -2..+2 group Project Owners",
+        "  label-Code-Review = -1..+1 group " + usersGroupName,
+        "  submit = group Administrators",
+        "  submit = group Project Owners",
+        "[access \"refs/meta/config\"]",
+        "  exclusiveGroupPermissions = read",
+        "  create = group Administrators",
+        "  create = group Project Owners",
+        "  label-Code-Review = -2..+2 group Administrators",
+        "  label-Code-Review = -2..+2 group Project Owners",
+        "  read = group Administrators",
+        "  read = group Project Owners",
+        "  submit = group Administrators",
+        "  submit = group Project Owners",
+        "[access \"refs/meta/version\"]",
+        "  read = group " + readersGroupName,
+        "[access \"refs/tags/*\"]",
+        "  create = group Administrators",
+        "  create = group Project Owners",
+        "  createSignedTag = group Administrators",
+        "  createSignedTag = group Project Owners",
+        "  createTag = group Administrators",
+        "  createTag = group Project Owners");
+  }
+
   private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_ACCESS_SECTION =
-      ImmutableList.of(
-          "[access \"refs/*\"]",
-          "  read = group Administrators",
-          "  read = block group Blocked Users",
-          "[access \"refs/for/*\"]",
-          "  addPatchSet = group Registered Users",
-          "  push = group Registered Users",
-          "  pushMerge = group Registered Users",
-          "[access \"refs/heads/*\"]",
-          "  read = group Anonymous Users",
-          "  revert = group Administrators",
-          "  revert = group Project Owners",
-          "  create = group Administrators",
-          "  create = group Project Owners",
-          "  editTopicName = +force group Administrators",
-          "  editTopicName = +force group Project Owners",
-          "  forgeAuthor = group Registered Users",
-          "  forgeCommitter = group Administrators",
-          "  forgeCommitter = group Project Owners",
-          "  label-Code-Review = -2..+2 group Administrators",
-          "  label-Code-Review = -2..+2 group Project Owners",
-          "  label-Code-Review = -1..+1 group Registered Users",
-          "  submit = group Administrators",
-          "  submit = group Project Owners",
-          "[access \"refs/meta/config\"]",
-          "  exclusiveGroupPermissions = read",
-          "  create = group Administrators",
-          "  create = group Project Owners",
-          "  label-Code-Review = -2..+2 group Administrators",
-          "  label-Code-Review = -2..+2 group Project Owners",
-          "  read = group Administrators",
-          "  read = group Project Owners",
-          "  submit = group Administrators",
-          "  submit = group Project Owners",
-          "[access \"refs/meta/version\"]",
-          "  read = group Anonymous Users",
-          "[access \"refs/tags/*\"]",
-          "  create = group Administrators",
-          "  create = group Project Owners",
-          "  createSignedTag = group Administrators",
-          "  createSignedTag = group Project Owners",
-          "  createTag = group Administrators",
-          "  createTag = group Project Owners");
+      getAllProjectsAccessSection("Anonymous Users", "Registered Users");
   private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_LABEL_SECTION =
       ImmutableList.of(
           "[label \"Code-Review\"]",
@@ -135,6 +142,34 @@
         .collect(Collectors.joining("\n"));
   }
 
+  public static String getAllProjectsWithCustomDefaultReadersAcls(String groupName) {
+    return Streams.stream(
+            Iterables.concat(
+                DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+                DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+                getAllProjectsAccessSection(groupName, "Registered Users"),
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_CODE_REVIEW_SUBMIT_REQUIREMENT_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
+        .collect(Collectors.joining("\n"));
+  }
+
+  public static String getAllProjectsWithCustomDefaultUsersAcls(String groupName) {
+    return Streams.stream(
+            Iterables.concat(
+                DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+                DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+                DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+                getAllProjectsAccessSection("Anonymous Users", groupName),
+                DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+                DEFAULT_ALL_PROJECTS_CODE_REVIEW_SUBMIT_REQUIREMENT_SECTION,
+                DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
+        .collect(Collectors.joining("\n"));
+  }
+
   public static String getAllProjectsWithoutDefaultAcls() {
     return Streams.stream(
             Iterables.concat(
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 0a45852..3c0cd00 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,9 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.RetryableAction.ActionType.INDEX_QUERY;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
@@ -441,7 +438,7 @@
                   String.format(
                       "submit requirement '%s' has an error: %s",
                       srResult.submitRequirement().name(), srResult.errorMessage().orElse("")));
-          case UNSATISFIED ->
+          case UNSATISFIED, TIMEOUT ->
               throw new ResourceConflictException(
                   String.format(
                       "submit requirement '%s' is unsatisfied.",
@@ -1166,47 +1163,22 @@
       // The branch doesn't exist.
       return;
     }
-    Project.NameKey project = branch.project();
-    if (!experimentFeatures.isFeatureEnabled(
-        GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE, project)) {
-      return;
-    }
     if (submitType == SubmitType.CHERRY_PICK || submitType == SubmitType.REBASE_ALWAYS) {
       return;
     }
 
-    boolean projectConfigRejectImplicitMerges =
+    Project.NameKey project = branch.project();
+    boolean rejectImplicitMerges =
         projectCache
             .get(project)
             .orElseThrow(illegalState(project))
             .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES);
-    boolean rejectImplicitMergesOnMerges =
-        experimentFeatures.isFeatureEnabled(
-                GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE, project)
-            && (experimentFeatures.isFeatureEnabled(
-                    GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE, project)
-                || projectConfigRejectImplicitMerges);
-    try {
-      if (hasImplicitMerges(branch, rw, commitsToSubmit, branchTip)) {
-        if (rejectImplicitMergesOnMerges) {
-          commitStatus.addImplicitMerge(project, branch);
-        } else {
-          String allCommits =
-              commitsToSubmit.stream()
-                  .map(CodeReviewCommit::getId)
-                  .map(c -> ObjectId.toString(c))
-                  .collect(joining(", "));
-          logger.atWarning().log(
-              "Implicit merge was detected for the branch %s of the project %s. "
-                  + "Commits to be merged are: %s",
-              branch.shortName(), project, allCommits);
-        }
-      }
-    } catch (Exception e) {
-      if (rejectImplicitMergesOnMerges) {
-        throw e;
-      }
-      logger.atWarning().withCause(e).log("Error while checking for implicit merges");
+    if (!rejectImplicitMerges) {
+      return;
+    }
+
+    if (hasImplicitMerges(branch, rw, commitsToSubmit, branchTip)) {
+      commitStatus.addImplicitMerge(project, branch);
     }
   }
 
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 76bf5ef..d5303e7 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.PendingIndexUpdate;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
@@ -560,10 +561,11 @@
     }
   }
 
-  // For upstream implementation, AccessPath.WEB_BROWSER is never set, so the method will always
-  // return false.
+  // Asynchronous change indexing is performed for WEB_BROWSER requests or for non-service
+  // users (such as interactive git pushes) when enabled in gerrit.config.
+  @VisibleForTesting
   @UsedAt(GOOGLE)
-  private boolean indexAsync() {
+  boolean indexAsync() {
     if (!gerritConfig.getBoolean("index", "indexChangesAsync", false)) {
       return false;
     }
@@ -668,6 +670,23 @@
       }
       return indexFutures.build();
     }
+
+    void writeIndexIntents(PendingIndexUpdate pendingIndexUpdate, long threadId)
+        throws IOException {
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        if (e.getValue() == ChangeResult.SKIPPED) {
+          continue;
+        }
+        pendingIndexUpdate.write(
+            threadId, project, e.getKey(), e.getValue() == ChangeResult.DELETED);
+      }
+    }
+
+    void deleteIndexIntents(PendingIndexUpdate pendingIndexUpdate, long threadId) {
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        pendingIndexUpdate.delete(threadId, project, e.getKey());
+      }
+    }
   }
 
   ChangesHandle executeChangeOps(
diff --git a/java/com/google/gerrit/server/update/BatchUpdates.java b/java/com/google/gerrit/server/update/BatchUpdates.java
index aa727f1..a4060bc 100644
--- a/java/com/google/gerrit/server/update/BatchUpdates.java
+++ b/java/com/google/gerrit/server/update/BatchUpdates.java
@@ -31,6 +31,7 @@
 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.server.index.change.PendingIndexUpdate;
 import com.google.gerrit.server.notedb.LimitExceededException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -83,10 +84,12 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
+  private final PendingIndexUpdate pendingIndexUpdate;
 
   @Inject
-  BatchUpdates(ChangeData.Factory changeDataFactory) {
+  BatchUpdates(ChangeData.Factory changeDataFactory, PendingIndexUpdate pendingIndexUpdate) {
     this.changeDataFactory = changeDataFactory;
+    this.pendingIndexUpdate = pendingIndexUpdate;
   }
 
   @CanIgnoreReturnValue
@@ -100,9 +103,10 @@
 
     checkDifferentProject(updates);
 
+    List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
+    List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
+    long threadId = Thread.currentThread().threadId();
     try {
-      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
-      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
       try {
         for (BatchUpdate u : updates) {
           u.executeUpdateRepo();
@@ -111,6 +115,11 @@
         for (BatchUpdate u : updates) {
           changesHandles.add(u.executeChangeOps(listeners, dryrun));
         }
+        if (!dryrun && pendingIndexUpdate.isEnabled()) {
+          for (ChangesHandle h : changesHandles) {
+            h.writeIndexIntents(pendingIndexUpdate, threadId);
+          }
+        }
         for (ChangesHandle h : changesHandles) {
           h.execute();
           if (h.requiresReindex()) {
@@ -137,6 +146,12 @@
       updates.forEach(BatchUpdate::fireRefChangeEvents);
 
       if (!dryrun) {
+        if (pendingIndexUpdate.isEnabled()) {
+          for (ChangesHandle h : changesHandles) {
+            h.deleteIndexIntents(pendingIndexUpdate, threadId);
+          }
+        }
+
         for (BatchUpdate u : updates) {
           u.executePostOps(changeDatas);
         }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index 813bee9..fad29a7 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,12 +21,12 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Provides;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
-import javax.inject.Inject;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Update superprojects after submission is done */
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index 6c031c6..b6b1af9 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -20,8 +20,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
+import com.google.gerrit.util.crypto.SecureRandomUtil;
 import java.util.Optional;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -32,18 +31,9 @@
 
 /** Utility functions to manipulate commit messages. */
 public class CommitMessageUtil {
-  private static final SecureRandom rng;
   private static final Pattern changeIdFooterPattern =
       Pattern.compile("Change-Id: *(I[a-f0-9]{40})");
 
-  static {
-    try {
-      rng = SecureRandom.getInstance("SHA1PRNG");
-    } catch (NoSuchAlgorithmException e) {
-      throw new IllegalStateException("Cannot create RNG for Change-Id generator", e);
-    }
-  }
-
   private CommitMessageUtil() {}
 
   /**
@@ -67,8 +57,7 @@
   }
 
   public static ObjectId generateChangeId() {
-    byte[] rand = new byte[Constants.OBJECT_ID_STRING_LENGTH];
-    rng.nextBytes(rand);
+    byte[] rand = SecureRandomUtil.newBytes(Constants.OBJECT_ID_STRING_LENGTH);
     String randomString = new String(rand, UTF_8);
 
     try (ObjectInserter f = new ObjectInserter.Formatter()) {
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index f20acb8..d0204b1 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Throwables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.common.Nullable;
@@ -111,6 +112,9 @@
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
 
+  /** Channel this command runs on; set when sshd destroys the command. */
+  private volatile ChannelSession channel;
+
   /** Text of the command line which lead up to invoking this instance. */
   private String commandName = "";
 
@@ -199,6 +203,7 @@
 
   @Override
   public void destroy(ChannelSession channel) {
+    this.channel = channel;
     Future<?> future = task.getAndSet(null);
     if (future != null && !future.isDone()) {
       future.cancel(true);
@@ -353,11 +358,14 @@
         || //
         (e.getClass() == SshException.class && "Already closed".equals(e.getMessage()))
         || //
-        e.getClass() == InterruptedIOException.class) {
+        e.getClass() == InterruptedIOException.class
+        || //
+        isInterruptAfterClientDisconnect(e)) {
       // This is sshd telling us the client just dropped off while
       // we were waiting for a read or a write to complete. Either
       // way its not really a fatal error. Don't log it.
       //
+      logger.atFine().withCause(e).log("Client disconnected during %s", context.getCommandLine());
       return 127;
     }
 
@@ -401,6 +409,23 @@
     return 128;
   }
 
+  /**
+   * Returns true if this exception is the thread interrupt we sent from {@link
+   * #destroy(ChannelSession)} after the client disconnected.
+   *
+   * <p>Requires both that the channel is gone and that the interrupt is in the causal chain.
+   * Libraries wrap {@link InterruptedException} in unchecked exceptions, so the interrupt is rarely
+   * the top-level throwable. Checking the channel alone would suppress unrelated failures that
+   * merely happened to surface after a disconnect.
+   */
+  private boolean isInterruptAfterClientDisconnect(Throwable e) {
+    ChannelSession c = channel;
+    if (c == null || c.isOpen()) {
+      return false;
+    }
+    return Throwables.getCausalChain(e).stream().anyMatch(t -> t instanceof InterruptedException);
+  }
+
   private void logCauseIfRelevant(Throwable e, StringBuilder message) {
     String zeroLength = "length=0";
     String streamAlreadyClosed = "stream is already closed";
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index c7a03c4..4c027b2 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -54,7 +54,7 @@
     try {
       @SuppressWarnings("unused")
       var unused = index.apply(new ProjectResource(projectState, user), null);
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       writeError(
           "error", String.format("Unable to index %s: %s", projectState.getName(), e.getMessage()));
     }
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 95f771c..911fe4f 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -374,7 +374,7 @@
     ProjectState allProjectsState;
     try {
       allProjectsState = projectCache.getAllProjects();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw die("missing " + allProjects.get(), e);
     }
 
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 7a958b7..bf7c154 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -45,8 +45,13 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.mina.MinaSession;
@@ -79,6 +84,17 @@
   @Option(name = "--show-threads", usage = "show detailed thread counts")
   private boolean showThreads;
 
+  @Option(
+      name = "--include-diskstats",
+      usage = "include disk stat collection for persistent caches")
+  private boolean includeDiskStats;
+
+  @Option(
+      name = "--cache",
+      usage = "show the named cache; may be supplied more than once",
+      metaVar = "NAME")
+  private List<String> caches = new ArrayList<>();
+
   @Inject private SshDaemon daemon;
   @Inject private ListCaches listCaches;
   @Inject private GetSummary getSummary;
@@ -153,14 +169,21 @@
   }
 
   private Collection<CacheInfo> getCaches() {
-    @SuppressWarnings("unchecked")
-    Map<String, CacheInfo> caches =
-        (Map<String, CacheInfo>) listCaches.apply(new ConfigResource()).value();
-    for (Map.Entry<String, CacheInfo> entry : caches.entrySet()) {
+    Map<String, CacheInfo> selected;
+    if (caches.isEmpty()) {
+      selected = listCaches.getCacheInfos(name -> true, includeDiskStats);
+    } else {
+      Set<String> filter =
+          caches.stream().map(n -> n.toLowerCase(Locale.US)).collect(Collectors.toSet());
+      selected =
+          listCaches.getCacheInfos(
+              n -> filter.contains(n.toLowerCase(Locale.US)), includeDiskStats);
+    }
+    for (Map.Entry<String, CacheInfo> entry : selected.entrySet()) {
       CacheInfo cache = entry.getValue();
       cache.name = entry.getKey();
     }
-    return caches.values();
+    return selected.values();
   }
 
   private void memSummary(MemSummaryInfo memSummary) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 1c44776..7f8e719 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.util.cli.Options;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.time.Instant;
@@ -50,7 +51,7 @@
     name = "show-queue",
     description = "Display the background work queues",
     runsAt = MASTER_OR_SLAVE)
-final class ShowQueue extends SshCommand {
+public final class ShowQueue extends SshCommand {
   @Option(
       name = "--wide",
       aliases = {"-w"},
@@ -70,7 +71,9 @@
   private boolean groupByQueue;
 
   @Inject private PermissionBackend permissionBackend;
-  @Inject private ListTasks listTasks;
+
+  @Inject @Options public ListTasks listTasks;
+
   @Inject private IdentifiedUser currentUser;
   @Inject private WorkQueue workQueue;
 
@@ -108,7 +111,7 @@
       throw die(e);
     } catch (PermissionBackendException e) {
       throw new Failure(1, "permission backend unavailable", e);
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       throw new Failure(1, "unavailable", e);
     }
 
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index c47d24c..7cccfed 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -270,7 +270,7 @@
     String msg = null;
     try {
       msg = gson.toJson(message) + "\n";
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
       logger.atWarning().withCause(e).log("Could not deserialize the msg");
     }
     if (msg != null) {
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 745f89a..c37dce9 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -190,6 +190,11 @@
 
   @Override
   protected void configure() {
+    configure(true);
+  }
+
+  protected void configure(boolean bindGerritApi) {
+
     // Do NOT bind @RemotePeer, as it is bound in a child injector of
     // ChangeMergeQueue (bound via GerritGlobalModule below), so there cannot be
     // a binding in the parent injector. If you need @RemotePeer, you must bind
@@ -226,7 +231,11 @@
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
-    install(new GerritApiModule());
+
+    if (bindGerritApi) {
+      install(new GerritApiModule());
+    }
+
     install(new ProjectQueryBuilderModule());
     install(new DefaultRefLogIdentityProvider.Module());
     factory(PluginUser.Factory.class);
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index f62aea90..70954a9 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -1,4 +1,4 @@
-load("@rules_java//java:defs.bzl", "java_library")
+load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 
 java_library(
     name = "cli",
diff --git a/java/com/google/gerrit/util/crypto/AesGcmCipher.java b/java/com/google/gerrit/util/crypto/AesGcmCipher.java
new file mode 100644
index 0000000..1db2d43
--- /dev/null
+++ b/java/com/google/gerrit/util/crypto/AesGcmCipher.java
@@ -0,0 +1,163 @@
+// Copyright (C) 2026 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.util.crypto;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
+import org.bouncycastle.crypto.params.HKDFParameters;
+
+/**
+ * Authenticated encryption of short strings with AES-256-GCM.
+ *
+ * <p>The supplied key material is expanded into a dedicated 256-bit AES key with HKDF-SHA256 (RFC
+ * 5869) using BouncyCastle, domain-separated by a caller-provided {@code info} label; the input key
+ * bytes are zeroed after derivation. Each value is sealed as {@code "gcm:v1:" + Base64(iv ||
+ * ciphertext || tag)} with a fresh 96-bit IV, and the caller supplies opaque additional
+ * authenticated data (AAD) that is bound into the tag.
+ */
+public final class AesGcmCipher {
+  private static final String PREFIX = "gcm:v1:";
+  private static final String TRANSFORMATION = "AES/GCM/NoPadding";
+
+  private static final int IV_BYTES = 12;
+  private static final int TAG_BITS = 128;
+  private static final int DERIVED_KEY_BYTES = 32;
+  private static final ThreadLocal<Cipher> CIPHERS =
+      ThreadLocal.withInitial(AesGcmCipher::newCipher);
+
+  private final SecretKey derivedKey;
+
+  /**
+   * Derives a dedicated 256-bit AES key from the given key material via HKDF, then wipes the input.
+   *
+   * @param masterKeyBytes raw key material (at least 128 bits); zeroed after derivation
+   * @param info HKDF domain-separation label
+   */
+  public AesGcmCipher(byte[] masterKeyBytes, byte[] info) {
+    if (masterKeyBytes == null || masterKeyBytes.length < 16) {
+      throw new IllegalArgumentException("Master key material must be at least 128 bits.");
+    }
+    try {
+      byte[] keyBytes = hkdfSha256(masterKeyBytes, info, DERIVED_KEY_BYTES);
+      try {
+        this.derivedKey = new SecretKeySpec(keyBytes, "AES");
+      } finally {
+        Arrays.fill(keyBytes, (byte) 0);
+      }
+    } finally {
+      Arrays.fill(masterKeyBytes, (byte) 0);
+    }
+  }
+
+  /** Returns whether {@code value} is a sealed payload produced by {@link #seal}. */
+  public boolean isSealed(String value) {
+    return value != null && value.startsWith(PREFIX);
+  }
+
+  /**
+   * Seals {@code plaintext} under {@code aad}.
+   *
+   * @return {@code "gcm:v1:" + Base64(iv || ciphertext || tag)}
+   */
+  public String seal(byte[] aad, String plaintext) {
+    try {
+      byte[] iv = new byte[IV_BYTES];
+      Holder.RANDOM.nextBytes(iv);
+
+      Cipher cipher = CIPHERS.get();
+      cipher.init(Cipher.ENCRYPT_MODE, derivedKey, new GCMParameterSpec(TAG_BITS, iv));
+      cipher.updateAAD(aad);
+
+      byte[] pt = plaintext.getBytes(UTF_8);
+      byte[] ct;
+      try {
+        ct = cipher.doFinal(pt);
+      } finally {
+        Arrays.fill(pt, (byte) 0);
+      }
+
+      byte[] out = new byte[IV_BYTES + ct.length];
+      System.arraycopy(iv, 0, out, 0, IV_BYTES);
+      System.arraycopy(ct, 0, out, IV_BYTES, ct.length);
+      return PREFIX + Base64.getEncoder().encodeToString(out);
+    } catch (GeneralSecurityException e) {
+      throw new IllegalStateException("AES-GCM encryption failed", e);
+    }
+  }
+
+  /**
+   * Opens a payload produced by {@link #seal} under the same {@code aad}.
+   *
+   * @throws IllegalStateException if the payload is corrupt, tampered, or sealed under a different
+   *     key or AAD
+   */
+  public String open(byte[] aad, String sealed) {
+    if (!isSealed(sealed)) {
+      throw new IllegalStateException("AES-GCM decryption failed (missing prefix)");
+    }
+    try {
+      byte[] all = Base64.getDecoder().decode(sealed.substring(PREFIX.length()));
+      if (all.length < IV_BYTES + (TAG_BITS / 8)) {
+        throw new IllegalArgumentException("Truncated or corrupted ciphertext payload.");
+      }
+      Cipher cipher = CIPHERS.get();
+      cipher.init(
+          Cipher.DECRYPT_MODE, derivedKey, new GCMParameterSpec(TAG_BITS, all, 0, IV_BYTES));
+      cipher.updateAAD(aad);
+
+      byte[] pt = cipher.doFinal(all, IV_BYTES, all.length - IV_BYTES);
+      try {
+        return new String(pt, UTF_8);
+      } finally {
+        Arrays.fill(pt, (byte) 0);
+      }
+    } catch (IllegalArgumentException e) {
+      throw new IllegalStateException("AES-GCM decryption failed (corrupt payload)", e);
+    } catch (GeneralSecurityException e) {
+      throw new IllegalStateException("AES-GCM decryption failed (wrong key or tampered)", e);
+    }
+  }
+
+  /** HKDF-SHA256 using BouncyCastle HKDFBytesGenerator. */
+  private static byte[] hkdfSha256(byte[] ikm, byte[] info, int length) {
+    HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest());
+    hkdf.init(new HKDFParameters(ikm, null, info));
+    byte[] okm = new byte[length];
+    hkdf.generateBytes(okm, 0, length);
+    return okm;
+  }
+
+  private static Cipher newCipher() {
+    try {
+      return Cipher.getInstance(TRANSFORMATION);
+    } catch (GeneralSecurityException e) {
+      throw new IllegalStateException("Failed to initialize AES-GCM cipher", e);
+    }
+  }
+
+  private static class Holder {
+    private static final SecureRandom RANDOM = new SecureRandom();
+  }
+}
diff --git a/java/com/google/gerrit/util/crypto/BUILD b/java/com/google/gerrit/util/crypto/BUILD
new file mode 100644
index 0000000..9f28cb6
--- /dev/null
+++ b/java/com/google/gerrit/util/crypto/BUILD
@@ -0,0 +1,11 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "crypto",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib/bouncycastle:bcprov-neverlink",
+    ],
+)
diff --git a/java/com/google/gerrit/util/crypto/SecureRandomUtil.java b/java/com/google/gerrit/util/crypto/SecureRandomUtil.java
new file mode 100644
index 0000000..10c206c
--- /dev/null
+++ b/java/com/google/gerrit/util/crypto/SecureRandomUtil.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2026 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.util.crypto;
+
+import com.google.common.io.BaseEncoding;
+import java.security.SecureRandom;
+
+/**
+ * Central source of cryptographically strong randomness for Gerrit.
+ *
+ * <p>Exposes a single shared {@link SecureRandom}. On the JDK's SUN provider every {@code
+ * SecureRandom} algorithm is registered with the {@code ThreadSafe=true} service attribute, so the
+ * shared instance is safe for concurrent use without external locking. On Unix-like platforms
+ * {@code new SecureRandom()} resolves to {@code NativePRNG} (the kernel CSPRNG via {@code
+ * /dev/urandom}); on platforms without native support it resolves to {@code DRBG}.
+ *
+ * <p>Prefer these helpers over {@code new SecureRandom()} and over explicitly requesting a named
+ * algorithm, so the algorithm choice lives in exactly one place and no caller is pinned to a
+ * specific, possibly legacy, generator.
+ */
+public final class SecureRandomUtil {
+  private static final SecureRandom RANDOM = new SecureRandom();
+
+  /** Returns {@code numBytes} cryptographically strong random bytes. */
+  public static byte[] newBytes(int numBytes) {
+    byte[] bytes = new byte[numBytes];
+    RANDOM.nextBytes(bytes);
+    return bytes;
+  }
+
+  /**
+   * Returns a URL- and cookie-safe random string carrying {@code numBytes} of entropy, encoded as
+   * unpadded base64url. The result is longer than {@code numBytes} (roughly {@code ceil(numBytes *
+   * 4 / 3)} characters).
+   *
+   * <p>Do not use this for values that are persisted and later decoded with a fixed codec (password
+   * salts, stored signing keys, session cookies): those must keep their existing encoding. Use
+   * {@link #newBytes(int)} there and encode at the call site.
+   */
+  public static String newRandomString(int numBytes) {
+    return BaseEncoding.base64Url().omitPadding().encode(newBytes(numBytes));
+  }
+
+  /**
+   * Returns a random string carrying <b>256 bits (32 bytes)</b> of entropy, encoded as unpadded
+   * base64url (about 43 characters).
+   *
+   * <p>256 bits is the conventional strength for an unguessable token or nonce (CSRF/OAuth state,
+   * session identifiers): it matches the 256-bit primitives already in use, and because brute-force
+   * guessing is infeasible well below that, it is a comfortable conservative choice — larger sizes
+   * add string length without added practical security, and 128 bits is the floor.
+   *
+   * <p>Sugar for {@link #newRandomString(int) newRandomString(32)} so callers don't repeat the
+   * literal; the {@code 32} in the name is the entropy in bytes, not the resulting string length.
+   */
+  public static String newRandomString32() {
+    return newRandomString(32);
+  }
+
+  private SecureRandomUtil() {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AbstractAccountIT.java
similarity index 80%
rename from javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
rename to javatests/com/google/gerrit/acceptance/api/accounts/AbstractAccountIT.java
index 5d17d39..1f14880 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AbstractAccountIT.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
@@ -32,8 +31,6 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
-import static com.google.gerrit.server.account.AccountProperties.ACCOUNT;
-import static com.google.gerrit.server.account.AccountProperties.ACCOUNT_CONFIG;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
@@ -41,13 +38,8 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
-import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
-import static java.util.concurrent.TimeUnit.SECONDS;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -112,13 +104,12 @@
 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.CheckAccountsInput;
-import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AccountStateInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.MetadataInfo;
@@ -138,12 +129,10 @@
 import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountStateProvider;
 import com.google.gerrit.server.account.AccountsUpdate;
@@ -155,10 +144,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
-import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
-import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
-import com.google.gerrit.server.account.storage.notedb.AccountsUpdateNoteDbImpl;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.RegexAllowedGroupsProvider;
@@ -166,7 +151,6 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RegexPermissionPolicy;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -175,7 +159,6 @@
 import com.google.gerrit.server.restapi.account.GetCapabilities;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryListener;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
@@ -200,11 +183,9 @@
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Collectors;
 import java.util.stream.StreamSupport;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.http.HttpResponse;
-import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.methods.HttpGet;
 import org.apache.http.impl.client.BasicCookieStore;
 import org.apache.http.impl.client.CloseableHttpClient;
@@ -217,26 +198,16 @@
 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.CommitBuilder;
 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.PersonIdent;
 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.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-public class AccountIT extends AbstractDaemonTest {
+public abstract class AbstractAccountIT extends AbstractDaemonTest {
   @ConfigSuite.Default
   public static Config enableSignedPushConfig() {
     Config cfg = new Config();
@@ -256,23 +227,20 @@
 
   @Inject protected GroupOperations groupOperations;
 
-  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject @ServerInitiated protected Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AccountIndexer accountIndexer;
-  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
-  @Inject private ExternalIdsNoteDbImpl externalIdsNoteDbImpl;
-  @Inject private GitReferenceUpdated gitReferenceUpdated;
-  @Inject private Provider<InternalAccountQuery> accountQueryProvider;
-  @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+  @Inject protected ExternalIds externalIds;
+  @Inject protected Provider<InternalAccountQuery> accountQueryProvider;
+  @Inject protected Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
   @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
   @Inject private RetryHelper.Metrics retryMetrics;
-  @Inject private Sequences seq;
-  @Inject private StalenessChecker stalenessChecker;
+  @Inject protected Sequences seq;
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
   @Inject private PluginSetContext<RetryListener> retryListeners;
-  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
-  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactoryNoteDbImpl;
-  @Inject private AuthConfig authConfig;
+  @Inject protected ExternalIdKeyFactory externalIdKeyFactory;
+  @Inject protected ExternalIdFactory externalIdFactory;
+  @Inject protected AuthConfig authConfig;
   @Inject private AccountControl.Factory accountControlFactory;
   @Inject private AccountOperations accountOperations;
   @Inject private AccountLimits.Factory limitsFactory;
@@ -302,7 +270,7 @@
       if (repo.getRefDatabase().exactRef(ref) != null) {
         RefUpdate ru = repo.updateRef(ref);
         ru.setForceUpdate(true);
-        assertWithMessage("Failed to delete " + ref)
+        assertWithMessage("Failed to delete %s", ref)
             .that(ru.delete())
             .isEqualTo(RefUpdate.Result.FORCED);
       }
@@ -343,18 +311,6 @@
   }
 
   @Test
-  public void createByAccountCreator() throws Exception {
-    RefUpdateCounter refUpdateCounter = createRefUpdateCounter();
-    try (Registration registration = extensionRegistry.newRegistration().add(refUpdateCounter)) {
-      Account.Id accountId = createByAccountCreator(1);
-      refUpdateCounter.assertRefUpdateFor(
-          RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
-          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
-          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS));
-    }
-  }
-
-  @Test
   public void createWithInvalidEmailAddress() throws Exception {
     AccountInput input = new AccountInput();
     input.username = name("test");
@@ -374,37 +330,6 @@
     return new AccountIndexedCounter();
   }
 
-  @UsedAt(UsedAt.Project.GOOGLE)
-  protected Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
-    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      String name = "foo";
-      TestAccount foo = accountCreator.create(name);
-      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
-      if (server.isUsernameSupported()) {
-        assertThat(info.username).isEqualTo(name);
-      } else {
-        assertThat(info.email).isEqualTo(foo.email());
-      }
-      assertThat(info.name).isEqualTo(name);
-      accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
-      assertUserBranch(foo.id(), name, null);
-      return foo.id();
-    }
-  }
-
-  @Test
-  public void createAnonymousCowardByAccountCreator() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount anonymousCoward = accountCreator.create();
-      accountIndexedCounter.assertReindexOf(anonymousCoward);
-      assertUserBranchWithoutAccountConfig(anonymousCoward.id());
-    }
-  }
-
   @Test
   public void create() throws Exception {
     AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
@@ -455,56 +380,6 @@
   }
 
   @Test
-  public void commitMessageOnAccountUpdates() throws Exception {
-    AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = Account.id(seq.nextAccountId());
-    au.insert("Create Test Account", accountId, u -> {});
-    assertLastCommitMessageOfUserBranch(accountId, "Create Test Account");
-
-    au.update("Set Status", accountId, u -> u.setStatus("Foo"));
-    assertLastCommitMessageOfUserBranch(accountId, "Set Status");
-  }
-
-  private void assertLastCommitMessageOfUserBranch(Account.Id accountId, String expectedMessage)
-      throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      Ref exactRef = repo.exactRef(RefNames.refsUsers(accountId));
-      assertThat(rw.parseCommit(exactRef.getObjectId()).getShortMessage())
-          .isEqualTo(expectedMessage);
-    }
-  }
-
-  @Test
-  @UseClockStep
-  public void createAtomically() throws Exception {
-    Account.Id accountId = Account.id(seq.nextAccountId());
-    String fullName = "Foo";
-    ExternalId extId = getExternalIdFactory().createEmail(accountId, "foo@example.com");
-    AccountState accountState =
-        accountsUpdateProvider
-            .get()
-            .insert(
-                "Create Account Atomically",
-                accountId,
-                u -> u.setFullName(fullName).addExternalId(extId));
-    assertThat(accountState.account().fullName()).isEqualTo(fullName);
-
-    AccountInfo info = gApi.accounts().id(accountId.get()).get();
-    assertThat(info.name).isEqualTo(fullName);
-
-    List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
-    assertThat(emails.stream().map(e -> e.email).collect(toSet())).containsExactly(extId.email());
-
-    RevCommit commitUserBranch =
-        projectOperations.project(allUsers).getHead(RefNames.refsUsers(accountId));
-    RevCommit commitRefsMetaExternalIds =
-        projectOperations.project(allUsers).getHead(RefNames.REFS_EXTERNAL_IDS);
-    assertThat(commitUserBranch.getCommitTime())
-        .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
-  }
-
-  @Test
   public void updateNonExistingAccount() throws Exception {
     Account.Id nonExistingAccountId = Account.id(999999);
     AtomicBoolean consumerCalled = new AtomicBoolean();
@@ -518,55 +393,16 @@
   }
 
   @Test
-  public void updateAccountWithoutAccountConfigNoteDb() throws Exception {
-    TestAccount anonymousCoward = accountCreator.create();
-    assertUserBranchWithoutAccountConfig(anonymousCoward.id());
+  public void randomNIds() throws Exception {
+    accountOperations.newAccount().create();
+    accountOperations.newAccount().create();
+    accountOperations.newAccount().create();
 
-    String status = "OOO";
-    Optional<AccountState> accountState =
-        accountsUpdateProvider
-            .get()
-            .update("Set status", anonymousCoward.id(), u -> u.setStatus(status));
-    assertThat(accountState).isPresent();
-    Account account = accountState.get().account();
-    assertThat(account.fullName()).isNull();
-    assertThat(account.status()).isEqualTo(status);
-    assertUserBranch(anonymousCoward.id(), null, status);
-  }
+    ImmutableList<Account.Id> result = accounts.randomNIds(2, 12345L);
 
-  private void assertUserBranchWithoutAccountConfig(Account.Id accountId) throws Exception {
-    assertUserBranch(accountId, null, null);
-  }
-
-  private void assertUserBranch(
-      Account.Id accountId, @Nullable String name, @Nullable String status) throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo);
-        ObjectReader or = repo.newObjectReader()) {
-      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
-      assertThat(ref).isNotNull();
-      RevCommit c = rw.parseCommit(ref.getObjectId());
-      long timestampDiffMs =
-          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().toEpochMilli());
-      assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
-
-      // Check the 'account.config' file.
-      try (TreeWalk tw = TreeWalk.forPath(or, ACCOUNT_CONFIG, c.getTree())) {
-        if (name != null || status != null) {
-          assertThat(tw).isNotNull();
-          Config cfg = new Config();
-          cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
-          assertThat(cfg)
-              .stringValue(ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
-              .isEqualTo(name);
-          assertThat(cfg)
-              .stringValue(ACCOUNT, null, AccountProperties.KEY_STATUS)
-              .isEqualTo(status);
-        } else {
-          // No account properties were set, hence an 'account.config' file was not created.
-          assertThat(tw).isNull();
-        }
-      }
+    assertThat(result).hasSize(2);
+    for (Account.Id id : result) {
+      assertThat(gApi.accounts().id(id.get()).get()).isNotNull();
     }
   }
 
@@ -1153,7 +989,7 @@
     assertThat(
             gApi.accounts().id(foo.id().get()).getEmails().stream()
                 .map(e -> e.email)
-                .collect(toSet()))
+                .collect(toImmutableSet()))
         .containsExactly(email, secondaryEmail);
   }
 
@@ -1175,7 +1011,7 @@
     assertThat(
             gApi.accounts().id(foo.id().get()).getEmails().stream()
                 .map(e -> e.email)
-                .collect(toSet()))
+                .collect(toImmutableSet()))
         .containsExactly(email, secondaryEmail);
   }
 
@@ -1421,7 +1257,7 @@
       assertThat(
               gApi.accounts().self().getExternalIds().stream()
                   .map(e -> e.identity)
-                  .collect(toSet()))
+                  .collect(toImmutableSet()))
           .containsAtLeast(extId1, extId2);
 
       requestScopeOperations.resetCurrentApiUser();
@@ -1435,7 +1271,7 @@
       assertThat(
               gApi.accounts().self().getExternalIds().stream()
                   .map(e -> e.identity)
-                  .collect(toSet()))
+                  .collect(toImmutableSet()))
           .containsNoneOf(extId1, extId2);
     }
   }
@@ -1474,7 +1310,9 @@
     requestScopeOperations.resetCurrentApiUser();
     assertThat(getEmails()).contains(ldapEmail);
     assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+            gApi.accounts().self().getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
         .contains(ldapExternalId);
   }
 
@@ -1504,7 +1342,9 @@
                                 admin.id(),
                                 ldapEmail)));
     assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+            gApi.accounts().self().getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
         .containsAtLeast(ldapExternalId, nonLdapExternalId);
 
     requestScopeOperations.resetCurrentApiUser();
@@ -1515,7 +1355,9 @@
     requestScopeOperations.resetCurrentApiUser();
     assertThat(getExtIdsEmail()).doesNotContain(nonLdapEMail);
     assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+            gApi.accounts().self().getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
         .contains(ldapExternalId);
   }
 
@@ -1733,98 +1575,20 @@
   public void refsUsersSelfIsAdvertised() throws Exception {
     TestRepository<?> testRepository = cloneProject(allUsers, user);
     try (Git git = testRepository.git()) {
-      List<String> advertisedRefs =
-          git.lsRemote().call().stream().map(Ref::getName).collect(toList());
+      ImmutableList<String> advertisedRefs =
+          git.lsRemote().call().stream().map(Ref::getName).collect(toImmutableList());
       assertThat(advertisedRefs).contains(RefNames.REFS_USERS_SELF);
     }
   }
 
   @Test
-  public void createDefaultUserBranch() throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
-    }
-
-    projectOperations
-        .project(allUsers)
-        .forUpdate()
-        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
-        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
-        .update();
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    pushFactory
-        .create(admin.newIdent(), allUsersRepo)
-        .to(RefNames.REFS_USERS_DEFAULT)
-        .assertOkStatus();
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNotNull();
-    }
-  }
-
-  @Test
-  public void cannotDeleteUserBranch() throws Exception {
-    projectOperations
-        .project(allUsers)
-        .forUpdate()
-        .add(
-            allow(Permission.DELETE)
-                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
-                .group(REGISTERED_USERS)
-                .force(true))
-        .update();
-
-    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
-  public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
-    projectOperations
-        .allProjectsForUpdate()
-        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
-        .update();
-    projectOperations
-        .project(allUsers)
-        .forUpdate()
-        .add(
-            allow(Permission.DELETE)
-                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
-                .group(REGISTERED_USERS)
-                .force(true))
-        .update();
-
-    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();
-    }
-
-    assertThat(accountCache.get(admin.id())).isEmpty();
-    assertThat(accountQueryProvider.get().byDefault(admin.id().toString(), true)).isEmpty();
-  }
-
-  @Test
   public void addGpgKey() throws Exception {
     TestKey key = validKeyWithoutExpiration();
     String id = key.getKeyIdString();
     addExternalIdEmail(admin, "test1@example.com");
 
     sender.clear();
-    assertKeyMapContains(key, addGpgKey(key.getPublicKeyArmored()));
+    assertKeyMapContains(key, addGpgKeyForSelf(key.getPublicKeyArmored()));
     assertKeys(key);
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
@@ -1843,7 +1607,9 @@
 
     sender.clear();
     requestScopeOperations.setApiUser(admin.id());
-    assertThrows(ResourceNotFoundException.class, () -> addGpgKey(user, key.getPublicKeyArmored()));
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> addGpgKeyForAccount(user, key.getPublicKeyArmored()));
   }
 
   @Test
@@ -1854,7 +1620,7 @@
     PGPPublicKey pk = key.getPublicKey();
 
     sender.clear();
-    GpgKeyInfo info = addGpgKey(armor(pk)).get(id);
+    GpgKeyInfo info = addGpgKeyForSelf(armor(pk)).get(id);
     assertThat(info.userIds).hasSize(2);
     assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
     assertThat(sender.getMessages()).hasSize(1);
@@ -1887,12 +1653,13 @@
       accountIndexedCounter.assertReindexOf(user);
 
       TestKey key = validKeyWithSecondUserId();
-      addGpgKey(key.getPublicKeyArmored());
+      addGpgKeyForSelf(key.getPublicKeyArmored());
       requestScopeOperations.setApiUser(user.id());
 
       ResourceConflictException thrown =
           assertThrows(
-              ResourceConflictException.class, () -> addGpgKey(user, key.getPublicKeyArmored()));
+              ResourceConflictException.class,
+              () -> addGpgKeyForAccount(user, key.getPublicKeyArmored()));
       assertThat(thrown)
           .hasMessageThat()
           .contains("GPG key already associated with another account");
@@ -1926,7 +1693,7 @@
       TestKey key = validKeyWithoutExpiration();
       String id = key.getKeyIdString();
       addExternalIdEmail(admin, "test1@example.com");
-      addGpgKey(key.getPublicKeyArmored());
+      addGpgKeyForSelf(key.getPublicKeyArmored());
       assertKeys(key);
       accountIndexedCounter.clear();
 
@@ -1998,7 +1765,8 @@
   @Test
   public void addMalformedGpgKey() throws Exception {
     String key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
-    BadRequestException unused = assertThrows(BadRequestException.class, () -> addGpgKey(key));
+    BadRequestException unused =
+        assertThrows(BadRequestException.class, () -> addGpgKeyForSelf(key));
   }
 
   @Test
@@ -2215,33 +1983,6 @@
     assertThat(accountQueryProvider.get().byDefault(name, true)).hasSize(2);
   }
 
-  @Test
-  public void checkMetaIdAndUniqueTag() throws Exception {
-    // In open-source Gerrit, the uniqueTag and metaId are always the same. Check them together
-    // in this test.
-    // metaId and uniqueTag are set when account is loaded
-    assertThat(accounts.get(admin.id()).get().account().metaId()).isEqualTo(getMetaId(admin.id()));
-    assertThat(accounts.get(admin.id()).get().account().uniqueTag())
-        .isEqualTo(getMetaId(admin.id()));
-
-    // metaId and uniqueTag are set when account is created
-    AccountsUpdate au = accountsUpdateProvider.get();
-    Account.Id accountId = Account.id(seq.nextAccountId());
-    AccountState accountState = au.insert("Create Test Account", accountId, u -> {});
-    assertThat(accountState.account().metaId()).isEqualTo(getMetaId(accountId));
-    assertThat(accountState.account().uniqueTag()).isEqualTo(getMetaId(accountId));
-
-    // metaId and uniqueTag are set when account is updated
-    Optional<AccountState> updatedAccountState =
-        au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
-    assertThat(updatedAccountState).isPresent();
-    Account updatedAccount = updatedAccountState.get().account();
-    assertThat(accountState.account().metaId()).isNotEqualTo(updatedAccount.metaId());
-    assertThat(accountState.account().uniqueTag()).isNotEqualTo(updatedAccount.uniqueTag());
-    assertThat(updatedAccount.metaId()).isEqualTo(getMetaId(accountId));
-    assertThat(updatedAccount.uniqueTag()).isEqualTo(getMetaId(accountId));
-  }
-
   private EmailInput newEmailInput(String email, boolean noConfirmation) {
     EmailInput input = new EmailInput();
     input.email = email;
@@ -2253,16 +1994,6 @@
     return newEmailInput(email, true);
   }
 
-  @Nullable
-  private String getMetaId(Account.Id accountId) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo);
-        ObjectReader or = repo.newObjectReader()) {
-      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
-      return ref != null ? ref.getObjectId().name() : null;
-    }
-  }
-
   @Test
   public void allGroupsForAnAdminAccountCanBeRetrieved() throws Exception {
     List<GroupInfo> groups = gApi.accounts().id(admin.id().get()).getGroups();
@@ -2468,12 +2199,12 @@
             "Set Status",
             admin.id(),
             (a, u) -> {
-              if ("A-1".equals(a.account().status())) {
+              if (Objects.equals(a.account().status(), "A-1")) {
                 bgIndicatorA1ToB1.set(true);
                 u.setStatus("B-1");
               }
 
-              if ("A-2".equals(a.account().status())) {
+              if (Objects.equals(a.account().status(), "A-2")) {
                 bgIndicatorA2ToB2.set(true);
                 u.setStatus("B-2");
               }
@@ -2528,7 +2259,7 @@
     assertThat(
             gApi.accounts().id(accountId.get()).getExternalIds().stream()
                 .map(i -> i.identity)
-                .collect(toSet()))
+                .collect(toImmutableSet()))
         .containsExactly(extIdA1.key().get());
 
     ExternalId extIdB1 = getExternalIdFactory().create("foo", "B-1", accountId);
@@ -2560,99 +2291,11 @@
     assertThat(
             gApi.accounts().id(accountId.get()).getExternalIds().stream()
                 .map(i -> i.identity)
-                .collect(toSet()))
+                .collect(toImmutableSet()))
         .containsExactly(extIdB2.key().get());
   }
 
   @Test
-  public void stalenessChecker() throws Exception {
-    // Newly created account is not stale.
-    AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
-    Account.Id accountId = Account.id(accountInfo._accountId);
-    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
-
-    // Manually updating the user ref makes the index document stale.
-    String userRef = RefNames.refsUsers(accountId);
-    testRefAction(
-        () -> {
-          try (Repository repo = repoManager.openRepository(allUsers);
-              ObjectInserter oi = repo.newObjectInserter();
-              RevWalk rw = new RevWalk(repo)) {
-            RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
-
-            PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
-            CommitBuilder cb = new CommitBuilder();
-            cb.setTreeId(commit.getTree());
-            cb.setCommitter(ident);
-            cb.setAuthor(ident);
-            cb.setMessage(commit.getFullMessage());
-            ObjectId emptyCommit = oi.insert(cb);
-            oi.flush();
-
-            RefUpdate updateRef = repo.updateRef(userRef);
-            updateRef.setExpectedOldObjectId(commit.toObjectId());
-            updateRef.setNewObjectId(emptyCommit);
-            assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
-          }
-        });
-    assertStaleAccountAndReindex(accountId);
-
-    // Manually inserting/updating/deleting an external ID of the user makes the index document
-    // stale.
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      testRefAction(
-          () -> {
-            ExternalIdNotes extIdNotes = getExternalIdNotes(repo);
-
-            ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
-            extIdNotes.insert(getExternalIdFactory().create(key, accountId));
-            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
-              extIdNotes.commit(update);
-            }
-            assertStaleAccountAndReindex(accountId);
-
-            extIdNotes = getExternalIdNotes(repo);
-            extIdNotes.upsert(
-                getExternalIdFactory().createWithEmail(key, accountId, "foo@example.com"));
-            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
-              extIdNotes.commit(update);
-            }
-            assertStaleAccountAndReindex(accountId);
-
-            extIdNotes = getExternalIdNotes(repo);
-            extIdNotes.delete(accountId, key);
-            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
-              extIdNotes.commit(update);
-            }
-          });
-      assertStaleAccountAndReindex(accountId);
-    }
-
-    // Manually delete account
-    testRefAction(
-        () -> {
-          try (Repository repo = repoManager.openRepository(allUsers);
-              RevWalk rw = new RevWalk(repo)) {
-            RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
-            RefUpdate updateRef = repo.updateRef(userRef);
-            updateRef.setExpectedOldObjectId(commit.toObjectId());
-            updateRef.setNewObjectId(ObjectId.zeroId());
-            updateRef.setForceUpdate(true);
-            assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
-          }
-        });
-    assertStaleAccountAndReindex(accountId);
-  }
-
-  private void assertStaleAccountAndReindex(Account.Id accountId) throws IOException {
-    assertThat(stalenessChecker.check(accountId).isStale()).isTrue();
-
-    // Reindex fixes staleness
-    accountIndexer.index(accountId);
-    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
-  }
-
-  @Test
   @UseClockStep
   public void deleteAllDraftComments() throws Exception {
     try {
@@ -2927,55 +2570,6 @@
   }
 
   @Test
-  public void externalIdBatchUpdates() throws Exception {
-    String extId1String = "foo:bar";
-    String extId2String = "foo:baz";
-    ExternalId extId1 =
-        getExternalIdFactory()
-            .createWithEmail(externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
-    ExternalId extId2 =
-        getExternalIdFactory()
-            .createWithEmail(externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
-
-    int initialCommits = countExternalIdsCommits();
-    AccountsUpdate.UpdateArguments ua1 =
-        new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
-    AccountsUpdate.UpdateArguments ua2 =
-        new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), u -> u.addExternalId(extId2));
-    ImmutableList<Optional<AccountState>> accountStates =
-        accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
-    assertThat(accountStates).hasSize(2);
-    assertThat(accountStates.get(0).get().externalIds()).contains(extId1);
-    assertThat(accountStates.get(1).get().externalIds()).contains(extId2);
-    assertThat(
-            gApi.accounts().id(admin.id().get()).getExternalIds().stream()
-                .map(e -> e.identity)
-                .collect(toSet()))
-        .contains(extId1String);
-    assertThat(
-            gApi.accounts().id(user.id().get()).getExternalIds().stream()
-                .map(e -> e.identity)
-                .collect(toSet()))
-        .contains(extId2String);
-
-    // Ensure that we only applied one single commit.
-    int afterUpdateCommits = countExternalIdsCommits();
-    assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
-  }
-
-  @UsedAt(UsedAt.Project.GOOGLE)
-  protected int countExternalIdsCommits() throws Exception {
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        Git git = new Git(allUsersRepo)) {
-      ObjectId refsMetaExternalIdsHead =
-          allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
-      return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
-    }
-  }
-
-  @Test
   public void externalIdBatchUpdates_fail_sameAccount() {
     ExternalId extId1 =
         getExternalIdFactory()
@@ -3021,49 +2615,6 @@
   }
 
   @Test
-  public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
-    ExternalId extId1 =
-        getExternalIdFactory()
-            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
-    ExternalId extId2 =
-        getExternalIdFactory()
-            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
-
-    AccountsUpdate.UpdateArguments ua1 =
-        new AccountsUpdate.UpdateArguments(
-            "first message", admin.id(), u -> u.addExternalId(extId1));
-    AccountsUpdate.UpdateArguments ua2 =
-        new AccountsUpdate.UpdateArguments(
-            "second message", user.id(), u -> u.addExternalId(extId2));
-    accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
-
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(allUsersRepo)) {
-      RevCommit commit =
-          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
-
-      assertThat(commit.getFullMessage()).isEqualTo("Batch update for 2 accounts\n");
-    }
-  }
-
-  @Test
-  public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
-    ExternalId extId =
-        getExternalIdFactory()
-            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
-
-    accountsUpdateProvider.get().update("foobar", admin.id(), u -> u.addExternalId(extId));
-
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(allUsersRepo)) {
-      RevCommit commit =
-          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
-
-      assertThat(commit.getFullMessage()).isEqualTo("foobar\n");
-    }
-  }
-
-  @Test
   public void searchForSecondaryEmailRequiresModifyAccountPermission() throws Exception {
     String email = "preferred@example.com";
     TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
@@ -3085,24 +2636,6 @@
         .isEqualTo(foo.id().get());
   }
 
-  @Test
-  public void getAccountFromMetaId() throws Exception {
-    AccountState preUpdateState = accountCache.get(admin.id()).get();
-    requestScopeOperations.setApiUser(admin.id());
-    gApi.accounts().self().setStatus("New status");
-
-    AccountState postUpdateStatus = accountCache.get(admin.id()).get();
-    assertThat(postUpdateStatus).isNotEqualTo(preUpdateState);
-    assertThat(
-            accountCache.getFromMetaId(
-                admin.id(), ObjectId.fromString(preUpdateState.account().metaId())))
-        .isEqualTo(preUpdateState);
-    assertThat(
-            accountCache.getFromMetaId(
-                admin.id(), ObjectId.fromString(postUpdateStatus.account().metaId())))
-        .isEqualTo(postUpdateStatus);
-  }
-
   @CanIgnoreReturnValue
   private CommentInfo createDraft(Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
@@ -3136,204 +2669,6 @@
   }
 
   @Test
-  public void projectWatchesUpdate_refsUsersUpdated() throws Exception {
-    AccountState preUpdateState = accountCache.get(admin.id()).get();
-    requestScopeOperations.setApiUser(admin.id());
-
-    ProjectWatchInfo projectWatchInfo = new ProjectWatchInfo();
-    projectWatchInfo.project = project.get();
-    projectWatchInfo.notifyAllComments = true;
-    gApi.accounts().self().setWatchedProjects(ImmutableList.of(projectWatchInfo));
-
-    AccountState updatedState1 = accountCache.get(admin.id()).get();
-    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
-
-    gApi.accounts().self().deleteWatchedProjects(ImmutableList.of(projectWatchInfo));
-
-    AccountState updatedState2 = accountCache.get(admin.id()).get();
-    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
-  }
-
-  @Test
-  public void updateExternalId_externalIdApiUpdate_refsUsersUpdated() throws Exception {
-    AccountState preUpdateState = accountCache.get(admin.id()).get();
-    requestScopeOperations.setApiUser(admin.id());
-
-    gApi.accounts().self().addEmail(newEmailInput("secondary@non.google"));
-    assertExternalIds(
-        admin.id(),
-        ImmutableSet.of(
-            "mailto:admin@example.com", "username:admin", "mailto:secondary@non.google"));
-
-    AccountState updatedState1 = accountCache.get(admin.id()).get();
-    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
-
-    gApi.accounts().self().deleteExternalIds(ImmutableList.of("mailto:secondary@non.google"));
-
-    AccountState updatedState2 = accountCache.get(admin.id()).get();
-    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
-  }
-
-  @Test
-  public void addExternalId_accountUpdate_refsUsersUpdated() throws Exception {
-    AccountState preUpdateState = accountCache.get(admin.id()).get();
-    requestScopeOperations.setApiUser(admin.id());
-
-    ExternalId externalId = getExternalIdFactory().create("custom", "value", admin.id());
-    accountsUpdateProvider
-        .get()
-        .update("Add External ID", admin.id(), u -> u.addExternalId(externalId));
-    assertExternalIds(
-        admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
-
-    AccountState updatedState = accountCache.get(admin.id()).get();
-    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
-  }
-
-  @Test
-  public void deleteExternalId_accountUpdate_refsUsersUpdated() throws Exception {
-    AccountState preUpdateState = accountCache.get(admin.id()).get();
-    requestScopeOperations.setApiUser(admin.id());
-
-    ExternalId externalId = createEmailExternalId(admin.id(), "admin@example.com");
-    accountsUpdateProvider
-        .get()
-        .update("Remove External ID", admin.id(), u -> u.deleteExternalId(externalId));
-    assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
-
-    AccountState updatedState = accountCache.get(admin.id()).get();
-    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
-  }
-
-  @Test
-  public void updateExternalId_accountUpdate_refsUsersUpdated() throws Exception {
-    AccountState preUpdateState = accountCache.get(admin.id()).get();
-    requestScopeOperations.setApiUser(admin.id());
-
-    ExternalId externalId =
-        getExternalIdFactory()
-            .createWithEmail(
-                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
-    accountsUpdateProvider
-        .get()
-        .update("Update External ID", admin.id(), u -> u.updateExternalId(externalId));
-    assertExternalIds(
-        admin.id(),
-        ImmutableSet.of(
-            "mailto:admin@example.com", "username:admin", "mailto:secondary@non.google"));
-
-    AccountState updatedState = accountCache.get(admin.id()).get();
-    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
-  }
-
-  @Test
-  public void replaceExternalId_accountUpdate_refsUsersUpdated() throws Exception {
-    AccountState preUpdateState = accountCache.get(admin.id()).get();
-    requestScopeOperations.setApiUser(admin.id());
-
-    ExternalId externalId =
-        getExternalIdFactory()
-            .createWithEmail(
-                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
-    ExternalId oldExternalId =
-        getExternalIdsReader().get(createEmailExternalId(admin.id(), admin.email()).key()).get();
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Replace External ID", admin.id(), u -> u.replaceExternalId(oldExternalId, externalId));
-    assertExternalIds(admin.id(), ImmutableSet.of("mailto:secondary@non.google", "username:admin"));
-
-    AccountState updatedState = accountCache.get(admin.id()).get();
-    assertThat(accountCache.get(admin.id()).get()).isNotSameInstanceAs(preUpdateState);
-    if (preUpdateState.account().metaId() == null) {
-      // When the test is executed on google infrastructure, metaId should be either always set
-      // or always be null.
-      assertThat(updatedState.account().metaId()).isNull();
-    } else {
-      assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
-    }
-  }
-
-  @Test
-  public void accountUpdate_updateBatch_allUsersExternalIdsUpdated_refsUsersUpdated()
-      throws Exception {
-    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
-    AccountState preUpdateUserState = accountCache.get(user.id()).get();
-
-    requestScopeOperations.setApiUser(admin.id());
-    ExternalId extId1 =
-        getExternalIdFactory()
-            .createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
-
-    ExternalId extId2 =
-        getExternalIdFactory().createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
-
-    AccountsUpdate.UpdateArguments ua1 =
-        new AccountsUpdate.UpdateArguments(
-            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
-    AccountsUpdate.UpdateArguments ua2 =
-        new AccountsUpdate.UpdateArguments(
-            "Add External ID", user.id(), u -> u.addExternalId(extId2));
-    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
-    }
-    accountIndexedCounter.assertReindexOf(admin.id(), 1);
-    accountIndexedCounter.assertReindexOf(user.id(), 1);
-
-    assertExternalIds(
-        admin.id(),
-        ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:admin-id"));
-    assertExternalIds(
-        user.id(), ImmutableSet.of("username:user1", "mailto:user1@example.com", "custom:user-id"));
-    // Assert reindexing has worked on the updated accounts.
-    assertThat(
-            Iterables.getOnlyElement(gApi.accounts().query("admin-id@test.com").get())._accountId)
-        .isEqualTo(admin.id().get());
-    assertThat(Iterables.getOnlyElement(gApi.accounts().query("user-id@test.com").get())._accountId)
-        .isEqualTo(user.id().get());
-    AccountState updatedAdminState = accountCache.get(admin.id()).get();
-    AccountState updatedUserState = accountCache.get(user.id()).get();
-    assertThat(preUpdateAdminState.account().metaId())
-        .isNotEqualTo(updatedAdminState.account().metaId());
-    assertThat(preUpdateUserState.account().metaId())
-        .isNotEqualTo(updatedUserState.account().metaId());
-  }
-
-  @Test
-  public void accountUpdate_updateBatch_someUsersExternalIdsUpdated_refsUsersUpdated()
-      throws Exception {
-    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
-    AccountState preUpdateUserState = accountCache.get(user.id()).get();
-
-    requestScopeOperations.setApiUser(admin.id());
-    AccountsUpdate.UpdateArguments ua1 =
-        new AccountsUpdate.UpdateArguments(
-            "Update Display Name", admin.id(), u -> u.setDisplayName("DN"));
-    AccountsUpdate.UpdateArguments ua2 =
-        new AccountsUpdate.UpdateArguments(
-            "Remove external Id",
-            user.id(),
-            u -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
-    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
-    }
-    accountIndexedCounter.assertReindexOf(admin.id(), 1);
-    accountIndexedCounter.assertReindexOf(user.id(), 1);
-
-    // Only the version in config of the user with external id update was updated.
-    AccountState updatedAdminState = accountCache.get(admin.id()).get();
-    AccountState updatedUserState = accountCache.get(user.id()).get();
-    assertThat(preUpdateAdminState.account().metaId())
-        .isNotEqualTo(updatedAdminState.account().metaId());
-    assertThat(preUpdateUserState.account().metaId())
-        .isNotEqualTo(updatedUserState.account().metaId());
-  }
-
-  @Test
   public void accountUpdate_emptyStringsToUnset() throws Exception {
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
@@ -3514,7 +2849,7 @@
         deleted,
         PushCertificateIdent.parse(validKeyWithoutExpiration().getFirstUserId()).getEmailAddress());
     TestKey key = validKeyWithoutExpiration();
-    addGpgKey(deleted, key.getPublicKeyArmored());
+    addGpgKeyForAccount(deleted, key.getPublicKeyArmored());
     assertKeys(key);
     assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
 
@@ -3810,10 +3145,11 @@
     GroupMembership testGroupMembership =
         new GroupMembership() {
           @Override
-          public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupUuids) {
+          public ImmutableSet<AccountGroup.UUID> intersection(
+              Iterable<AccountGroup.UUID> groupUuids) {
             return StreamSupport.stream(groupUuids.spliterator(), /* parallel= */ false)
                 .filter(this::contains)
-                .collect(toSet());
+                .collect(toImmutableSet());
           }
 
           @Override
@@ -3848,7 +3184,7 @@
     return testGroupBackend;
   }
 
-  private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
+  protected void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
       throws Exception {
     assertExternalIds(
         gApi.accounts().id(accountId.get()).getExternalIds().stream()
@@ -3940,10 +3276,10 @@
     Account.Id currAccountId = localCtx.getContext().getUser().getAccountId();
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
-    Set<String> actualFps =
+    ImmutableSet<String> actualFps =
         getExternalIdsReader().byAccount(currAccountId, SCHEME_GPGKEY).stream()
             .map(e -> e.key().id())
-            .collect(toSet());
+            .collect(toImmutableSet());
     assertWithMessage("external IDs in database")
         .that(actualFps)
         .containsExactlyElementsIn(expectedFps);
@@ -3991,12 +3327,13 @@
   }
 
   @CanIgnoreReturnValue
-  private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
-    return addGpgKey(admin, armored);
+  private Map<String, GpgKeyInfo> addGpgKeyForSelf(String armored) throws Exception {
+    return addGpgKeyForAccount(admin, armored);
   }
 
   @CanIgnoreReturnValue
-  private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
+  private Map<String, GpgKeyInfo> addGpgKeyForAccount(TestAccount account, String armored)
+      throws Exception {
     return testRefAction(
         () -> {
           AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
@@ -4050,15 +3387,13 @@
     return gApi.accounts().id(user.id().get());
   }
 
-  private Set<String> getCookiesNames() {
-    Set<String> cookieNames =
-        httpCookieStore.getCookies().stream()
-            .map(cookie -> cookie.getName())
-            .collect(Collectors.toSet());
-    return cookieNames;
+  private ImmutableSet<String> getCookiesNames() {
+    return httpCookieStore.getCookies().stream()
+        .map(cookie -> cookie.getName())
+        .collect(toImmutableSet());
   }
 
-  private void webLogin(Integer accountId) throws IOException, ClientProtocolException {
+  private void webLogin(Integer accountId) throws IOException {
     httpGetAndAssertStatus(
         "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
   }
@@ -4080,50 +3415,21 @@
             r -> r.withBlockStrategy(noSleepBlockStrategy)));
   }
 
-  private ExternalIdNotes getExternalIdNotes(Repository allUsersRepo)
-      throws ConfigInvalidException, IOException {
-    return ExternalIdNotes.load(
-        allUsers,
-        allUsersRepo,
-        externalIdFactoryNoteDbImpl,
-        authConfig.isUserNameCaseInsensitiveMigrationMode());
-  }
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected abstract AccountsUpdate getAccountsUpdateWithRunnables(
+      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper);
 
   @UsedAt(UsedAt.Project.GOOGLE)
   protected ExternalIdFactory getExternalIdFactory() {
-    return externalIdFactoryNoteDbImpl;
+    return externalIdFactory;
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
   protected ExternalIds getExternalIdsReader() {
-    return externalIdsNoteDbImpl;
+    return externalIds;
   }
 
-  @UsedAt(UsedAt.Project.GOOGLE)
-  protected AccountsUpdate getAccountsUpdateWithRunnables(
-      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
-    return getAccountsUpdateNoteDbImplWithRunnables(afterReadRevision, beforeCommit, retryHelper);
-  }
-
-  @UsedAt(UsedAt.Project.GOOGLE)
-  protected final AccountsUpdateNoteDbImpl getAccountsUpdateNoteDbImplWithRunnables(
-      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
-    return new AccountsUpdateNoteDbImpl(
-        repoManager,
-        gitReferenceUpdated,
-        Optional.empty(),
-        allUsers,
-        externalIdsNoteDbImpl,
-        extIdNotesFactory,
-        metaDataUpdateInternalFactory,
-        retryHelper,
-        serverIdent.get(),
-        afterReadRevision,
-        beforeCommit);
-  }
-
-  private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus)
-      throws ClientProtocolException, IOException {
+  private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus) throws IOException {
     HttpGet httpGet = new HttpGet(canonicalWebUrl.get() + urlPath);
     HttpResponse loginResponse = httpclient.execute(httpGet);
     assertThat(loginResponse.getStatusLine().getStatusCode()).isEqualTo(expectedHttpStatus);
@@ -4181,7 +3487,7 @@
   }
 
   public static class TestAccountStateProvider implements AccountStateProvider {
-    private ArrayList<MetadataInfo> metadataList = new ArrayList<>();
+    private final List<MetadataInfo> metadataList = new ArrayList<>();
 
     public MetadataInfo addMetadata(
         String name, @Nullable String value, @Nullable String description) {
@@ -4198,4 +3504,104 @@
       return ImmutableList.copyOf(metadataList);
     }
   }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void queryAccountsVisibilitySameGroup() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // Switch to user context (user and user2 are not in the same group by default)
+    requestScopeOperations.setApiUser(user.id());
+
+    // Querying for user2 should return nothing
+    List<AccountInfo> result = gApi.accounts().query("email:" + user2.email()).get();
+    assertThat(result).isEmpty();
+
+    // Querying for user2 with details should also return nothing (reproduces Bypass 1)
+    result =
+        gApi.accounts()
+            .query("email:" + user2.email())
+            .withOption(ListAccountsOption.DETAILS)
+            .get();
+    assertThat(result).isEmpty();
+
+    // Suggesting for user2 should also return nothing (reproduces Bypass 2)
+    result = gApi.accounts().suggestAccounts(user2.email()).get();
+    assertThat(result).isEmpty();
+
+    // Querying for self should still work
+    result = gApi.accounts().query("email:" + user.email()).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0)._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void queryAccountsVisibilityNone() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // Querying for user2 should return nothing
+    List<AccountInfo> result = gApi.accounts().query("email:" + user2.email()).get();
+    assertThat(result).isEmpty();
+
+    // Querying for self should still return self
+    result = gApi.accounts().query("email:" + user.email()).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0)._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void queryAccountsAnonymousUserSameGroup() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+
+    // Anonymous user is not in same group as user, so query should return empty list
+    List<AccountInfo> result =
+        gApi.accounts().query("email:" + user.email()).withOption(ListAccountsOption.DETAILS).get();
+    assertThat(result).isEmpty();
+
+    // Suggest should also return empty list
+    result = gApi.accounts().suggestAccounts(user.email()).get();
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "VISIBLE_GROUP")
+  public void queryAccountsAnonymousUserVisibleGroup() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+
+    // Anonymous user cannot see user, so query should return empty list
+    List<AccountInfo> result =
+        gApi.accounts().query("email:" + user.email()).withOption(ListAccountsOption.DETAILS).get();
+    assertThat(result).isEmpty();
+
+    // Suggest should also return empty list
+    result = gApi.accounts().suggestAccounts(user.email()).get();
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "ALL")
+  public void queryAccountsAnonymousUserVisibilityAll() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+
+    // Query with details should return details (name, email)
+    List<AccountInfo> result =
+        gApi.accounts().query("email:" + user.email()).withOption(ListAccountsOption.DETAILS).get();
+    assertThat(result).isNotEmpty();
+    for (AccountInfo info : result) {
+      assertThat(info.name).isEqualTo(user.fullName());
+      assertThat(info.email).isEqualTo(user.email());
+    }
+
+    // Suggest should return details (name, email)
+    result = gApi.accounts().suggestAccounts(user.email()).get();
+    assertThat(result).isNotEmpty();
+    for (AccountInfo info : result) {
+      assertThat(info.name).isEqualTo(user.fullName());
+      assertThat(info.email).isEqualTo(user.email());
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountNoteDbIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountNoteDbIT.java
new file mode 100644
index 0000000..7a32314
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountNoteDbIT.java
@@ -0,0 +1,783 @@
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT_CONFIG;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AccountIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.account.AccountProperties;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
+import com.google.gerrit.server.account.storage.notedb.AccountsUpdateNoteDbImpl;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.index.account.StalenessChecker;
+import com.google.gerrit.server.project.RefPattern;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.api.Git;
+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.CommitBuilder;
+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.PersonIdent;
+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.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Test;
+
+public class AccountNoteDbIT extends AbstractAccountIT {
+  @Inject private AccountIndexer accountIndexer;
+  @Inject private StalenessChecker stalenessChecker;
+  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
+  @Inject private ExternalIdsNoteDbImpl externalIdsNoteDbImpl;
+  @Inject private GitReferenceUpdated gitReferenceUpdated;
+  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactoryNoteDbImpl;
+
+  @Override
+  protected ExternalIdFactory getExternalIdFactory() {
+    return externalIdFactoryNoteDbImpl;
+  }
+
+  @Override
+  protected ExternalIds getExternalIdsReader() {
+    return externalIdsNoteDbImpl;
+  }
+
+  @Override
+  protected AccountsUpdate getAccountsUpdateWithRunnables(
+      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
+    return getAccountsUpdateNoteDbImplWithRunnables(afterReadRevision, beforeCommit, retryHelper);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String name = "foo";
+      TestAccount foo = accountCreator.create(name);
+      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
+      if (server.isUsernameSupported()) {
+        assertThat(info.username).isEqualTo(name);
+      } else {
+        assertThat(info.email).isEqualTo(foo.email());
+      }
+      assertThat(info.name).isEqualTo(name);
+      accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
+      assertUserBranch(foo.id(), name, null);
+      return foo.id();
+    }
+  }
+
+  @Test
+  public void createByAccountCreator() throws Exception {
+    RefUpdateCounter refUpdateCounter = createRefUpdateCounter();
+    try (Registration registration = extensionRegistry.newRegistration().add(refUpdateCounter)) {
+      Account.Id accountId = createByAccountCreator(1);
+      refUpdateCounter.assertRefUpdateFor(
+          RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS));
+    }
+  }
+
+  @Test
+  public void createAnonymousCowardByAccountCreator() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount anonymousCoward = accountCreator.create();
+      accountIndexedCounter.assertReindexOf(anonymousCoward);
+      assertUserBranchWithoutAccountConfig(anonymousCoward.id());
+    }
+  }
+
+  @Test
+  public void commitMessageOnAccountUpdates() throws Exception {
+    AccountsUpdate au = accountsUpdateProvider.get();
+    Account.Id accountId = Account.id(seq.nextAccountId());
+    au.insert("Create Test Account", accountId, u -> {});
+    assertLastCommitMessageOfUserBranch(accountId, "Create Test Account");
+
+    au.update("Set Status", accountId, u -> u.setStatus("Foo"));
+    assertLastCommitMessageOfUserBranch(accountId, "Set Status");
+  }
+
+  private void assertLastCommitMessageOfUserBranch(Account.Id accountId, String expectedMessage)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref exactRef = repo.exactRef(RefNames.refsUsers(accountId));
+      assertThat(rw.parseCommit(exactRef.getObjectId()).getShortMessage())
+          .isEqualTo(expectedMessage);
+    }
+  }
+
+  @Test
+  @UseClockStep
+  public void createAtomically() throws Exception {
+    Account.Id accountId = Account.id(seq.nextAccountId());
+    String fullName = "Foo";
+    ExternalId extId = getExternalIdFactory().createEmail(accountId, "foo@example.com");
+    AccountState accountState =
+        accountsUpdateProvider
+            .get()
+            .insert(
+                "Create Account Atomically",
+                accountId,
+                u -> u.setFullName(fullName).addExternalId(extId));
+    assertThat(accountState.account().fullName()).isEqualTo(fullName);
+
+    AccountInfo info = gApi.accounts().id(accountId.get()).get();
+    assertThat(info.name).isEqualTo(fullName);
+
+    List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
+    assertThat(emails.stream().map(e -> e.email).collect(toImmutableSet()))
+        .containsExactly(extId.email());
+
+    RevCommit commitUserBranch =
+        projectOperations.project(allUsers).getHead(RefNames.refsUsers(accountId));
+    RevCommit commitRefsMetaExternalIds =
+        projectOperations.project(allUsers).getHead(RefNames.REFS_EXTERNAL_IDS);
+    assertThat(commitUserBranch.getCommitTime())
+        .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
+  }
+
+  @Test
+  public void updateAccountWithoutAccountConfigNoteDb() throws Exception {
+    TestAccount anonymousCoward = accountCreator.create();
+    assertUserBranchWithoutAccountConfig(anonymousCoward.id());
+
+    String status = "OOO";
+    Optional<AccountState> accountState =
+        accountsUpdateProvider
+            .get()
+            .update("Set status", anonymousCoward.id(), u -> u.setStatus(status));
+    assertThat(accountState).isPresent();
+    Account account = accountState.get().account();
+    assertThat(account.fullName()).isNull();
+    assertThat(account.status()).isEqualTo(status);
+    assertUserBranch(anonymousCoward.id(), null, status);
+  }
+
+  private void assertUserBranchWithoutAccountConfig(Account.Id accountId) throws Exception {
+    assertUserBranch(accountId, null, null);
+  }
+
+  private void assertUserBranch(
+      Account.Id accountId, @Nullable String name, @Nullable String status) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
+      assertThat(ref).isNotNull();
+      RevCommit c = rw.parseCommit(ref.getObjectId());
+      long timestampDiffMs =
+          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().toEpochMilli());
+      assertThat(timestampDiffMs).isAtMost(Duration.ofSeconds(1).toMillis());
+
+      // Check the 'account.config' file.
+      try (TreeWalk tw = TreeWalk.forPath(or, ACCOUNT_CONFIG, c.getTree())) {
+        if (name != null || status != null) {
+          assertThat(tw).isNotNull();
+          Config cfg = new Config();
+          cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
+          assertThat(cfg)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
+              .isEqualTo(name);
+          assertThat(cfg)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_STATUS)
+              .isEqualTo(status);
+        } else {
+          // No account properties were set, hence an 'account.config' file was not created.
+          assertThat(tw).isNull();
+        }
+      }
+    }
+  }
+
+  @Test
+  public void checkMetaIdAndUniqueTag() throws Exception {
+    // In open-source Gerrit, the uniqueTag and metaId are always the same. Check them together
+    // in this test.
+    // metaId and uniqueTag are set when account is loaded
+    assertThat(accounts.get(admin.id()).get().account().metaId()).isEqualTo(getMetaId(admin.id()));
+    assertThat(accounts.get(admin.id()).get().account().uniqueTag())
+        .isEqualTo(getMetaId(admin.id()));
+
+    // metaId and uniqueTag are set when account is created
+    AccountsUpdate au = accountsUpdateProvider.get();
+    Account.Id accountId = Account.id(seq.nextAccountId());
+    AccountState accountState = au.insert("Create Test Account", accountId, u -> {});
+    assertThat(accountState.account().metaId()).isEqualTo(getMetaId(accountId));
+    assertThat(accountState.account().uniqueTag()).isEqualTo(getMetaId(accountId));
+
+    // metaId and uniqueTag are set when account is updated
+    Optional<AccountState> updatedAccountState =
+        au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
+    assertThat(updatedAccountState).isPresent();
+    Account updatedAccount = updatedAccountState.get().account();
+    assertThat(accountState.account().metaId()).isNotEqualTo(updatedAccount.metaId());
+    assertThat(accountState.account().uniqueTag()).isNotEqualTo(updatedAccount.uniqueTag());
+    assertThat(updatedAccount.metaId()).isEqualTo(getMetaId(accountId));
+    assertThat(updatedAccount.uniqueTag()).isEqualTo(getMetaId(accountId));
+  }
+
+  @Nullable
+  private String getMetaId(Account.Id accountId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
+      return ref != null ? ref.getObjectId().name() : null;
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates() throws Exception {
+    String extId1String = "foo:bar";
+    String extId2String = "foo:baz";
+    ExternalId extId1 =
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
+
+    int initialCommits = countExternalIdsCommits();
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), u -> u.addExternalId(extId2));
+    ImmutableList<Optional<AccountState>> accountStates =
+        accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    assertThat(accountStates).hasSize(2);
+    assertThat(accountStates.get(0).get().externalIds()).contains(extId1);
+    assertThat(accountStates.get(1).get().externalIds()).contains(extId2);
+    assertThat(
+            gApi.accounts().id(admin.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
+        .contains(extId1String);
+    assertThat(
+            gApi.accounts().id(user.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
+        .contains(extId2String);
+
+    // Ensure that we only applied one single commit.
+    int afterUpdateCommits = countExternalIdsCommits();
+    assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int countExternalIdsCommits() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        Git git = new Git(allUsersRepo)) {
+      ObjectId refsMetaExternalIdsHead =
+          allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+      return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
+    ExternalId extId1 =
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "first message", admin.id(), u -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "second message", user.id(), u -> u.addExternalId(extId2));
+    accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("Batch update for 2 accounts\n");
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
+    ExternalId extId =
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+
+    accountsUpdateProvider.get().update("foobar", admin.id(), u -> u.addExternalId(extId));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("foobar\n");
+    }
+  }
+
+  @Test
+  public void getAccountFromMetaId() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.accounts().self().setStatus("New status");
+
+    AccountState postUpdateStatus = accountCache.get(admin.id()).get();
+    assertThat(postUpdateStatus).isNotEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(preUpdateState.account().metaId())))
+        .isEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(postUpdateStatus.account().metaId())))
+        .isEqualTo(postUpdateStatus);
+  }
+
+  @Test
+  public void projectWatchesUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ProjectWatchInfo projectWatchInfo = new ProjectWatchInfo();
+    projectWatchInfo.project = project.get();
+    projectWatchInfo.notifyAllComments = true;
+    gApi.accounts().self().setWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_externalIdApiUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.accounts().self().addEmail(newEmailInput("secondary@non.google"));
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of(
+            "mailto:admin@example.com", "username:admin", "mailto:secondary@non.google"));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteExternalIds(ImmutableList.of("mailto:secondary@non.google"));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void addExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = getExternalIdFactory().create("custom", "value", admin.id());
+    accountsUpdateProvider
+        .get()
+        .update("Add External ID", admin.id(), u -> u.addExternalId(externalId));
+    assertExternalIds(
+        admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void deleteExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = createEmailExternalId(admin.id(), "admin@example.com");
+    accountsUpdateProvider
+        .get()
+        .update("Remove External ID", admin.id(), u -> u.deleteExternalId(externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+    accountsUpdateProvider
+        .get()
+        .update("Update External ID", admin.id(), u -> u.updateExternalId(externalId));
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of(
+            "mailto:admin@example.com", "username:admin", "mailto:secondary@non.google"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void replaceExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+    ExternalId oldExternalId =
+        getExternalIdsReader().get(createEmailExternalId(admin.id(), admin.email()).key()).get();
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Replace External ID", admin.id(), u -> u.replaceExternalId(oldExternalId, externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:secondary@non.google", "username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(accountCache.get(admin.id()).get()).isNotSameInstanceAs(preUpdateState);
+    if (preUpdateState.account().metaId() == null) {
+      // When the test is executed on google infrastructure, metaId should be either always set
+      // or always be null.
+      assertThat(updatedState.account().metaId()).isNull();
+    } else {
+      assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+    }
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_allUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    ExternalId extId1 =
+        getExternalIdFactory()
+            .createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
+
+    ExternalId extId2 =
+        getExternalIdFactory().createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), u -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), u -> u.addExternalId(extId2));
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:admin-id"));
+    assertExternalIds(
+        user.id(), ImmutableSet.of("username:user1", "mailto:user1@example.com", "custom:user-id"));
+    // Assert reindexing has worked on the updated accounts.
+    assertThat(
+            Iterables.getOnlyElement(gApi.accounts().query("admin-id@test.com").get())._accountId)
+        .isEqualTo(admin.id().get());
+    assertThat(Iterables.getOnlyElement(gApi.accounts().query("user-id@test.com").get())._accountId)
+        .isEqualTo(user.id().get());
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_someUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Update Display Name", admin.id(), u -> u.setDisplayName("DN"));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Remove external Id",
+            user.id(),
+            u -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    // Only the version in config of the user with external id update was updated.
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  @Test
+  public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
+
+    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();
+    }
+
+    assertThat(accountCache.get(admin.id())).isEmpty();
+    assertThat(accountQueryProvider.get().byDefault(admin.id().toString(), true)).isEmpty();
+  }
+
+  @Test
+  public void cannotDeleteUserBranch() throws Exception {
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(
+            allow(Permission.DELETE)
+                .ref(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}")
+                .group(REGISTERED_USERS)
+                .force(true))
+        .update();
+
+    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
+  public void createDefaultUserBranch() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
+    }
+
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS_DEFAULT).group(adminGroupUuid()))
+        .update();
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    pushFactory
+        .create(admin.newIdent(), allUsersRepo)
+        .to(RefNames.REFS_USERS_DEFAULT)
+        .assertOkStatus();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNotNull();
+    }
+  }
+
+  @Test
+  public void stalenessChecker() throws Exception {
+    // Newly created account is not stale.
+    AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
+    Account.Id accountId = Account.id(accountInfo._accountId);
+    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
+
+    // Manually updating the user ref makes the index document stale.
+    String userRef = RefNames.refsUsers(accountId);
+    testRefAction(
+        () -> {
+          try (Repository repo = repoManager.openRepository(allUsers);
+              ObjectInserter oi = repo.newObjectInserter();
+              RevWalk rw = new RevWalk(repo)) {
+            RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+
+            PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
+            CommitBuilder cb = new CommitBuilder();
+            cb.setTreeId(commit.getTree());
+            cb.setCommitter(ident);
+            cb.setAuthor(ident);
+            cb.setMessage(commit.getFullMessage());
+            ObjectId emptyCommit = oi.insert(cb);
+            oi.flush();
+
+            RefUpdate updateRef = repo.updateRef(userRef);
+            updateRef.setExpectedOldObjectId(commit.toObjectId());
+            updateRef.setNewObjectId(emptyCommit);
+            assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+          }
+        });
+    assertStaleAccountAndReindex(accountId);
+
+    // Manually inserting/updating/deleting an external ID of the user makes the index document
+    // stale.
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      testRefAction(
+          () -> {
+            ExternalIdNotes extIdNotes = getExternalIdNotes(repo);
+
+            ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
+            extIdNotes.insert(getExternalIdFactory().create(key, accountId));
+            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+              extIdNotes.commit(update);
+            }
+            assertStaleAccountAndReindex(accountId);
+
+            extIdNotes = getExternalIdNotes(repo);
+            extIdNotes.upsert(
+                getExternalIdFactory().createWithEmail(key, accountId, "foo@example.com"));
+            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+              extIdNotes.commit(update);
+            }
+            assertStaleAccountAndReindex(accountId);
+
+            extIdNotes = getExternalIdNotes(repo);
+            extIdNotes.delete(accountId, key);
+            try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+              extIdNotes.commit(update);
+            }
+          });
+      assertStaleAccountAndReindex(accountId);
+    }
+
+    // Manually delete account
+    testRefAction(
+        () -> {
+          try (Repository repo = repoManager.openRepository(allUsers);
+              RevWalk rw = new RevWalk(repo)) {
+            RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+            RefUpdate updateRef = repo.updateRef(userRef);
+            updateRef.setExpectedOldObjectId(commit.toObjectId());
+            updateRef.setNewObjectId(ObjectId.zeroId());
+            updateRef.setForceUpdate(true);
+            assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+          }
+        });
+    assertStaleAccountAndReindex(accountId);
+  }
+
+  private ExternalIdNotes getExternalIdNotes(Repository allUsersRepo)
+      throws ConfigInvalidException, IOException {
+    return ExternalIdNotes.load(
+        allUsers,
+        allUsersRepo,
+        externalIdFactoryNoteDbImpl,
+        authConfig.isUserNameCaseInsensitiveMigrationMode());
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected final AccountsUpdateNoteDbImpl getAccountsUpdateNoteDbImplWithRunnables(
+      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
+    return new AccountsUpdateNoteDbImpl(
+        repoManager,
+        gitReferenceUpdated,
+        Optional.empty(),
+        allUsers,
+        externalIdsNoteDbImpl,
+        extIdNotesFactory,
+        metaDataUpdateInternalFactory,
+        retryHelper,
+        serverIdent.get(),
+        afterReadRevision,
+        beforeCommit);
+  }
+
+  private void assertStaleAccountAndReindex(Account.Id accountId) throws IOException {
+    assertThat(stalenessChecker.check(accountId).isStale()).isTrue();
+
+    // Reindex fixes staleness
+    accountIndexer.index(accountId);
+    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index c441402..a09ecf5 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -2,7 +2,10 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
-    srcs = glob(["*IT.java"]),
+    srcs = glob(
+        ["*IT.java"],
+        exclude = ["AbstractAccountIT.java"],
+    ),
     group = "api_account",
     labels = [
         "api",
@@ -20,7 +23,10 @@
 java_library(
     name = "util",
     testonly = True,
-    srcs = glob(["TestRealm.java"]),
+    srcs = [
+        "AbstractAccountIT.java",
+        "TestRealm.java",
+    ],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
     ],
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
index 7c6e5ed..981b6e9 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -33,6 +33,12 @@
   }
 
   @Test
+  public void responsiveModeDefault() throws Exception {
+    DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
+    assertThat(d.responsiveMode).isEqualTo(DiffPreferencesInfo.ResponsiveMode.NONE);
+  }
+
+  @Test
   public void setDiffPreferences() throws Exception {
     DiffPreferencesInfo i = DiffPreferencesInfo.defaults();
 
@@ -61,9 +67,11 @@
     i.hideEmptyPane ^= true;
     i.matchBrackets ^= true;
     i.lineWrapping ^= true;
+    i.responsiveMode = DiffPreferencesInfo.ResponsiveMode.SHRINK_ONLY;
 
     DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertPrefs(o, i);
+
     // Re-getting the preferences should yield the same fields
     o = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertPrefs(o, i);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index dd2d0c3..0453d60 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -87,6 +87,7 @@
     i.allowSuggestCodeWhileCommenting ^= false;
     i.allowAutocompletingComments ^= false;
     i.aiChatSelectedModel = "test-ai-model";
+    i.labelFilter = "Code-Review,Verified";
     i.diffPageSidebar = "plugin-insight";
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
@@ -105,6 +106,7 @@
     assertThat(o.aiChatSelectedModel).isEqualTo(i.aiChatSelectedModel);
     assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar);
     assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
+    assertThat(o.labelFilter).isEqualTo(i.labelFilter);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
index 984b32d..a1a6755 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
 import java.time.Instant;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d716006..35e2199 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -42,6 +42,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
@@ -155,6 +156,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -1220,8 +1222,7 @@
 
   @Test
   public void deleteAllForProjectNotifiesListeners() {
-    TestDeleteAllForProjectListener deleteAllForProjectsListener =
-        new TestDeleteAllForProjectListener();
+    TestDeleteForProjectListener deleteAllForProjectsListener = new TestDeleteForProjectListener();
 
     try (Registration ignore =
         extensionRegistry.newRegistration().add(deleteAllForProjectsListener)) {
@@ -1229,7 +1230,26 @@
       indexer.deleteAllForProject(project);
     }
 
-    assertThat(deleteAllForProjectsListener.getFiredCount()).isEqualTo(1);
+    assertThat(deleteAllForProjectsListener.getAllChangesPerProjectDeletedFiredCount())
+        .isEqualTo(1);
+  }
+
+  @SuppressWarnings("FutureReturnValueIgnored")
+  @Test
+  public void deleteChangeFromIndexNotifiesListeners() {
+    TestChange change = changeOperations.newChange().createAndGet();
+    TestDeleteForProjectListener deleteAllForProjectsListener = new TestDeleteForProjectListener();
+    String projectName = "my-test-project";
+
+    try (Registration ignore =
+        extensionRegistry.newRegistration().add(deleteAllForProjectsListener)) {
+
+      var unused =
+          indexer.deleteAsync(Project.NameKey.parse(projectName), change.numericChangeId());
+    }
+
+    assertThat(deleteAllForProjectsListener.getSingleChangeDeletedFiredCount()).isEqualTo(1);
+    assertThat(deleteAllForProjectsListener.getReceivedProjectName()).isEqualTo(projectName);
   }
 
   @Test
@@ -2423,7 +2443,7 @@
     assertThat(messages).hasSize(1);
     Message msg = messages.get(0);
     assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
+    assertThat(msg.body()).contains(admin.getNameEmail() + " has removed a vote from this change.");
     assertThat(msg.body())
         .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
 
@@ -2950,6 +2970,30 @@
   }
 
   @Test
+  public void queryChangesOptionsBulk() throws Exception {
+    int numChanges = 20;
+    for (int i = 0; i < numChanges; i++) {
+      createChange();
+    }
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new TestDownloadScheme(), "test-scheme")) {
+      List<ChangeInfo> results =
+          gApi.changes()
+              .query("project:" + project.get() + " status:open")
+              .withOptions(CHANGE_ACTIONS, CURRENT_REVISION, CURRENT_ACTIONS, DOWNLOAD_COMMANDS)
+              .get();
+      assertThat(results).hasSize(numChanges);
+      for (ChangeInfo result : results) {
+        assertThat(result.actions).containsKey("abandon");
+        RevisionInfo rev = Iterables.getOnlyElement(result.revisions.values());
+        assertThat(rev.actions).isNotEmpty();
+        assertThat(rev.fetch).isNotEmpty();
+      }
+    }
+  }
+
+  @Test
   public void queryChangesOwnerWithDifferentUsers() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(
@@ -5150,7 +5194,7 @@
     // Check that the email was removed as a CC and an email was sent.
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+        .contains(String.format("%s has removed %s", admin.getNameEmail(), reviewerInput.reviewer));
   }
 
   @Test
@@ -5191,7 +5235,7 @@
     // Check that the email was removed as a CC and an email was sent.
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+        .contains(String.format("%s has removed %s", admin.getNameEmail(), reviewerInput.reviewer));
   }
 
   @Test
@@ -5299,7 +5343,7 @@
     Change.Id changeId = change.getChange().getId();
     String metaRef = changeMetaRef(changeId);
 
-    indexer.delete(changeId);
+    indexer.delete(change.getChange().project(), changeId);
 
     try (Repository repo = repoManager.openRepository(project);
         ObjectInserter inserter = repo.newObjectInserter();
@@ -5417,26 +5461,66 @@
     }
   }
 
-  public static class TestDeleteAllForProjectListener implements ChangeIndexedListener {
-    private final AtomicInteger firedCount = new AtomicInteger(0);
+  public static class TestDeleteForProjectListener implements ChangeIndexedListener {
+    private final AtomicInteger allChangesPerProjectDeletedFiredCount = new AtomicInteger(0);
+    private final AtomicInteger singleChangeDeletedFiredCount = new AtomicInteger(0);
+    private String receivedProjectName = null;
 
     @Override
     public void onChangeIndexed(String projectName, int id) {}
 
     @Override
-    public void onChangeDeleted(int id) {}
+    public void onChangeDeleted(String projectName, int id) {
+      singleChangeDeletedFiredCount.incrementAndGet();
+      receivedProjectName = projectName;
+    }
 
     @Override
     public void onAllChangesDeletedForProject(String projectName) {
-      firedCount.incrementAndGet();
+      allChangesPerProjectDeletedFiredCount.incrementAndGet();
     }
 
-    public int getFiredCount() {
-      return firedCount.get();
+    public int getAllChangesPerProjectDeletedFiredCount() {
+      return allChangesPerProjectDeletedFiredCount.get();
+    }
+
+    public int getSingleChangeDeletedFiredCount() {
+      return singleChangeDeletedFiredCount.get();
+    }
+
+    public String getReceivedProjectName() {
+      return receivedProjectName;
     }
   }
 
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
+
+  private static class TestDownloadScheme extends DownloadScheme {
+    @Override
+    public String getUrl(String project) {
+      return "http://foo/" + project;
+    }
+
+    @Override
+    public boolean isAuthRequired() {
+      return true;
+    }
+
+    @Override
+    public boolean isAuthSupported() {
+      return true;
+    }
+
+    @Override
+    public boolean isEnabled() {
+      return true;
+    }
+
+    @Override
+    public boolean isHidden() {
+      return false;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java b/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java
index 0d8e262..2004c8a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/EvaluateChangeQueryExpressionIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.TestExtensions.TestSubmitRule;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.TestChange;
 import com.google.gerrit.extensions.api.changes.ChangeIdentifier;
 import com.google.gerrit.extensions.common.EvaluateChangeQueryExpressionResultInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -351,4 +352,29 @@
     }
     assertThat(testSubmitRule.count()).isEqualTo(0);
   }
+
+  @Test
+  public void evaluatingUsingIndexWhenChangeMissingFromIndexFallsBackToNoteDb() throws Exception {
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().create();
+    changeOperations.change(changeIdentifier).newVote().codeReviewApproval().create();
+
+    TestChange testChange = changeOperations.change(changeIdentifier).get();
+    indexer.delete(testChange.project(), testChange.numericChangeId());
+
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      EvaluateChangeQueryExpressionResultInfo info =
+          gApi.changes()
+              .id(changeIdentifier)
+              .evaluateChangeQueryExpression()
+              .withExpression("is:submittable")
+              .useIndex()
+              .get();
+      assertThat(info.status).isTrue();
+      assertThat(info.passingAtoms).containsExactly("is:submittable");
+      assertThat(info.failingAtoms).isEmpty();
+      assertThat(info.atomExplanations).isNull();
+    }
+    assertThat(testSubmitRule.count()).isEqualTo(1);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 8bf46fa..181de63 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -64,6 +64,7 @@
 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.config.FactoryModule;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
@@ -229,6 +230,41 @@
   }
 
   @Test
+  public void validateCommentsInInput_inReplyToCommentInDifferentPatchsetRejected()
+      throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String filePath = r1.getChange().currentFilePaths().get(0);
+
+    CommentInput comment1 = newComment(filePath);
+    comment1.updated = new Timestamp(0);
+    ReviewInput input1 = new ReviewInput();
+    input1.comments = ImmutableMap.of(filePath, ImmutableList.of(comment1));
+    gApi.changes().id(r1.getChangeId()).current().review(input1);
+
+    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    assertThat(comments).hasSize(1);
+    String parentCommentId = comments.get(0).id;
+
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+
+    CommentInput comment2 = newComment(filePath);
+    comment2.inReplyTo = parentCommentId;
+    comment2.updated = new Timestamp(0);
+    ReviewInput input2 = new ReviewInput();
+    input2.comments = ImmutableMap.of(filePath, ImmutableList.of(comment2));
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r2.getChangeId()).current().review(input2));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Invalid comment in_reply_to. Comment replies must be submitted on the same patchset as"
+                + " the parent comment");
+  }
+
+  @Test
   public void validateDrafts_draftOK() throws Exception {
     PushOneCommit.Result r = createChange();
     when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture()))
@@ -1322,6 +1358,63 @@
     }
   }
 
+  @Test
+  public void postCommentWithIsAi() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String filePath = r.getChange().currentFilePaths().get(0);
+
+    CommentInput comment = new CommentInput();
+    comment.line = 1;
+    comment.message = "AI comment";
+    comment.path = filePath;
+    comment.isAi = true;
+
+    ReviewInput input = new ReviewInput();
+    input.comments = ImmutableMap.of(filePath, ImmutableList.of(comment));
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    List<com.google.gerrit.extensions.common.CommentInfo> comments =
+        gApi.changes().id(r.getChangeId()).commentsAsList();
+    assertThat(comments).hasSize(1);
+    com.google.gerrit.extensions.common.CommentInfo commentInfo = comments.get(0);
+    assertThat(commentInfo.message).isEqualTo("AI comment");
+    assertThat(commentInfo.isAi).isTrue();
+  }
+
+  @Test
+  public void createDraftWithIsAiAndPublish() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String filePath = r.getChange().currentFilePaths().get(0);
+
+    DraftInput draft = new DraftInput();
+    draft.line = 1;
+    draft.message = "AI draft";
+    draft.path = filePath;
+    draft.isAi = true;
+
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().getName()).createDraft(draft);
+
+    List<com.google.gerrit.extensions.common.CommentInfo> drafts =
+        gApi.changes().id(r.getChangeId()).draftsAsList();
+    assertThat(drafts).hasSize(1);
+    com.google.gerrit.extensions.common.CommentInfo draftInfo = drafts.get(0);
+    assertThat(draftInfo.message).isEqualTo("AI draft");
+    assertThat(draftInfo.isAi).isTrue();
+
+    // Publish drafts
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH;
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    // Verify published comment has isAi = true
+    List<com.google.gerrit.extensions.common.CommentInfo> comments =
+        gApi.changes().id(r.getChangeId()).commentsAsList();
+    assertThat(comments).hasSize(1);
+    com.google.gerrit.extensions.common.CommentInfo commentInfo = comments.get(0);
+    assertThat(commentInfo.message).isEqualTo("AI draft");
+    assertThat(commentInfo.isAi).isTrue();
+  }
+
   private static void assertAttentionSet(
       ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) {
     assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList()))
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 8e2a3ba..87a58db 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
@@ -34,6 +35,7 @@
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -426,7 +428,33 @@
     ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
     assertThat(sender.getMessages(revertChange.changeId, "newchange")).hasSize(1);
-    assertThat(sender.getMessages(r.getChangeId(), "revert")).hasSize(1);
+    List<Message> revertMessages = sender.getMessages(r.getChangeId(), "revert");
+    assertThat(revertMessages).hasSize(1);
+    assertThat(revertMessages.get(0).body())
+        .contains(admin.getNameEmail() + " has created a revert of this change.");
+  }
+
+  @Test
+  public void revertNotificationIncludesSenderEmail() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    TestAccount meUser =
+        accountCreator.create(
+            "me_user", "me@example.com", "Me", /* displayName= */ null, "Administrators");
+    requestScopeOperations.setApiUser(meUser.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revert();
+
+    List<Message> revertMessages = sender.getMessages(r.getChangeId(), "revert");
+    assertThat(revertMessages).hasSize(1);
+    assertThat(revertMessages.get(0).body())
+        .contains("Me <me@example.com> has created a revert of this change.");
+    assertThat(revertMessages.get(0).htmlBody())
+        .contains(
+            "Me &lt;me@example.com&gt; has <strong>created a revert</strong> of this change.");
   }
 
   @Test
@@ -516,6 +544,53 @@
   }
 
   @Test
+  public void revertDoesNotAddDeletedReviewersOrCcs() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount reviewer =
+        accountCreator.create("reviewer-temp", "reviewer-temp@example.com", "Reviewer Temp", null);
+    TestAccount cc = accountCreator.create("cc-temp", "cc-temp@example.com", "CC Temp", null);
+    TestAccount reverter = accountCreator.admin2();
+
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(reviewer.email());
+    in.reviewer(cc.email(), ReviewerState.CC, true);
+    in.reviewer(reverter.email());
+
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    // Delete reviewer and CC accounts
+    deleteAccount(reviewer.id());
+    deleteAccount(cc.id());
+
+    requestScopeOperations.setApiUser(reverter.id());
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        gApi.changes().id(r.getChangeId()).revert().get().reviewers;
+
+    // The deleted reviewer and CC should not be added.
+    // Only the change owner (admin) should be added as reviewer (since they are not deleted).
+    // Reverter (admin2) is the new owner, so they are not in reviewers list.
+    if (result.containsKey(ReviewerState.REVIEWER)) {
+      ImmutableList<Integer> reviewers =
+          result.get(ReviewerState.REVIEWER).stream()
+              .map(a -> a._accountId)
+              .collect(toImmutableList());
+      assertThat(reviewers).containsExactly(admin.id().get());
+    }
+    if (result.containsKey(ReviewerState.CC)) {
+      ImmutableList<Integer> ccs =
+          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toImmutableList());
+      assertThat(ccs).isEmpty();
+    }
+  }
+
+  private void deleteAccount(Account.Id id) throws Exception {
+    requestScopeOperations.setApiUser(id);
+    gApi.accounts().self().delete();
+  }
+
+  @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   public void revertWithNonVisibleUsers() throws Exception {
     projectOperations
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 67301ff..6b4c697 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -2870,6 +2870,58 @@
   }
 
   @Test
+  public void submitRequirement_disallowsUnsatisfiedRequirementCountInExpression()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Wrong-Req")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("unsatisfied_requirement_count:0"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("Wrong-Req"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult.errorMessage)
+        .isEqualTo(
+            "Operator 'unsatisfied_requirement_count' cannot be used in submit requirement"
+                + " expressions.");
+  }
+
+  @Test
+  public void submitRequirement_disallowsUnmetRequirementInExpression() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Wrong-Req")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("unmet_requirement:Code-Review"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("Wrong-Req"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult.errorMessage)
+        .isEqualTo(
+            "Operator 'unmet_requirement' cannot be used in submit requirement expressions.");
+  }
+
+  @Test
   public void submitRequirements_forcedByDirectSubmission() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index 5046eee..927442b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -1012,7 +1012,7 @@
     commentInput.message = "done";
     commentInput.unresolved = false;
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(commentInput));
-    gApi.changes().id(project.get(), r.getChange().getId().get()).current().review(reviewInput);
+    gApi.changes().id(project.get(), r.getChange().getId().get()).revision(1).review(reviewInput);
     assertNotMatching("has:unresolved", r.getChange().getId());
     assertMatching("-has:unresolved", r.getChange().getId());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
index 8619157..0c4133d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.inject.Inject;
 import java.util.List;
@@ -113,4 +114,31 @@
       u.save();
     }
   }
+
+  @Test
+  public void submitRecordsForOpenChanges_reusedAcrossLenientAndStrict() throws Exception {
+    SubmitRuleEvaluator strictEvaluator =
+        submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
+    SubmitRuleEvaluator lenientEvaluator =
+        submitRuleEvaluatorFactory.create(
+            SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build());
+
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+
+    ChangeData cd = r.getChange();
+    List<SubmitRecord> strictRecords = strictEvaluator.evaluate(cd);
+    assertThat(strictRecords).isNotEmpty();
+
+    // Lenient evaluation on the same open change should return the exact same cached instance
+    List<SubmitRecord> lenientRecords = lenientEvaluator.evaluate(cd);
+    assertThat(lenientRecords).isSameInstanceAs(strictRecords);
+
+    // Also testing cd.submitRecords
+    ChangeData cd2 = r.getChange();
+    List<SubmitRecord> lenientRecords2 =
+        cd2.submitRecords(SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build());
+    List<SubmitRecord> strictRecords2 = cd2.submitRecords(SubmitRuleOptions.defaults());
+    assertThat(strictRecords2).isSameInstanceAs(lenientRecords2);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
index b2765a4..a32e0c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -48,37 +48,10 @@
     assertThat(experiments.keySet())
         .containsAtLeast(
             ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS,
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
             ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
-            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
-            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE,
             ExperimentFeaturesConstants.SKIP_SUBMIT_RECORDS_WITHOUT_SUBMIT_REQUIREMENTS)
         .inOrder();
 
-    // "GerritBackendFeature__check_implicit_merges_on_merge",
-    // "GerritBackendFeature__reject_implicit_merges_on_merge" and
-    // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
-    // AbstractDaemonTest#beforeTest
-    assertThat(
-            experiments.get(
-                    ExperimentFeaturesConstants
-                        .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE)
-                .enabled)
-        .isTrue();
-    assertThat(
-            experiments.get(
-                    ExperimentFeaturesConstants
-                        .GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE)
-                .enabled)
-        .isTrue();
-    assertThat(
-            experiments.get(
-                    ExperimentFeaturesConstants
-                        .GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
-                .enabled)
-        .isTrue();
-
     assertThat(
             experiments.get(ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS).enabled)
         .isFalse();
@@ -94,21 +67,12 @@
   @GerritConfig(
       name = "experiments.enabled",
       values = {"GerritBackendFeature__attach_nonce_to_documentation"})
-  // "GerritBackendFeature__check_implicit_merges_on_merge",
-  // "GerritBackendFeature__reject_implicit_merges_on_merge" and
-  // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
-  // AbstractDaemonTest#beforeTest
   public void listEnabled_noneEnabled() throws Exception {
     ImmutableMap<String, ExperimentInfo> experiments =
         gApi.config().server().listExperiments().enabledOnly().get();
     assertThat(experiments.keySet())
         .containsExactly(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
-            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
-            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
-            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
-        .inOrder();
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION);
     for (ExperimentInfo experimentInfo : experiments.values()) {
       assertThat(experimentInfo.enabled).isTrue();
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/flow/BUILD b/javatests/com/google/gerrit/acceptance/api/flow/BUILD
index 24aea20..384e7e7 100644
--- a/javatests/com/google/gerrit/acceptance/api/flow/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/flow/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index 2f3ef24..5396c83 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -105,6 +106,23 @@
   }
 
   @Test
+  public void batchEvictionByUuidUpdatesStaleUuidCache() throws Exception {
+    AccountGroup.UUID groupUuid1 = createGroup("group1");
+    AccountGroup.UUID groupUuid2 = createGroup("group2");
+    loadGroupToCache(groupUuid1);
+    loadGroupToCache(groupUuid2);
+    updateGroupWithoutCacheOrIndex(groupUuid1, newGroupDelta().setDescription("Modified1").build());
+    updateGroupWithoutCacheOrIndex(groupUuid2, newGroupDelta().setDescription("Modified2").build());
+
+    groupCache.evict(ImmutableList.of(groupUuid1, groupUuid2));
+
+    Optional<InternalGroup> updatedGroup1 = groupCache.get(groupUuid1);
+    assertThatGroup(updatedGroup1).value().description().isEqualTo("Modified1");
+    Optional<InternalGroup> updatedGroup2 = groupCache.get(groupUuid2);
+    assertThatGroup(updatedGroup2).value().description().isEqualTo("Modified2");
+  }
+
+  @Test
   public void reindexingStaleGroupUpdatesTheIndex() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("users");
     AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index dbd1fb8..4b9537c 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -170,17 +170,17 @@
 
   private void assertPlugins(List<PluginInfo> actual, List<String> expected) {
     List<String> _actual = actual.stream().map(p -> p.id).collect(toList());
-    List<String> _expected = expected.stream().map(this::pluginName).collect(toList());
+    List<String> _expected = expected.stream().map(PluginIT::pluginName).collect(toList());
     assertThat(_actual).containsExactlyElementsIn(_expected);
   }
 
-  private String pluginName(String plugin) {
+  static String pluginName(String plugin) {
     int dot = plugin.indexOf(".");
     assertThat(dot).isGreaterThan(0);
     return plugin.substring(0, dot);
   }
 
-  private RawInput pluginJarContent(String plugin) throws IOException {
+  private static RawInput pluginJarContent(String plugin) throws IOException {
     ByteArrayOutputStream arrayStream = new ByteArrayOutputStream();
     Manifest manifest = new Manifest();
     Attributes attributes = manifest.getMainAttributes();
@@ -193,7 +193,7 @@
     return RawInputUtil.create(arrayStream.toByteArray());
   }
 
-  private RawInput pluginContent(String plugin) throws IOException {
+  static RawInput pluginContent(String plugin) throws IOException {
     if (plugin.endsWith(".js")) {
       return JS_PLUGIN_CONTENT;
     }
@@ -202,7 +202,7 @@
   }
 
   @Nullable
-  private String pluginVersion(String plugin) {
+  private static String pluginVersion(String plugin) {
     String name = pluginName(plugin);
     if (name.endsWith("empty")) {
       return "";
@@ -215,7 +215,7 @@
   }
 
   @Nullable
-  private String pluginApiVersion(String plugin) {
+  private static String pluginApiVersion(String plugin) {
     if (plugin.endsWith("normal.jar")) {
       return "2.16.19-SNAPSHOT";
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginOnStartStopIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginOnStartStopIT.java
new file mode 100644
index 0000000..17067c5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginOnStartStopIT.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2026 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.api.plugin;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.api.plugin.PluginIT.pluginContent;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
+import com.google.gerrit.extensions.api.plugins.PluginApi;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.plugins.Plugin;
+import com.google.gerrit.server.plugins.StartPluginListener;
+import com.google.gerrit.server.plugins.StopPluginListener;
+import com.google.inject.AbstractModule;
+import com.google.inject.Injector;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import org.jspecify.annotations.NonNull;
+import org.junit.Test;
+
+@TestPlugin(
+    name = "plugin-start-stop-listener",
+    sysModule = "com.google.gerrit.acceptance.api.plugin.PluginOnStartStopIT$TestModule")
+public class PluginOnStartStopIT extends LightweightPluginDaemonTest {
+  private static final String TEST_PLUGIN = "test-plugin";
+  private static final String TEST_PLUGIN_FILENAME = TEST_PLUGIN + ".jar";
+
+  @Singleton
+  public static class TestStartPluginListener implements StartPluginListener {
+    public volatile Plugin plugin;
+
+    @Override
+    public void onStartPlugin(Plugin plugin) {
+      this.plugin = plugin;
+    }
+  }
+
+  @Singleton
+  public static class TestStopPluginListener implements StopPluginListener {
+    public volatile Plugin plugin;
+    private List<Consumer<Plugin>> beforeStopListeners = new ArrayList<>();
+
+    @Override
+    public void onStopPlugin(Plugin plugin) {
+      this.plugin = plugin;
+    }
+
+    @Override
+    public void beforeStopPlugin(Plugin plugin) {
+      beforeStopListeners.forEach(listener -> listener.accept(plugin));
+    }
+
+    public void addBeforeStopListener(Consumer<Plugin> pluginAsserts) {
+      beforeStopListeners.add(pluginAsserts);
+    }
+  }
+
+  public static class TestModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(StartPluginListener.class)
+          .annotatedWith(UniqueAnnotations.create())
+          .to(TestStartPluginListener.class);
+      bind(StopPluginListener.class)
+          .annotatedWith(UniqueAnnotations.create())
+          .to(TestStopPluginListener.class);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void pluginStartStopListener_calledOnPluginLoadedUnloaded() throws Exception {
+    Injector pluginSysInjector = plugin.getSysInjector();
+    TestStartPluginListener testStartPluginListener =
+        pluginSysInjector.getInstance(TestStartPluginListener.class);
+    TestStopPluginListener testStopPluginListener =
+        pluginSysInjector.getInstance(TestStopPluginListener.class);
+
+    String pluginId = installPlugin(installPluginInput()).get().id;
+    assertThat(pluginId).isEqualTo(TEST_PLUGIN);
+
+    assertThat(testStartPluginListener.plugin).isNotNull();
+    assertThat(testStartPluginListener.plugin.getName()).isEqualTo(TEST_PLUGIN);
+
+    plugin().disable();
+    PluginInfo pluginInfo = plugin().get();
+    assertThat(pluginInfo.id).isEqualTo(TEST_PLUGIN);
+    assertThat(pluginInfo.disabled).isTrue();
+
+    assertThat(testStopPluginListener.plugin).isNotNull();
+    assertThat(testStopPluginListener.plugin.getName()).isEqualTo(TEST_PLUGIN);
+  }
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void pluginStartStopListener_calledOnPluginLoadedAndReloaded() throws Exception {
+    Injector pluginSysInjector = plugin.getSysInjector();
+    TestStartPluginListener testStartPluginListener =
+        pluginSysInjector.getInstance(TestStartPluginListener.class);
+    TestStopPluginListener testStopPluginListener =
+        pluginSysInjector.getInstance(TestStopPluginListener.class);
+
+    String pluginId = installPlugin(installPluginInput()).get().id;
+    assertThat(pluginId).isEqualTo(TEST_PLUGIN);
+
+    assertThat(testStartPluginListener.plugin).isNotNull();
+    assertThat(testStartPluginListener.plugin.getName()).isEqualTo(TEST_PLUGIN);
+
+    testStopPluginListener.addBeforeStopListener(
+        (pluginBeforeStop) -> {
+          assertThat(testStartPluginListener.plugin).isEqualTo(pluginBeforeStop);
+          assertThat(testStopPluginListener.plugin).isNull();
+        });
+
+    plugin().reload();
+
+    assertThat(testStopPluginListener.plugin).isNotNull();
+    assertThat(testStopPluginListener.plugin.getName()).isEqualTo(TEST_PLUGIN);
+  }
+
+  private static @NonNull InstallPluginInput installPluginInput() throws IOException {
+    InstallPluginInput input = new InstallPluginInput();
+    input.raw = pluginContent(TEST_PLUGIN_FILENAME);
+    return input;
+  }
+
+  private PluginApi plugin() throws RestApiException {
+    return gApi.plugins().name(TEST_PLUGIN);
+  }
+
+  private PluginApi installPlugin(InstallPluginInput input) throws RestApiException {
+    return gApi.plugins().install(TEST_PLUGIN_FILENAME, input);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index 5f29062..eeeef99 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -71,6 +71,7 @@
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.permissions.RegexPermissionPolicy;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.GrantPostReviewCommentPermission;
 import com.google.gerrit.server.schema.GrantRevertPermission;
 import com.google.inject.Inject;
 import java.util.Arrays;
@@ -102,6 +103,7 @@
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private GrantRevertPermission grantRevertPermission;
+  @Inject private GrantPostReviewCommentPermission grantPostReviewCommentPermission;
 
   private Project.NameKey newProjectName;
 
@@ -213,6 +215,67 @@
   }
 
   @Test
+  public void grantPostReviewCommentPermission() throws Exception {
+    String ref = "refs/*";
+    String groupId = "global:Registered-Users";
+
+    grantPostReviewCommentPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+    assertThat(info.local.containsKey(ref)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(ref);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.POST_REVIEW_COMMENT)).isTrue();
+    PermissionInfo permissionInfo =
+        accessSectionInfo.permissions.get(Permission.POST_REVIEW_COMMENT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantPostReviewCommentPermissionOnlyWorksOnce() throws Exception {
+    grantPostReviewCommentPermission.execute(newProjectName);
+    grantPostReviewCommentPermission.execute(newProjectName);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
+
+      Permission permission = all.getPermission(Permission.POST_REVIEW_COMMENT);
+      assertThat(permission.getRules()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void grantPostReviewCommentPermissionDoesntOverrideAdminsPreferences() throws Exception {
+    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      projectConfig.upsertAccessSection(
+          AccessSection.ALL,
+          all -> {
+            grant(projectConfig, all, Permission.POST_REVIEW_COMMENT, otherGroup);
+          });
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add Post Review Comment permission for anonymous users\n");
+
+      projectConfig.commit(md);
+    }
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo expected = pApi().access();
+
+    grantPostReviewCommentPermission.execute(newProjectName);
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo actual = pApi().access();
+    // Permissions don't change.
+    assertThat(actual.local).isEqualTo(expected.local);
+  }
+
+  @Test
   public void getDefaultInheritance() throws Exception {
     String inheritedName = pApi().access().inheritsFrom.name;
     assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 09cd037..58f6605 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -1185,10 +1185,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.disabled",
-      // The test intentionally create an implicit merge change.
-      value = "GerritBackendFeature__reject_implicit_merges_on_merge")
   @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void commitsIncludedInRefsMergedChangeNonTipCommit() throws Exception {
     String branchWithChange1 = R_HEADS + "branch-with-change1";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 9c6584e..191832d 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 [acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
index 8635b15..10b7c2e 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
@@ -272,6 +272,37 @@
     assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
   }
 
+  @Test
+  public void previewFixWithUnchangedLinesInsideReplacementRange() throws Exception {
+    // Replacement covers lines 3 to 5, where line 4 is unchanged.
+    String replacement = "Modified third line\nFourth line\nModified fifth line\n";
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, replacement, 3, 0, 6, 0);
+
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+    DiffInfo diff = fixPreview.get(FILE_NAME);
+
+    // Should be split into two replacement hunks around the unchanged line 4.
+    assertThat(diff.content).hasSize(5);
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("First line", "Second line");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("Third line");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("Modified third line");
+    assertThat(diff).content().element(2).commonLines().containsExactly("Fourth line");
+    assertThat(diff).content().element(3).linesOfA().containsExactly("Fifth line");
+    assertThat(diff).content().element(3).linesOfB().containsExactly("Modified fifth line");
+    assertThat(diff)
+        .content()
+        .element(4)
+        .commonLines()
+        .containsExactly(
+            "Sixth line", "Seventh line", "Eighth line", "Ninth line", "Tenth line", "");
+  }
+
   private ApplyProvidedFixInput createApplyProvidedFixInput(
       String file_name,
       String replacement,
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 5abc9dc..6f150ae 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -954,6 +954,63 @@
   }
 
   @Test
+  public void cherryPickSetsCherryPickOfInCommitReceivedEvent() throws Exception {
+    PushOneCommit.Result r = createChange();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "cherry-pick";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(r.getChangeId()).current().cherryPick(in);
+      assertThat(testCommitValidationListener.receiveEvent.cherryPickOf)
+          .isEqualTo(PatchSet.id(r.getChange().getId(), 1));
+    }
+  }
+
+  @Test
+  public void regularPushDoesNotSetCherryPickOfInCommitReceivedEvent() throws Exception {
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      createChange();
+      assertThat(testCommitValidationListener.receiveEvent.cherryPickOf).isNull();
+    }
+  }
+
+  @Test
+  public void pushNewPatchSetToCherryPickedChangeSetsCherryPickOfInCommitReceivedEvent()
+      throws Exception {
+    // Create a change on master.
+    PushOneCommit.Result r = createChange();
+    Change.Id origChangeId = r.getChange().getId();
+
+    // Create branch foo and cherry-pick the change to it.
+    BranchInput branchInput = new BranchInput();
+    branchInput.revision = r.getCommit().getParent(0).name();
+    gApi.projects().name(project.get()).branch("foo").create(branchInput);
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = "foo";
+    cherryPickInput.message = r.getCommit().getFullMessage();
+    ChangeApi cherryPicked =
+        gApi.changes().id(r.getChangeId()).current().cherryPick(cherryPickInput);
+    String cherryPickedChangeId = cherryPicked.get().changeId;
+
+    // Push a new patchset to the cherry-picked change and verify that cherryPickOf is set
+    // in the CommitReceivedEvent during push validation.
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      amendChange(cherryPickedChangeId, "refs/for/foo", admin, testRepo);
+      assertThat(testCommitValidationListener.receiveEvent.cherryPickOf)
+          .isEqualTo(PatchSet.id(origChangeId, 1));
+    }
+  }
+
+  @Test
   public void cherryPickToExistingChangeUpdatesCherryPickOf() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
@@ -1793,7 +1850,7 @@
           }
 
           @Override
-          public void onChangeDeleted(int id) {}
+          public void onChangeDeleted(String projectName, int id) {}
         };
 
     try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
@@ -2526,7 +2583,7 @@
     ImmutableList<FakeEmailSender.Message> messages = sender.getMessages();
     FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(m.body()).contains(admin.fullName() + " has uploaded a new patch set (#2).");
+    assertThat(m.body()).contains(admin.getNameEmail() + " has uploaded a new patch set (#2).");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 4ccb80b..4a740cd 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -37,6 +37,8 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
@@ -499,7 +501,7 @@
         .committer(new PersonIdent(admin.newIdent(), testRepo.getInstant()))
         .create();
     PushResult result = pushHead(testRepo, "refs/for/master");
-    assertThat(result.getMessages()).contains("warning: pushing without Change-Id is deprecated");
+    assertThat(result.getMessages()).contains("WARNING: pushing without Change-Id is deprecated");
   }
 
   @Test
@@ -805,6 +807,30 @@
   }
 
   @Test
+  public void authorRemainsReviewerOnNewPatchSet() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    String changeId = r.getChangeId();
+
+    TestAccount user = accountCreator.user1();
+    gApi.changes().id(changeId).addReviewer(user.email());
+
+    PushOneCommit push =
+        pushFactory.create(user.newIdent(), testRepo, "Subject", "file.txt", "content", changeId);
+    r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    Collection<AccountInfo> reviewers = changeInfo.reviewers.get(REVIEWER);
+    assertThat(reviewers).isNotNull();
+    assertThat(reviewers.stream().anyMatch(a -> a._accountId == user.id().get())).isTrue();
+
+    Collection<AccountInfo> ccs = changeInfo.reviewers.get(CC);
+    if (ccs != null) {
+      assertThat(ccs.stream().anyMatch(a -> a._accountId == user.id().get())).isFalse();
+    }
+  }
+
+  @Test
   public void pushForMasterWithReviewerByEmail() throws Exception {
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
@@ -1904,7 +1930,7 @@
     r = push.to("refs/for/master");
     r.assertOkStatus();
 
-    indexer.delete(r.getChange().getId());
+    indexer.delete(project, r.getChange().getId());
 
     assertPushRejected(
         pushHead(testRepo, "refs/for/master", false),
@@ -3027,7 +3053,7 @@
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
         .contains(
-            "warning: no changes between prior commit "
+            "WARNING: no changes between prior commit "
                 + abbreviateName(c)
                 + " and new commit "
                 + abbreviateName(amended));
@@ -3056,7 +3082,8 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains("warning: " + abbreviateName(amended) + ": no files changed, message updated");
+        .contains(
+            "WARNING: commit " + abbreviateName(amended) + ": no files changed, message updated");
   }
 
   @Test
@@ -3080,7 +3107,8 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains("warning: " + abbreviateName(amended) + ": no files changed, author changed");
+        .contains(
+            "WARNING: commit " + abbreviateName(amended) + ": no files changed, author changed");
   }
 
   @Test
@@ -3107,7 +3135,7 @@
     pr = pushHead(testRepo, r, false);
     assertPushOk(pr, r);
     assertThat(pr.getMessages())
-        .contains("warning: " + abbreviateName(amended) + ": no files changed, was rebased");
+        .contains("WARNING: commit " + abbreviateName(amended) + ": no files changed, was rebased");
   }
 
   @Test
@@ -3336,6 +3364,50 @@
   }
 
   @Test
+  public void pushWithEmptySilentReviewerIsRejected() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("r=:silent"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("reviewer identifier cannot be empty");
+  }
+
+  @Test
+  public void pushWithEmptySilentCcIsRejected() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("cc=:silent"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("CC identifier cannot be empty");
+  }
+
+  @Test
+  public void pushWithSilentCcPushOption() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("cc=" + user.email() + ":silent"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS);
+    Collection<AccountInfo> ccs =
+        firstNonNull(ci.reviewers.get(ReviewerState.CC), ImmutableList.<AccountInfo>of());
+    assertThat(ccs.stream().map(a -> a._accountId).collect(toList()))
+        .containsExactly(user.id().get());
+  }
+
+  @Test
+  public void pushWithSilentReviewerPushOption() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("r=" + user.email() + ":silent"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS);
+    Collection<AccountInfo> reviewers =
+        firstNonNull(ci.reviewers.get(ReviewerState.REVIEWER), ImmutableList.<AccountInfo>of());
+    assertThat(reviewers.stream().map(a -> a._accountId).collect(toList()))
+        .containsExactly(user.id().get());
+  }
+
+  @Test
   public void pushWithInvalidBaseIsRejected() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%base=invalid");
     r.assertErrorStatus("expected SHA1 for option --base: invalid");
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitConfigIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitConfigIT.java
new file mode 100644
index 0000000..f72b7fc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitConfigIT.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.util.CommitMessageUtil.generateChangeId;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that receive.rejectImplicitMerges controls implicit merge checks on submit.
+ *
+ * <p>All tests use the same commit graph, where the change targeted to stable has a parent from
+ * master.
+ */
+public class ImplicitMergeOnSubmitConfigIT extends AbstractImplicitMergeTest {
+  @ConfigSuite.Configs
+  public static ImmutableMap<String, Config> configs() {
+    ImmutableMap.Builder<String, Config> builder = ImmutableMap.builder();
+    for (SubmitType submitType : SubmitType.values()) {
+      if (submitType == SubmitType.INHERIT
+          || submitType == SubmitType.CHERRY_PICK
+          || submitType == SubmitType.REBASE_ALWAYS) {
+        continue;
+      }
+      Config cfg = new Config();
+      cfg.setString("test", null, "submitType", submitType.name());
+      builder.put(String.format("submitType=%s", submitType), cfg);
+    }
+    return builder.buildOrThrow();
+  }
+
+  private String implicitMergeChangeId;
+  private String explicitMergeChangeId;
+
+  @Before
+  public void setUp() throws Exception {
+    String submitTypeValue = cfg.getString("test", null, "submitType");
+    assume().that(submitTypeValue).isNotEmpty();
+    RevCommit base = repo().parseCommit(repo().exactRef("HEAD").getObjectId());
+    RevCommit stableBranchTip =
+        pushTo("refs/heads/stable", ImmutableMap.of("stable-content", "stable-first-line\n"), base)
+            .getCommit();
+    RevCommit masterBranchTip =
+        pushTo(
+                "refs/heads/master",
+                ImmutableMap.of("master-content", "master-first-line\n"),
+                stableBranchTip)
+            .getCommit();
+    implicitMergeChangeId = "I" + generateChangeId().name();
+    RevCommit implicitMergeChange =
+        createChangeWithoutPush(
+            implicitMergeChangeId,
+            ImmutableMap.of("master-content2", "added-by-implicit-merge\n"),
+            masterBranchTip);
+    explicitMergeChangeId =
+        pushTo(
+                "refs/for/stable",
+                ImmutableMap.of("stable-content", "stable-first-line\nadded-by-explicit-merge\n"),
+                implicitMergeChange,
+                stableBranchTip)
+            .getChangeId();
+    gApi.changes().id(implicitMergeChangeId).current().review(ReviewInput.approve());
+    gApi.changes().id(explicitMergeChangeId).current().review(ReviewInput.approve());
+    setSubmitType(SubmitType.valueOf(submitTypeValue));
+  }
+
+  @Test
+  public void implicitMergeRejectedByDefault() throws Exception {
+    assertThatImplicitMergeSubmitRejected();
+  }
+
+  @Test
+  public void explicitMergeAllowedByDefault() throws Exception {
+    assertThatExplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  public void rejectImplicitMergesFalse_allowsImplicitMerge() throws Exception {
+    setRejectImplicitMerges(/* reject= */ false);
+    assertThatImplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  public void rejectImplicitMergesFalse_allowsExplicitMerge() throws Exception {
+    setRejectImplicitMerges(/* reject= */ false);
+    assertThatExplicitMergeSubmitAllowed();
+  }
+
+  private void assertThatImplicitMergeSubmitRejected() throws Exception {
+    ResourceConflictException e =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(implicitMergeChangeId).current().submit());
+    assertThat(e.getMessage().toLowerCase()).contains("submit makes implicit merge to the branch");
+    ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
+    assertThat(ci.submitted).isNull();
+    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+        .containsExactly("stable-content", "stable-first-line\n");
+  }
+
+  private void assertThatImplicitMergeSubmitAllowed() throws Exception {
+    gApi.changes().id(implicitMergeChangeId).current().submit();
+
+    ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
+    assertThat(ci.submitted).isNotNull();
+    assertThat(ci.submitter).isNotNull();
+    assertThat(ci.submitter._accountId)
+        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
+
+    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+        .containsExactly(
+            "master-content", "master-first-line\n",
+            "master-content2", "added-by-implicit-merge\n",
+            "stable-content", "stable-first-line\n");
+  }
+
+  private void assertThatExplicitMergeSubmitAllowed() throws Exception {
+    gApi.changes().id(explicitMergeChangeId).current().submit();
+
+    ChangeInfo ci = gApi.changes().id(explicitMergeChangeId).info();
+    assertThat(ci.submitted).isNotNull();
+    assertThat(ci.submitter).isNotNull();
+    assertThat(ci.submitter._accountId)
+        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
+    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+        .containsExactly(
+            "master-content", "master-first-line\n",
+            "master-content2", "added-by-implicit-merge\n",
+            "stable-content", "stable-first-line\nadded-by-explicit-merge\n");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
deleted file mode 100644
index a974a92..0000000
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
+++ /dev/null
@@ -1,335 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.util.CommitMessageUtil.generateChangeId;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.testing.ConfigSuite;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
-import org.junit.Test;
-
-/**
- * Verifies that gerrit correctly rejects or submits implicit merges depending on experiments.
- *
- * <p>All tests use the same commit configuration (master branch is one commit ahead of stable
- * branch):
- *
- * <pre>{@code
- * change[1] (target - stable, explicit merge of stable branch and master branches)
- * |         \
- * |         change[0] (target - stable, i.e. implicit merge of master and stable branches)
- * |          |
- * |        master
- * |           |
- * stable <--- |
- * }</pre>
- */
-public class ImplicitMergeOnSubmitExperimentsIT extends AbstractImplicitMergeTest {
-  @Override
-  protected boolean enableExperimentsRejectImplicitMergesOnMerge() {
-    // Tests uses own experiment setup.
-    return false;
-  }
-
-  @ConfigSuite.Configs
-  public static ImmutableMap<String, Config> configs() {
-    // The @RunWith(Parameterized.class) can't be used, because AbstractDaemonClass already
-    // uses @RunWith(ConfigSuite.class). Emulate parameters using configs.
-    ImmutableMap.Builder<String, Config> builder = ImmutableMap.builder();
-    for (SubmitType submitType : SubmitType.values()) {
-      if (submitType == SubmitType.INHERIT
-          || submitType == SubmitType.CHERRY_PICK
-          || submitType == SubmitType.REBASE_ALWAYS) {
-        continue;
-      }
-      Config cfg = new Config();
-      cfg.setString("test", null, "submitType", submitType.name());
-      builder.put(String.format("submitType=%s", submitType), cfg);
-    }
-    return builder.buildOrThrow();
-  }
-
-  private String implicitMergeChangeId;
-  private String explicitMergeChangeId;
-
-  private SubmitType submitType;
-
-  @Before
-  public void setUp() throws Exception {
-    // The ConfigSuite runner always adds a default config. Ignore it (submitType is not set for
-    // it).
-    assume().that(cfg.getString("test", null, "submitType")).isNotEmpty();
-    RevCommit base = repo().parseCommit(repo().exactRef("HEAD").getObjectId());
-    RevCommit stableBranchTip =
-        pushTo("refs/heads/stable", ImmutableMap.of("stable-content", "stable-first-line\n"), base)
-            .getCommit();
-    RevCommit masterBranchTip =
-        pushTo(
-                "refs/heads/master",
-                ImmutableMap.of("master-content", "master-first-line\n"),
-                stableBranchTip)
-            .getCommit();
-    implicitMergeChangeId = "I" + generateChangeId().name();
-    RevCommit implicitMergeChange =
-        createChangeWithoutPush(
-            implicitMergeChangeId,
-            ImmutableMap.of("master-content2", "added-by-implicit-merge\n"),
-            masterBranchTip);
-    explicitMergeChangeId =
-        pushTo(
-                "refs/for/stable",
-                ImmutableMap.of("stable-content", "stable-first-line\nadded-by-explicit-merge\n"),
-                implicitMergeChange,
-                stableBranchTip)
-            .getChangeId();
-    gApi.changes().id(implicitMergeChangeId).current().review(ReviewInput.approve());
-    gApi.changes().id(explicitMergeChangeId).current().review(ReviewInput.approve());
-    submitType = SubmitType.valueOf(cfg.getString("test", null, "submitType"));
-    setSubmitType(submitType);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
-      })
-  public void alwaysRejectOnMerge_rejectImplicitMergeFalse_rejectImplicitMergeOnSubmit()
-      throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatImplicitMergeSubmitRejected();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
-      })
-  public void alwaysRejectOnMerge_rejectImplicitMergeFalse_canSubmitExplicitMerge()
-      throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
-      })
-  public void alwaysRejectOnMerge_rejectImplicitMergeTrue_rejectImplicitMergeOnSubmit()
-      throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatImplicitMergeSubmitRejected();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
-      })
-  public void alwaysRejectOnMerge_rejectImplicitMergeTrue_canSubmitExplicitMerge()
-      throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-      })
-  public void rejectOnMerge_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatImplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-      })
-  public void rejectOnMerge_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-      })
-  public void rejectOnMerge_rejectImplicitMergeTrue_rejectImplicitMergeOnSubmit() throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatImplicitMergeSubmitRejected();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-        "GerritBackendFeature__reject_implicit_merges_on_merge",
-      })
-  public void rejectOnMerge_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-      })
-  public void checkOnly_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatImplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-      })
-  public void checkOnly_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-      })
-  public void checkOnly_rejectImplicitMergeTrue_canSubmitImplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatImplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        "GerritBackendFeature__check_implicit_merges_on_merge",
-      })
-  public void checkOnly_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  public void noExperiments_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatImplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  public void noExperiments_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ false);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  public void noExperiments_rejectImplicitMergeTrue_canSubmitImplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatImplicitMergeSubmitAllowed();
-  }
-
-  @Test
-  public void noExperiments_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
-    setRejectImplicitMerges(/* reject= */ true);
-    assertThatExcplicitMergeSubmitAllowed();
-  }
-
-  private void assertThatImplicitMergeSubmitRejected() throws Exception {
-    ResourceConflictException e =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(implicitMergeChangeId).current().submit());
-    assertThat(e.getMessage().toLowerCase()).contains("submit makes implicit merge to the branch");
-    ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
-    assertThat(ci.submitted).isNull();
-    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
-        .containsExactly("stable-content", "stable-first-line\n");
-  }
-
-  private void assertThatImplicitMergeSubmitAllowed() throws Exception {
-    gApi.changes().id(implicitMergeChangeId).current().submit();
-
-    ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
-    assertThat(ci.submitted).isNotNull();
-    assertThat(ci.submitter).isNotNull();
-    assertThat(ci.submitter._accountId)
-        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
-
-    if (submitType != SubmitType.REBASE_ALWAYS) {
-      assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
-          .containsExactly(
-              "master-content", "master-first-line\n",
-              "master-content2", "added-by-implicit-merge\n",
-              "stable-content", "stable-first-line\n");
-    } else {
-      assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
-          .containsExactly(
-              "master-content2", "added-by-implicit-merge\n",
-              "stable-content", "stable-first-line\n");
-    }
-  }
-
-  private void assertThatExcplicitMergeSubmitAllowed() throws Exception {
-    gApi.changes().id(explicitMergeChangeId).current().submit();
-
-    ChangeInfo ci = gApi.changes().id(explicitMergeChangeId).info();
-    assertThat(ci.submitted).isNotNull();
-    assertThat(ci.submitter).isNotNull();
-    assertThat(ci.submitter._accountId)
-        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
-    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
-        .containsExactly(
-            "master-content", "master-first-line\n",
-            "master-content2", "added-by-implicit-merge\n",
-            "stable-content", "stable-first-line\nadded-by-explicit-merge\n");
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
index 1c74165..b7c5743 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
@@ -19,7 +19,6 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -42,10 +41,10 @@
  * }</pre>
  *
  * Tests use only MergeAlways strategy. All other submit strategies (except cherry pick and rebase
- * always) use the same checks on submit. The {@link ImplicitMergeOnSubmitExperimentsIT} validates
- * that the implicit merge check is applied to all strategies (except cherry pick and rebase always)
- * and {@link ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT} contains tests for the cherry pick
- * and rebase always strategies.
+ * always) use the same checks on submit. The {@link ImplicitMergeOnSubmitConfigIT} validates that
+ * the implicit merge check is applied to all strategies (except cherry pick and rebase always), and
+ * {@link ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT} contains tests for the cherry pick and
+ * rebase always strategies.
  */
 public class ImplicitMergeOnSubmitIT extends AbstractImplicitMergeTest {
   private RevCommit masterTip;
@@ -55,6 +54,7 @@
   @Before
   public void setUp() throws Exception {
     setSubmitType(SubmitType.MERGE_ALWAYS);
+    setRejectImplicitMerges(/* reject= */ false);
     gApi.projects().name(project.get()).branch("other").create(new BranchInput());
     baseCommit =
         repo()
@@ -70,14 +70,12 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void singleChangeImplicitMerge() throws Exception {
     PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
     assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void chainOfChangesImplicitMerge() throws Exception {
     PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
     PushOneCommit.Result c1 = createApprovedChange("master", implicitMerge);
@@ -110,7 +108,6 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void chainOfChangesNotOnTopOfTargetBranchTipWithImplicitMerge() throws Exception {
     // Add one more commit to master branch.
     pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
@@ -123,7 +120,6 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void chainOfChangesEndsWithExplicitMerge_onlyExplcitMergeCanBeSubmitted()
       throws Exception {
     PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
@@ -136,7 +132,6 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void twoChainOfChangesSameTopic_oneChainImplicitMerge_rejectedOnSubmit() throws Exception {
     cfg.setBoolean("change", null, "submitWholeTopic", true);
     PushOneCommit.Result c1 = createApprovedChange("master", masterTip);
@@ -212,7 +207,6 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void twoChainOfChangesEndsWithExplicitMergeSameTopicNotTipOfBranches_canBeSubmitted()
       throws Exception {
     cfg.setBoolean("change", null, "submitWholeTopic", true);
@@ -242,7 +236,6 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void twoChainOfChangesDifferentBranchesSameTopic_oneChainImplicitMerge_rejectedOnSubmit()
       throws Exception {
     cfg.setBoolean("change", null, "submitWholeTopic", true);
@@ -268,7 +261,6 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void explicitMergeOnTopOfChain_onlyTopSubmittable() throws Exception {
     PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
     PushOneCommit.Result im1 = createApprovedChange("master", implicitMerge);
@@ -282,7 +274,6 @@
   }
 
   @Test
-  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void explicitMergeOnTopOfChainParentIsNotBranchTip_onlyTopSubmittable() throws Exception {
     // Add one more commit to master and other branches.
     pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
@@ -319,6 +310,7 @@
   }
 
   private void assertSubmitRejectedWithImplicitMerge(String changeId) throws Exception {
+    setRejectImplicitMerges();
     ResourceConflictException e =
         assertThrows(
             ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
@@ -326,6 +318,7 @@
   }
 
   private void assertThatChangeSubmittable(String changeId) throws Exception {
+    setRejectImplicitMerges();
     ChangeInfo ci = gApi.changes().id(changeId).current().submit();
     assertThat(ci.submitted).isNotNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index 8f011f8..9a69013 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -45,6 +45,7 @@
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Collection;
 import java.util.function.Consumer;
 import org.eclipse.jgit.lib.Config;
@@ -64,8 +65,8 @@
   public void reindexFromScratch() throws Exception {
     setUpChange();
 
-    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
-    Files.createDirectory(sitePaths.index_dir);
+    MoreFiles.deleteRecursively(sitePaths.resolve("index"), RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.createDirectory(sitePaths.resolve("index"));
     assertServerStartupFails();
 
     runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
@@ -111,8 +112,8 @@
     updateConfig(config -> config.setBoolean("index", null, "reuseExistingDocuments", true));
     setUpChange();
 
-    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
-    Files.createDirectory(sitePaths.index_dir);
+    MoreFiles.deleteRecursively(sitePaths.resolve("index"), RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.createDirectory(sitePaths.resolve("index"));
     assertServerStartupFails();
 
     runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
@@ -121,7 +122,7 @@
     runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
     assertIndexQueries();
 
-    Files.copy(sitePaths.index_dir, sitePaths.resolve("index-backup"));
+    Files.copy(sitePaths.resolve("index"), sitePaths.resolve("index-backup"));
     try (ServerContext ctx = startServer()) {
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       gApi.changes().id(changeId).revision(1).review(ReviewInput.approve());
@@ -129,8 +130,8 @@
       assertThat(gApi.changes().query("label:Code-Review+2").get().stream().map(c -> c.changeId))
           .containsExactly(changeId);
     }
-    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
-    Files.copy(sitePaths.resolve("index-backup"), sitePaths.index_dir);
+    MoreFiles.deleteRecursively(sitePaths.resolve("index"), RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.copy(sitePaths.resolve("index-backup"), sitePaths.resolve("index"));
     runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
     try (ServerContext ctx = startServer()) {
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -139,6 +140,30 @@
     }
   }
 
+  @Test
+  public void reindexWithCustomIndexDirectory() throws Exception {
+    // Create a change against default index location, then update index.directory
+    setUpChange();
+    updateConfig(config -> config.setString("index", null, "directory", "custom-index"));
+
+    // Gerrit will refuse to start until that directory is (re)indexed.
+    assertServerStartupFails();
+
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
+    Path customIndexDir = sitePaths.resolve("custom-index");
+    assertWithMessage("custom index.directory directory")
+        .that(Files.exists(customIndexDir))
+        .isTrue();
+    assertWithMessage("gerrit_index.config under custom index.directory")
+        .that(Files.exists(customIndexDir.resolve("gerrit_index.config")))
+        .isTrue();
+
+    GerritIndexStatus status = new GerritIndexStatus(customIndexDir);
+    assertThat(status.getReady(CHANGES, ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion()))
+        .isTrue();
+    assertIndexQueries();
+  }
+
   private void assertIndexQueries() throws Exception {
     try (ServerContext ctx = startServer()) {
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -253,7 +278,7 @@
     int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
 
     // Before storing any changes, switch back to the previous version.
-    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths.resolve("index"));
     status.setReady(CHANGES, currVersion, false);
     status.setReady(CHANGES, prevVersion, true);
     status.save();
@@ -361,7 +386,7 @@
   private void assertReady(int expectedReady) throws Exception {
     ImmutableSortedSet<Integer> allVersions =
         ChangeSchemaDefinitions.INSTANCE.getSchemas().keySet();
-    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths.resolve("index"));
     assertWithMessage("ready state for index versions")
         .that(
             allVersions.stream().collect(toImmutableMap(v -> v, v -> status.getReady(CHANGES, v))))
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index b79977e..5a648ca 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -72,13 +72,13 @@
 
     // Simulate a projects indexes files modified in the past by 3 seconds
     Optional<Instant> projectsLastModified =
-        getProjectsIndexLastModified(sitePaths.index_dir).map(t -> t.minusSeconds(3));
+        getProjectsIndexLastModified(sitePaths.resolve("index")).map(t -> t.minusSeconds(3));
     assertThat(projectsLastModified).isPresent();
-    setProjectsIndexLastModifiedInThePast(sitePaths.index_dir, projectsLastModified.get());
+    setProjectsIndexLastModifiedInThePast(sitePaths.resolve("index"), projectsLastModified.get());
 
     initSite();
     Optional<Instant> projectsLastModifiedAfterInit =
-        getProjectsIndexLastModified(sitePaths.index_dir);
+        getProjectsIndexLastModified(sitePaths.resolve("index"));
 
     // Verify that projects index files haven't been updated
     assertThat(projectsLastModified).isEqualTo(projectsLastModifiedAfterInit);
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
index 5a6c5c5..e695254 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -277,7 +277,7 @@
 
     assertExistentSr(
         /* srName */ "Foo",
-        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\"",
+        /* applicabilityExpression= */ "branch:\"refs/heads/master\"",
         /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
         /* canOverride= */ true);
     assertLabelFunction("Foo", "NoBlock");
@@ -299,8 +299,52 @@
 
     assertExistentSr(
         /* srName */ "Foo",
-        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
-            + "OR branch:\\\"refs/heads/develop\\\"",
+        /* applicabilityExpression= */ "branch:\"refs/heads/master\" "
+            + "OR branch:\"refs/heads/develop\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withQuotesInBranchNameAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/gerr\"it"));
+
+    assertNonExistentSr(/* srName= */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName= */ "Foo",
+        /* applicabilityExpression= */ "branch:\"refs/heads/gerr\\\"it\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withHashInBranchNameAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/gerr#it"));
+
+    assertNonExistentSr(/* srName= */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName= */ "Foo",
+        /* applicabilityExpression= */ "branch:\"refs/heads/gerr#it\"",
         /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
         /* canOverride= */ true);
     assertLabelFunction("Foo", "NoBlock");
@@ -321,8 +365,30 @@
     assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
 
     assertExistentSr(
-        /* srName */ "Foo",
-        /* applicabilityExpression= */ "branch:\\\"^refs/heads/main-.*\\\"",
+        /* srName= */ "Foo",
+        /* applicabilityExpression= */ "branch:^refs/heads/main-.*",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withWildcardBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/release/*"));
+
+    assertNonExistentSr(/* srName= */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName= */ "Foo",
+        /* applicabilityExpression= */ "branch:^\\Qrefs/heads/release/\\E.*",
         /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
         /* canOverride= */ true);
     assertLabelFunction("Foo", "NoBlock");
@@ -344,8 +410,8 @@
 
     assertExistentSr(
         /* srName */ "Foo",
-        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
-            + "OR branch:\\\"^refs/heads/main-.*\\\"",
+        /* applicabilityExpression= */ "branch:\"refs/heads/master\" "
+            + "OR branch:^refs/heads/main-.*",
         /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
         /* canOverride= */ true);
     assertLabelFunction("Foo", "NoBlock");
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index c3b1672..b8f805c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -1195,7 +1195,7 @@
     }
 
     @Override
-    public void onChangeDeleted(int id) {}
+    public void onChangeDeleted(String projectName, int id) {}
   }
 
   private static class TraceSubmitRule implements SubmitRule {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountLoaderIT.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountLoaderIT.java
new file mode 100644
index 0000000..1157fea
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountLoaderIT.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2026 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 com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class AccountLoaderIT extends AbstractDaemonTest {
+  @Inject private AccountLoader.Factory accountLoaderFactory;
+
+  @Test
+  public void fillAccounts_detailed() throws Exception {
+    TestAccount user1 = accountCreator.create("u1", "u1@example.com", "User One", "U1");
+    TestAccount user2 = accountCreator.create("u2", "u2@example.com", "User Two", "U2");
+
+    AccountLoader loader = accountLoaderFactory.create(true);
+    AccountInfo info1 = loader.get(user1.id());
+    AccountInfo info2 = loader.get(user2.id());
+
+    assertThat(info1.name).isNull();
+    assertThat(info2.name).isNull();
+
+    loader.fill();
+
+    assertThat(info1._accountId).isEqualTo(user1.id().get());
+    assertThat(info1.name).isEqualTo("User One");
+    assertThat(info1.email).isEqualTo("u1@example.com");
+    assertThat(info1.username).isEqualTo("u1");
+
+    assertThat(info2._accountId).isEqualTo(user2.id().get());
+    assertThat(info2.name).isEqualTo("User Two");
+    assertThat(info2.email).isEqualTo("u2@example.com");
+    assertThat(info2.username).isEqualTo("u2");
+  }
+
+  @Test
+  public void fillAccounts_idOnly() throws Exception {
+    TestAccount user1 = accountCreator.create("u_id1", "uid1@example.com", "User Id Only", null);
+
+    AccountLoader loader = accountLoaderFactory.create(false);
+    AccountInfo info = loader.get(user1.id());
+    loader.fill();
+
+    assertThat(info._accountId).isEqualTo(user1.id().get());
+    assertThat(info.name).isNull();
+    assertThat(info.email).isNull();
+  }
+
+  @Test
+  public void fillAccounts_missingAccount() throws Exception {
+    Account.Id missingId = Account.id(999999);
+    AccountLoader loader = accountLoaderFactory.create(true);
+    AccountInfo info = loader.get(missingId);
+    loader.fill();
+
+    assertThat(info._accountId).isEqualTo(999999);
+    assertThat(info.deleted).isTrue();
+    assertThat(info.name).isNull();
+  }
+
+  @Test
+  public void fillAccounts_duplicateInstances() throws Exception {
+    TestAccount user1 = accountCreator.create("dup1", "dup1@example.com", "Dup User", null);
+
+    AccountLoader loader = accountLoaderFactory.create(true);
+    AccountInfo prime = loader.get(user1.id());
+    AccountInfo dup1 = new AccountInfo(user1.id().get());
+    AccountInfo dup2 = new AccountInfo(user1.id().get());
+    loader.put(dup1);
+    loader.put(dup2);
+
+    loader.fill();
+
+    assertThat(prime.name).isEqualTo("Dup User");
+    assertThat(dup1.name).isEqualTo("Dup User");
+    assertThat(dup1.email).isEqualTo("dup1@example.com");
+    assertThat(dup2.name).isEqualTo("Dup User");
+    assertThat(dup2.email).isEqualTo("dup1@example.com");
+  }
+
+  @Test
+  public void fillOne() throws Exception {
+    TestAccount user1 = accountCreator.create("fill_one", "fillone@example.com", "Fill One", null);
+
+    AccountLoader loader = accountLoaderFactory.create(true);
+    AccountInfo info = loader.fillOne(user1.id());
+
+    assertThat(info).isNotNull();
+    assertThat(info._accountId).isEqualTo(user1.id().get());
+    assertThat(info.name).isEqualTo("Fill One");
+    assertThat(info.email).isEqualTo("fillone@example.com");
+  }
+
+  @Test
+  public void fillAccounts_empty() throws Exception {
+    AccountLoader loader = accountLoaderFactory.create(true);
+    loader.fill();
+    assertThat(loader.get(null)).isNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 9140a71..45e95db 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -97,6 +97,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.Submit;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -1042,12 +1043,12 @@
     // Cherry-pick strategy does not query from index
     assume().that(getSubmitType()).isNotEqualTo(CHERRY_PICK);
     // retry on index
-    PushOneCommit.Result change = createChange();
+    ChangeData change = createChange().getChange();
 
     // Submit using full change Id to avoid using index.
-    String id = change.getChange().project() + "~" + change.getChange().getId().get();
+    String id = change.project() + "~" + change.getId().get();
     approve(id);
-    changeIndex.delete(change.getChange().getId());
+    changeIndex.delete(change.project(), change.getId());
 
     TestSubmitInput input = new TestSubmitInput();
 
@@ -1076,14 +1077,14 @@
       throws Throwable {
     // Cherry-pick strategy does not query from the index
     assume().that(getSubmitType()).isNotEqualTo(CHERRY_PICK);
-    PushOneCommit.Result change = createChange();
+    ChangeData change = createChange().getChange();
 
     // Submit using full change Id to avoid using index.
-    String id = change.getChange().project() + "~" + change.getChange().getId().get();
+    String id = change.project() + "~" + change.getId().get();
     approve(id);
 
     // Delete the change from the index, to ensure the use of the backfill mechanism
-    changeIndex.delete(change.getChange().getId());
+    changeIndex.delete(change.project(), change.getId());
 
     testMetricMaker.reset();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index a89e30a..6d34fe5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.html.HtmlEscapers;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -161,7 +162,7 @@
         .contains(
             String.format(
                 "%s requires the attention of %s to this change.\n The reason is: first.",
-                user.fullName(), admin.fullName()));
+                user.getNameEmail(), admin.fullName()));
 
     // Update the reason
     sender.clear();
@@ -179,7 +180,7 @@
         .contains(
             String.format(
                 "%s requires the attention of %s to this change.\n The reason is: second.",
-                user.fullName(), admin.fullName()));
+                user.getNameEmail(), admin.fullName()));
   }
 
   @Test
@@ -200,7 +201,7 @@
         .contains(
             String.format(
                 "%s requires the attention of %s to this change.\n The reason is: reason.",
-                user.fullName(), admin.fullName()));
+                user.getNameEmail(), admin.fullName()));
 
     // Second add with the same reason is ignored.
     sender.clear();
@@ -338,7 +339,7 @@
     String emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
     assertThat(emailBody)
         .contains(
-            user.fullName()
+            user.getNameEmail()
                 + " removed themselves from the attention set of this change.\n"
                 + " The reason is: removed.");
   }
@@ -2058,7 +2059,7 @@
         .contains(
             String.format(
                 "%s requires the attention of %s to this change.\n The reason is: Added by %s.",
-                user.fullName(), admin.fullName(), user.getNameEmail()));
+                user.getNameEmail(), admin.fullName(), user.getNameEmail()));
   }
 
   @Test
@@ -2078,7 +2079,7 @@
             String.format(
                 "%s removed themselves from the attention set of this change.\n"
                     + " The reason is: Removed by %s.",
-                user.fullName(), user.getNameEmail()));
+                user.getNameEmail(), user.getNameEmail()));
   }
 
   @Test
@@ -2586,7 +2587,8 @@
             String.format(
                 "<p> Attention is currently required from: %s. </p>\n"
                     + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
-                user.fullName(), admin.fullName()));
+                user.fullName(),
+                HtmlEscapers.htmlEscaper().escape(admin.getNameEmail().toString())));
     assertThat(message.htmlBody())
         .contains(
             String.format(
@@ -2699,7 +2701,8 @@
             String.format(
                 "<p> Attention is currently required from: %s. </p>\n"
                     + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
-                user.fullName(), admin.fullName()));
+                user.fullName(),
+                HtmlEscapers.htmlEscaper().escape(admin.getNameEmail().toString())));
     assertThat(message.htmlBody())
         .contains(
             String.format(
@@ -2809,7 +2812,9 @@
             String.format(
                 "<p> Attention is currently required from: %s, %s. </p>\n"
                     + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
-                user.fullName(), user2.fullName(), admin.fullName()));
+                user.fullName(),
+                user2.fullName(),
+                HtmlEscapers.htmlEscaper().escape(admin.getNameEmail().toString())));
     assertThat(message.htmlBody())
         .contains(
             String.format(
@@ -2879,7 +2884,7 @@
         .contains(
             String.format(
                 "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
-                admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(admin.getNameEmail().toString())));
     assertThat(message.htmlBody())
         .contains(
             String.format(
@@ -2961,7 +2966,7 @@
         .contains(
             String.format(
                 "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
-                admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(admin.getNameEmail().toString())));
     assertThat(message.htmlBody())
         .doesNotContain("The following approvals got outdated and were removed:");
   }
@@ -3045,7 +3050,7 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
@@ -3131,7 +3136,7 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
     assertThat(message.body()).contains("\nPatch Set 2: Code-Review+1\n");
     assertThat(message.body())
@@ -3219,7 +3224,7 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.body()).contains("\nPatch Set 2: Code-Review-2\n");
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index 36ed19b..633dccf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -62,10 +62,10 @@
   /** Captures the arguments this extension point was actually invoked with. */
   private static class CapturingExternalIncludedIn implements ExternalIncludedIn {
     String capturedProject;
+    Integer capturedChangeNumber;
     String capturedCommit;
     Collection<String> capturedTags;
     Collection<String> capturedBranches;
-    private Integer capturedChangeNumber;
 
     @Override
     public ImmutableListMultimap<String, String> getIncludedIn(
@@ -74,8 +74,8 @@
         String commit,
         Collection<String> tags,
         Collection<String> branches) {
-      capturedChangeNumber = changeNumber;
       capturedProject = project;
+      capturedChangeNumber = changeNumber;
       capturedCommit = commit;
       capturedTags = tags;
       capturedBranches = branches;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
index bf4cf13..5af03ca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.common.collect.Iterables;
+import com.google.common.html.HtmlEscapers;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -86,12 +87,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -135,12 +137,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -185,12 +188,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -310,12 +314,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body())
         .contains(
             "The change is no longer submittable:"
@@ -367,12 +372,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -418,12 +424,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -460,12 +467,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -502,12 +510,13 @@
                 "Attention is currently required from: %s, %s.\n"
                     + "\n"
                     + "%s has posted comments on this change by %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.htmlBody())
         .contains(
             String.format(
                 "<p>%s has posted comments on this change by %s.</p>",
-                approver.fullName(), admin.fullName()));
+                HtmlEscapers.htmlEscaper().escape(approver.getNameEmail().toString()),
+                admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -558,7 +567,7 @@
                 admin.fullName(),
                 user.fullName(),
                 approver.fullName(),
-                uploaderPs3.fullName(),
+                uploaderPs3.getNameEmail(),
                 admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
@@ -631,7 +640,7 @@
                     + "\n"
                     + "%s has uploaded a new patch set (#3) to the change originally created by"
                     + " %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -703,7 +712,7 @@
                     + "\n"
                     + "%s has uploaded a new patch set (#3) to the change originally created by"
                     + " %s.",
-                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), approver.getNameEmail(), admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
     assertThat(message.htmlBody())
@@ -780,7 +789,7 @@
                 admin.fullName(),
                 user.fullName(),
                 uploaderPs3.fullName(),
-                uploaderPs3.fullName(),
+                uploaderPs3.getNameEmail(),
                 admin.fullName()));
     assertThat(message.body())
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
@@ -843,7 +852,7 @@
                     + "\n"
                     + "%s has uploaded a new patch set (#3) to the change originally created by"
                     + " %s.",
-                admin.fullName(), user.fullName(), uploaderPs3.fullName(), admin.fullName()));
+                admin.fullName(), user.fullName(), uploaderPs3.getNameEmail(), admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -904,7 +913,7 @@
                 user.fullName(),
                 approver.fullName(),
                 uploaderPs3.fullName(),
-                uploaderPs3.fullName(),
+                uploaderPs3.getNameEmail(),
                 admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
@@ -930,7 +939,7 @@
         .contains(
             String.format(
                 "%s has uploaded a new patch set (#3) to the change originally created by %s.",
-                uploaderPs3.fullName(), admin.fullName()));
+                uploaderPs3.getNameEmail(), admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
@@ -981,7 +990,7 @@
                     + "\n"
                     + "%s has uploaded a new patch set (#3) to the change originally created by"
                     + " %s.",
-                uploaderPs3.fullName(), uploaderPs3.fullName(), admin.fullName()));
+                uploaderPs3.fullName(), uploaderPs3.getNameEmail(), admin.fullName()));
     assertThat(message.body()).doesNotContain("The change is no longer submittable");
     assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index adcea96..2f4900d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -556,7 +556,10 @@
     assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
     assertThat(m.body())
         .contains(
-            admin.fullName() + " has posted comments on this change by " + admin.fullName() + ".");
+            admin.getNameEmail()
+                + " has posted comments on this change by "
+                + admin.fullName()
+                + ".");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index b1e8ba1..36e8c52 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -196,7 +196,7 @@
     assertThat(messages).hasSize(1);
     FakeEmailSender.Message msg = messages.get(0);
     assertThat(msg.rcpt()).containsExactly(admin.getNameEmail(), user2.getNameEmail());
-    assertThat(msg.body()).contains(user.fullName() + " has removed a vote from this change.");
+    assertThat(msg.body()).contains(user.getNameEmail() + " has removed a vote from this change.");
     assertThat(msg.body())
         .contains("Removed Code-Review+1 by " + admin.fullName() + " <" + admin.email() + ">\n");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 0f41219..88c58b8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -282,10 +282,6 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.disabled",
-      // The test intentionally create an implicit merge change.
-      value = "GerritBackendFeature__reject_implicit_merges_on_merge")
   @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void submitWithMergedAncestorsOnOtherBranch() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
@@ -336,10 +332,7 @@
   }
 
   @Test
-  @GerritConfig(
-      name = "experiments.disabled",
-      // The test intentionally create an implicit merge change.
-      value = "GerritBackendFeature__reject_implicit_merges_on_merge")
+  @GerritConfig(name = "repository.*.defaultConfig", value = "receive.rejectImplicitMerges=false")
   public void submitWithOpenAncestorsOnOtherBranch() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 =
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index 81962e3..3827710 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -17,10 +17,16 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 
+import com.google.common.base.Stopwatch;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,14 +39,24 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 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.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 @NoHttpd
@@ -299,6 +315,200 @@
     assertChangeSetMergeable(d.getChange(), false);
   }
 
+  @Test
+  public void chainWithOutdatedPatchSetParentIsUnmergeable() throws Exception {
+    /*
+      A(ps1) <- B(ps1)
+      A is amended to A(ps2), making B(ps1) depend on an outdated patchset of A.
+    */
+    PushOneCommit.Result a = createChange("A");
+    PushOneCommit.Result b =
+        createChange("B", "b.txt", "content B", ImmutableList.of(a.getCommit()));
+
+    approve(a.getChangeId());
+    approve(b.getChangeId());
+
+    // Initially, both A and B are up-to-date and mergeable together.
+    assertChangeSetMergeable(b.getChange(), true);
+
+    // Amend A to create patch-set 2.
+    testRepo.reset(a.getCommit());
+    PushOneCommit.Result amendResult =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "A amended",
+                "a.txt",
+                "content A amended",
+                a.getChangeId())
+            .to("refs/for/master");
+    amendResult.assertOkStatus();
+    approve(a.getChangeId());
+
+    // Now B's parent points to PS1 of A, which is outdated.
+    assertChangeSetMergeable(b.getChange(), false);
+    // Root change A directly based on destination branch tip is mergeable.
+    assertChangeSetMergeable(a.getChange(), true);
+
+    // Rebase B on A's latest patch-set.
+    gApi.changes().id(b.getChangeId()).rebase();
+    approve(b.getChangeId());
+
+    // Now B is mergeable again.
+    assertChangeSetMergeable(b.getChange(), true);
+    assertChangeSetMergeable(a.getChange(), true);
+  }
+
+  @Test
+  public void reproducePerformanceWithManyPatchSetsInChain() throws Exception {
+    // Create a chain of 4 changes: c0 -> c1 -> c2 -> c3
+    int chainLength = 4;
+    int patchSetsPerChange = 10;
+    List<PushOneCommit.Result> chain = new ArrayList<>();
+    RevCommit parentCommit = null;
+
+    for (int i = 0; i < chainLength; i++) {
+      PushOneCommit.Result change =
+          createChange(
+              testRepo,
+              "Change " + i,
+              "file_" + i + ".txt",
+              "initial content " + i,
+              parentCommit != null ? ImmutableList.of(parentCommit) : ImmutableList.of());
+      approve(change.getChangeId());
+      // Create additional patch sets for this change
+      for (int ps = 2; ps <= patchSetsPerChange; ps++) {
+        PushOneCommit.Result amendResult =
+            amendChange(
+                change.getChangeId(),
+                "Change " + i + " ps" + ps,
+                "file_" + i + ".txt",
+                "content " + i + " v" + ps);
+        amendResult.assertOkStatus();
+        approve(change.getChangeId());
+        if (ps == patchSetsPerChange) {
+          parentCommit = amendResult.getCommit();
+        }
+      }
+      chain.add(change);
+    }
+
+    PushOneCommit.Result tip = chain.get(chainLength - 1);
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(
+                tip.getChange().change(), user(admin), /* includingTopicClosure= */ false);
+
+    // Warm up both paths
+    for (int i = 0; i < 5; i++) {
+      var unused1 = submit.getUnmergeableChanges(cs);
+      var unused2 = getUnmergeableChangesBaseline(cs);
+    }
+
+    int iterations = 100;
+
+    // Benchmark Baseline (Unoptimized: eager NoteDb scans + per-change repo opens)
+    Stopwatch baselineTimer = Stopwatch.createStarted();
+    Set<ChangeData> baselineResult = null;
+    for (int i = 0; i < iterations; i++) {
+      baselineResult = getUnmergeableChangesBaseline(cs);
+    }
+    baselineTimer.stop();
+    long baselineMs = baselineTimer.elapsed(TimeUnit.MILLISECONDS);
+
+    // Benchmark Optimized (O(1) commit parent check + batched repo lifecycle)
+    Stopwatch optTimer = Stopwatch.createStarted();
+    java.util.Collection<ChangeData> optResult = null;
+    for (int i = 0; i < iterations; i++) {
+      optResult = submit.getUnmergeableChanges(cs);
+    }
+    optTimer.stop();
+    long optMs = optTimer.elapsed(TimeUnit.MILLISECONDS);
+
+    double speedup = (double) baselineMs / Math.max(optMs, 1);
+    System.out.printf(
+        "\n"
+            + "=======================================================\n"
+            + "PERFORMANCE REPRODUCTION BENCHMARK (%d changes x %d patch sets = %d total patch"
+            + " sets, %d iterations):\n"
+            + "  - Baseline (unoptimized NoteDb eager scan + per-change repo): %d ms (avg %.3f"
+            + " ms/call)\n"
+            + "  - Optimized (bounded parent check + single repo lifecycle):    %d ms (avg %.3f"
+            + " ms/call)\n"
+            + "  - Speedup factor: %.2fx faster!\n"
+            + "=======================================================\n\n",
+        chainLength,
+        patchSetsPerChange,
+        chainLength * patchSetsPerChange,
+        iterations,
+        baselineMs,
+        (double) baselineMs / iterations,
+        optMs,
+        (double) optMs / iterations,
+        speedup);
+
+    // Verify behavioral parity
+    assertThat(optResult).containsExactlyElementsIn(baselineResult);
+    assertThat(optResult).isEmpty(); // All changes in chain are up-to-date and mergeable
+  }
+
+  @Nullable
+  private Set<ChangeData> getUnmergeableChangesBaseline(ChangeSet cs) throws Exception {
+    Set<ChangeData> unmergeableChanges = new HashSet<>();
+    Set<ObjectId> outDatedPatchSets = new HashSet<>();
+    for (ChangeData change : cs.changes()) {
+      unmergeableChanges.add(change);
+      // Baseline eagerly loads all patch sets from NoteDb
+      outDatedPatchSets.addAll(
+          change.notes().getPatchSets().values().stream()
+              .map(PatchSet::commitId)
+              .collect(Collectors.toSet()));
+      outDatedPatchSets.remove(change.currentPatchSet().commitId());
+    }
+
+    ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
+    for (BranchNameKey branch : cbb.keySet()) {
+      List<ChangeData> targetBranch = cbb.get(branch);
+      HashMap<Change.Id, RevCommit> commits = new HashMap<>();
+      try (Repository repo = repoManager.openRepository(branch.project());
+          RevWalk walk = new RevWalk(repo)) {
+        for (ChangeData change : targetBranch) {
+          RevCommit commit = walk.parseCommit(change.currentPatchSet().commitId());
+          commits.put(change.getId(), commit);
+        }
+      }
+      Set<ObjectId> allParents =
+          commits.values().stream()
+              .flatMap(c -> Arrays.stream(c.getParents()))
+              .map(RevObject::getId)
+              .collect(Collectors.toSet());
+      for (ChangeData change : targetBranch) {
+        RevCommit commit = commits.get(change.getId());
+        boolean isMergeCommit = commit.getParentCount() > 1;
+        boolean isLastInChain = !allParents.contains(commit.getId());
+        if (Arrays.stream(commit.getParents())
+            .anyMatch(c -> outDatedPatchSets.contains(c.getId()))) {
+          continue;
+        }
+        change.setMergeable(null);
+        Boolean mergeable = change.isMergeable();
+        if (mergeable == null) {
+          return null;
+        }
+        if (mergeable) {
+          unmergeableChanges.remove(change);
+        }
+        if (isLastInChain && isMergeCommit && mergeable) {
+          targetBranch.stream().forEach(unmergeableChanges::remove);
+          break;
+        }
+      }
+    }
+    return unmergeableChanges;
+  }
+
   private void submit(String changeId) throws Exception {
     gApi.changes().id(changeId).current().submit();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
index 614ce80..416f8d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
@@ -16,36 +16,69 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
 import com.google.gerrit.server.restapi.config.IndexChanges;
 import com.google.inject.Inject;
+import java.util.Optional;
+import org.eclipse.jgit.junit.TestRepository;
+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.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.junit.Test;
 
 public class IndexChangesIT extends AbstractDaemonTest {
+  private static final String TEST_CHANGE_NUM = "1";
+  private static final String TEST_CHANGE_ID = "I8350971af868ee34b17fc8703aa9ef40c03f5ec5";
+  private static final boolean PRESERVE_MISSING = false;
+  private static final boolean DELETE_MISSING = true;
 
   @Inject private ProjectOperations projectOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private ChangeIndexCollection changeIndexCollection;
+  @Inject private IndexConfig indexConfig;
+  @Inject private ChangeNumberVirtualIdAlgorithm changeNumberVirtualIdAlgorithm;
 
   @Test
   public void indexRequestFromNonAdminRejected() throws Exception {
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      String changeId = createChange().getChangeId();
-      IndexChanges.Input in = new IndexChanges.Input();
-      in.changes = ImmutableSet.of(changeId);
+      PushOneCommit.Result change = createChange();
       changeIndexedCounter.clear();
-      userRestSession.post("/config/server/index.changes", in).assertForbidden();
-      assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(0);
+      userRestSession
+          .post("/config/server/index.changes", indexChangesInput(change.getChange().getId()))
+          .assertForbidden();
+      assertThat(changeIndexedCounter.getCount(info(change.getChangeId()))).isEqualTo(0);
     }
   }
 
@@ -54,51 +87,204 @@
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      String changeId = createChange().getChangeId();
-      IndexChanges.Input in = new IndexChanges.Input();
-      in.changes = ImmutableSet.of(changeId);
+      PushOneCommit.Result change = createChange();
       changeIndexedCounter.clear();
-      adminRestSession.post("/config/server/index.changes", in).assertOK();
-      assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
+      adminRestSession
+          .post("/config/server/index.changes", indexChangesInput(change.getChange().getId()))
+          .assertOK();
+      assertThat(changeIndexedCounter.getCount(info(change.getChangeId()))).isEqualTo(1);
     }
   }
 
   @Test
+  public void indexChangeNotInIndex() throws Exception {
+    PushOneCommit.Result change = createChange();
+    Change.Id changeId = change.getChange().getId();
+
+    assertThat(getChangeFromIndex(changeId)).isPresent();
+    indexer.delete(project, changeId);
+    assertThat(getChangeFromIndex(changeId)).isEmpty();
+
+    adminRestSession.post("/config/server/index.changes", indexChangesInput(changeId)).assertOK();
+    assertThat(getChangeFromIndex(changeId)).isPresent();
+  }
+
+  @Test
   public void indexNonVisibleChange() throws Exception {
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      String changeId = createChange().getChangeId();
+      String changeId = projectAndChangeNumId(project, createChange().getChange().getId());
       ChangeInfo changeInfo = info(changeId);
       projectOperations
           .project(project)
           .forUpdate()
           .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
           .update();
-      IndexChanges.Input in = new IndexChanges.Input();
       changeIndexedCounter.clear();
-      in.changes = ImmutableSet.of(changeId);
-      adminRestSession.post("/config/server/index.changes", in).assertOK();
+      adminRestSession.post("/config/server/index.changes", indexChangesInput(changeId)).assertOK();
       assertThat(changeIndexedCounter.getCount(changeInfo)).isEqualTo(1);
     }
   }
 
   @Test
+  public void indexChangeWithPlainNumericIdAccepted() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    adminRestSession
+        .post("/config/server/index.changes", indexChangesInput(String.valueOf(changeId.get())))
+        .assertOK();
+  }
+
+  @Test
+  public void deleteMissingChangeFromIndexWithPlainNumericIdRejected() throws Exception {
+    adminRestSession
+        .post("/config/server/index.changes", indexChangesInput(TEST_CHANGE_NUM, DELETE_MISSING))
+        .assertBadRequest();
+  }
+
+  @Test
+  public void indexChangeWithTripletIdAccepted() throws Exception {
+    String changeId = createChange().getChangeId();
+    adminRestSession
+        .post("/config/server/index.changes", indexChangesInput(project + "~master~" + changeId))
+        .assertOK();
+  }
+
+  @Test
+  public void deleteMissingChangeFromIndexWithTripletIdRejected() throws Exception {
+    adminRestSession
+        .post(
+            "/config/server/index.changes",
+            indexChangesInput(project + "~master~" + TEST_CHANGE_ID, DELETE_MISSING))
+        .assertBadRequest();
+  }
+
+  @Test
+  public void deleteMissingChangeFromIndexByProjectAndNumericId() throws Exception {
+    PushOneCommit.Result result = createChange();
+    Change.Id changeId = result.getChange().getId();
+
+    assertThat(getChangeFromIndex(changeId)).isPresent();
+    deleteChangeFromNoteDbWithoutUpdatingIndex(changeId);
+    assertThat(getChangeFromIndex(changeId)).isPresent();
+
+    adminRestSession
+        .post(
+            "/config/server/index.changes",
+            indexChangesInput(projectAndChangeNumId(project, changeId), DELETE_MISSING))
+        .assertOK();
+
+    assertThat(getChangeFromIndex(changeId)).isEmpty();
+  }
+
+  @Test
   public void indexMultipleChanges() throws Exception {
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
       ImmutableSet.Builder<String> changeIds = ImmutableSet.builder();
       for (int i = 0; i < 10; i++) {
-        changeIds.add(createChange().getChangeId());
+        changeIds.add(projectAndChangeNumId(project, createChange().getChange().getId()));
       }
-      IndexChanges.Input in = new IndexChanges.Input();
-      in.changes = changeIds.build();
+      IndexChanges.Input in = new IndexChanges.Input(changeIds.build(), PRESERVE_MISSING);
       changeIndexedCounter.clear();
       adminRestSession.post("/config/server/index.changes", in).assertOK();
-      for (String changeId : in.changes) {
+      for (String changeId : in.changes()) {
         assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
       }
     }
   }
+
+  @Test
+  @GerritConfig(name = "gerrit.importedServerId", value = "imported-server-id")
+  public void deleteMissingImportedChangeFromIndex() throws Exception {
+    PushOneCommit.Result result = createImportedChange();
+    Change.Id changeId = result.getChange().getId();
+    Change.Id virtualId =
+        changeNumberVirtualIdAlgorithm.apply(() -> "imported-server-id", changeId);
+
+    assertThat(getChangeFromIndex(virtualId)).isPresent();
+    deleteChangeFromNoteDbWithoutUpdatingIndex(changeId);
+    assertThat(getChangeFromIndex(virtualId)).isPresent();
+
+    adminRestSession
+        .post("/config/server/index.changes", indexChangesInput(changeId, PRESERVE_MISSING))
+        .assertOK();
+    assertThat(getChangeFromIndex(virtualId)).isPresent();
+
+    adminRestSession
+        .post("/config/server/index.changes", indexChangesInput(changeId, DELETE_MISSING))
+        .assertOK();
+    assertThat(getChangeFromIndex(virtualId)).isEmpty();
+  }
+
+  private IndexChanges.Input indexChangesInput(String changeId) {
+    return new IndexChanges.Input(ImmutableSet.of(changeId), PRESERVE_MISSING);
+  }
+
+  private IndexChanges.Input indexChangesInput(Change.Id changeId) {
+    return indexChangesInput(projectAndChangeNumId(project, changeId));
+  }
+
+  private IndexChanges.Input indexChangesInput(String changeId, boolean deleteMissing) {
+    return new IndexChanges.Input(ImmutableSet.of(changeId), deleteMissing);
+  }
+
+  private IndexChanges.Input indexChangesInput(Change.Id changeId, boolean deleteMissing) {
+    return indexChangesInput(projectAndChangeNumId(project, changeId), deleteMissing);
+  }
+
+  private PushOneCommit.Result createImportedChange() throws Exception {
+    PushOneCommit.Result change = createChange();
+    Change.Id changeId = change.getChange().getId();
+    String metaRef = changeMetaRef(changeId);
+
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter inserter = repo.newObjectInserter();
+        ObjectReader reader = repo.newObjectReader();
+        RevWalk revWalk = new RevWalk(reader);
+        var ignored = openTestRefUpdateContext()) {
+
+      Ref ref = repo.getRefDatabase().exactRef(metaRef);
+      RevCommit tip = revWalk.parseCommit(ref.getObjectId());
+
+      CommitBuilder commit = new CommitBuilder();
+      commit.setTreeId(tip.getTree());
+      commit.setAuthor(
+          new PersonIdent("Gerrit User " + admin.id(), admin.id() + "@imported-server-id"));
+      commit.setCommitter(new PersonIdent("Gerrit Code Review", admin.email()));
+      commit.setMessage(tip.getFullMessage());
+
+      ObjectId commitId = inserter.insert(commit);
+      inserter.flush();
+
+      RefUpdate refUpdate = repo.updateRef(metaRef);
+      refUpdate.setNewObjectId(commitId);
+      refUpdate.forceUpdate();
+    }
+
+    // Re-index after rewriting the meta-ref so the index reflects the imported serverId,
+    // ensuring the virtualId in the index matches what the API will compute at delete time.
+    indexer.delete(project, changeId);
+    indexer.index(project, changeId);
+
+    return change;
+  }
+
+  private void deleteChangeFromNoteDbWithoutUpdatingIndex(Change.Id changeId) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
+      testRepo.delete(RefNames.changeMetaRef(changeId));
+    }
+  }
+
+  Optional<ChangeData> getChangeFromIndex(Change.Id changeId) {
+    ChangeIndex idx = changeIndexCollection.getSearchIndex();
+    QueryOptions opts = IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of());
+    return idx.get(changeId, opts);
+  }
+
+  String projectAndChangeNumId(Project.NameKey project, Change.Id changeNum) {
+    return project + "~" + changeNum;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
index 95de918..59a941d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
@@ -131,7 +131,7 @@
   private File verifySnapshot(Response<?> rsp) {
     assertThat(rsp.value()).isInstanceOf(SnapshotInfo.class);
     SnapshotInfo snapshotInfo = (SnapshotInfo) rsp.value();
-    Path snapshotDir = sitePaths.index_dir.resolve("snapshots").resolve(snapshotInfo.id);
+    Path snapshotDir = sitePaths.resolve("index").resolve("snapshots").resolve(snapshotInfo.id);
     File snapshot = snapshotDir.toFile();
     assertThat(snapshot.exists()).isTrue();
     assertThat(snapshot.isDirectory()).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index a987225..7f2e39e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -21,6 +21,7 @@
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.extensions.common.CacheInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.Arrays;
@@ -88,4 +89,36 @@
   public void listCaches_BadRequest() throws Exception {
     adminRestSession.get("/config/server/caches/?format=NONSENSE").assertBadRequest();
   }
+
+  @Test
+  public void listCaches_withoutIncludeDiskStats_memCacheUnaffected() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/");
+    r.assertOK();
+    Map<String, CacheInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
+
+    assertThat(result).containsKey("accounts");
+    CacheInfo accountsCacheInfo = result.get("accounts");
+    assertThat(accountsCacheInfo.type).isEqualTo(CacheInfo.CacheType.MEM);
+    assertThat(accountsCacheInfo.entries.mem).isAtLeast(1L);
+    assertThat(accountsCacheInfo.hitRatio.mem).isAtLeast(0);
+
+    assertThat(accountsCacheInfo.entries.disk).isNull();
+    assertThat(accountsCacheInfo.hitRatio.disk).isNull();
+  }
+
+  @Test
+  @UseLocalDisk
+  public void listCaches_withIncludeDiskStats_diskCacheHasDiskStats() throws Exception {
+    RestResponse r = adminRestSession.get("/config/server/caches/?include-diskstats=true");
+    r.assertOK();
+    Map<String, CacheInfo> result =
+        newGson().fromJson(r.getReader(), new TypeToken<Map<String, CacheInfo>>() {}.getType());
+
+    assertThat(result).containsKey("accounts");
+    CacheInfo accountsInfo = result.get("accounts");
+    assertThat(accountsInfo.type).isEqualTo(CacheInfo.CacheType.DISK);
+    assertThat(accountsInfo.entries.mem).isNotNull();
+    assertThat(accountsInfo.entries.disk).isNotNull();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateCommitIT.java
new file mode 100644
index 0000000..fb7644a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateCommitIT.java
@@ -0,0 +1,557 @@
+// Copyright (C) 2026 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput.FileChange;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.events.RefReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateCommitIT extends AbstractDaemonTest {
+  private static final String BRANCH = "master";
+
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void setUp() throws Exception {
+    // Seed master with a submitted file (PushOneCommit.FILE_NAME / FILE_CONTENT).
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+    revision(change).submit();
+  }
+
+  @Test
+  public void directCreateNewFile() throws Exception {
+    RestResponse r =
+        adminRestSession.post(commitUrl(), write("Add new file", "new/file.txt", "hello"));
+    r.assertOK();
+    CommitInfo commit = newGson().fromJson(r.getReader(), CommitInfo.class);
+    assertThat(commit.commit).isNotNull();
+    assertThat(readFile("new/file.txt")).isEqualTo("hello");
+  }
+
+  @Test
+  public void directUpdateExistingFile() throws Exception {
+    RestResponse r =
+        adminRestSession.post(
+            commitUrl(), write("Update", PushOneCommit.FILE_NAME, "updated body"));
+    r.assertOK();
+    assertThat(readFile(PushOneCommit.FILE_NAME)).isEqualTo("updated body");
+  }
+
+  @Test
+  public void directMultiFileWriteAndDelete() throws Exception {
+    // First add a file we will later delete, plus the existing seeded file.
+    adminRestSession.post(commitUrl(), write("Add doomed", "doomed.txt", "bye")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Write one, delete one";
+    input.files = new HashMap<>();
+    input.files.put("kept.txt", contentChange("kept"));
+    input.files.put("doomed.txt", deleteChange());
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    r.assertOK();
+
+    assertThat(readFile("kept.txt")).isEqualTo("kept");
+    RestResponse doomed =
+        adminRestSession.get(
+            String.format(
+                "/projects/%s/branches/%s/files/doomed.txt/content", project.get(), BRANCH));
+    doomed.assertNotFound();
+  }
+
+  @Test
+  public void directRenameFile() throws Exception {
+    // Seed a file, then rename it in a single commit.
+    adminRestSession.post(commitUrl(), write("Add original", "original.txt", "body")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename original.txt to renamed.txt";
+    input.files = new HashMap<>();
+    input.files.put("renamed.txt", renameChange("original.txt"));
+    adminRestSession.post(commitUrl(), input).assertOK();
+
+    assertThat(readFile("renamed.txt")).isEqualTo("body");
+    RestResponse original =
+        adminRestSession.get(
+            String.format(
+                "/projects/%s/branches/%s/files/original.txt/content", project.get(), BRANCH));
+    original.assertNotFound();
+  }
+
+  @Test
+  public void directWithMatchingBaseRevisionSucceeds() throws Exception {
+    CreateCommitInput input = write("Guarded", "guarded.txt", "ok");
+    input.baseRevision = branchTip();
+    adminRestSession.post(commitUrl(), input).assertOK();
+  }
+
+  @Test
+  public void directWithStaleBaseRevisionIsRejected() throws Exception {
+    String stale = branchTip();
+    // Advance the branch so `stale` is no longer the tip.
+    adminRestSession.post(commitUrl(), write("Advance", "advance.txt", "x")).assertOK();
+
+    CreateCommitInput input = write("Stale", "stale.txt", "y");
+    input.baseRevision = stale;
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(409);
+  }
+
+  @Test
+  public void directNoOpIsRejected() throws Exception {
+    // Writing the existing content unchanged produces no tree change.
+    RestResponse r =
+        adminRestSession.post(
+            commitUrl(), write("No-op", PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT));
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directWithoutPushPermissionIsForbidden() throws Exception {
+    RestResponse r = userRestSession.post(commitUrl(), write("Nope", "x.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(403);
+  }
+
+  @Test
+  public void directWithNonexistentBaseRevisionIsRejected() throws Exception {
+    CreateCommitInput input = write("Bad base", "bad.txt", "y");
+    input.baseRevision = "0123456789012345678901234567890123456789";
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(409);
+  }
+
+  @Test
+  public void directWithMalformedBaseRevisionIsRejected() throws Exception {
+    CreateCommitInput input = write("Bad base", "bad.txt", "y");
+    input.baseRevision = "deadbeef";
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directCommitInvokesCommitValidators() throws Exception {
+    // A registered plugin commit validator must see the direct commit, so the endpoint
+    // cannot be used to bypass commit-validation policy.
+    TestCommitValidationListener listener = new TestCommitValidationListener();
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      adminRestSession.post(commitUrl(), write("Validated", "validated.txt", "x")).assertOK();
+      assertThat(listener.receiveEvent).isNotNull();
+      assertThat(listener.receiveEvent.refName).isEqualTo("refs/heads/" + BRANCH);
+    }
+  }
+
+  @Test
+  public void directCommitRejectedByCommitValidator() throws Exception {
+    CommitValidationListener rejecting =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            throw new CommitValidationException("blocked by test validator");
+          }
+        };
+    try (Registration unused = extensionRegistry.newRegistration().add(rejecting)) {
+      RestResponse r = adminRestSession.post(commitUrl(), write("Nope", "blocked.txt", "x"));
+      assertThat(r.getStatusCode()).isEqualTo(409);
+    }
+  }
+
+  @Test
+  public void directCollidingPathsIsRejected() throws Exception {
+    // Seed a file, then rename it while also writing the same source path: both touch src.txt.
+    adminRestSession.post(commitUrl(), write("Add src", "src.txt", "body")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Collide";
+    input.files = new HashMap<>();
+    input.files.put("dst.txt", renameChange("src.txt"));
+    input.files.put("src.txt", contentChange("rewritten"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directEmptyRenameFromIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Bad rename";
+    input.files = new HashMap<>();
+    input.files.put("dst.txt", renameChange(""));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directRenameToSamePathIsRejected() throws Exception {
+    adminRestSession.post(commitUrl(), write("Add self", "self.txt", "body")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename to self";
+    input.files = new HashMap<>();
+    input.files.put("self.txt", renameChange("self.txt"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directBlankCommitMessageIsRejected() throws Exception {
+    RestResponse r = adminRestSession.post(commitUrl(), write("   ", "blankmsg.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directMissingCommitMessageIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.files = new HashMap<>();
+    input.files.put("nomsg.txt", contentChange("x"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directNullBodyIsRejected() throws Exception {
+    RestResponse r =
+        adminRestSession.postRaw(
+            commitUrl(), RawInputUtil.create("null".getBytes(UTF_8), "application/json"));
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directUnsupportedFileModeIsRejected() throws Exception {
+    // 644 is a well-formed octal value but not one of the git file modes Gerrit supports; it is
+    // rejected by the shared downstream validation (Patch.FileMode), not a local list.
+    FileChange unsupported = contentChange("x");
+    unsupported.fileMode = 644;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Unsupported mode";
+    input.files = new HashMap<>();
+    input.files.put("unsupported.txt", unsupported);
+    assertThat(adminRestSession.post(commitUrl(), input).getStatusCode()).isEqualTo(400);
+
+    // A value with non-octal digits is rejected at the boundary (400) rather than causing a 500.
+    FileChange nonOctal = contentChange("x");
+    nonOctal.fileMode = 8;
+    CreateCommitInput input2 = new CreateCommitInput();
+    input2.commitMessage = "Non-octal mode";
+    input2.files = new HashMap<>();
+    input2.files.put("nonoctal.txt", nonOctal);
+    assertThat(adminRestSession.post(commitUrl(), input2).getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directExecutableFileModeSucceeds() throws Exception {
+    FileChange executable = contentChange("#!/bin/sh\n");
+    executable.fileMode = 100755;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Executable";
+    input.files = new HashMap<>();
+    input.files.put("run.sh", executable);
+    adminRestSession.post(commitUrl(), input).assertOK();
+    assertThat(readFile("run.sh")).isEqualTo("#!/bin/sh\n");
+  }
+
+  @Test
+  public void directWriteToRefsMetaConfigIsForbidden() throws Exception {
+    String url =
+        String.format("/projects/%s/branches/%s/commit", project.get(), "refs%2Fmeta%2Fconfig");
+    RestResponse r = adminRestSession.post(url, write("Nope", "project.config", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(403);
+  }
+
+  @Test
+  public void directWriteToHeadIsForbidden() throws Exception {
+    String url = String.format("/projects/%s/branches/HEAD/commit", project.get());
+    RestResponse r = adminRestSession.post(url, write("Nope", "head.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(405);
+  }
+
+  @Test
+  public void directMissingFilesIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "No files";
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directEmptyFilesIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Empty files";
+    input.files = new HashMap<>();
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directInvalidBase64ContentIsRejected() throws Exception {
+    FileChange fc = new FileChange();
+    fc.content = "@@@@"; // not valid base64
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Bad base64";
+    input.files = new HashMap<>();
+    input.files.put("bad.txt", fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directMultipleOperationsOnEntryIsRejected() throws Exception {
+    FileChange fc = contentChange("x");
+    fc.delete = true; // content + delete on one entry
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Ambiguous";
+    input.files = new HashMap<>();
+    input.files.put("ambiguous.txt", fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directEmptyPathIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Empty path";
+    input.files = new HashMap<>();
+    input.files.put("", contentChange("x"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directWriteToReadOnlyProjectIsRejected() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
+      u.save();
+    }
+    RestResponse r = adminRestSession.post(commitUrl(), write("Read only", "ro.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(409);
+  }
+
+  @Test
+  public void directCommitRejectedByRefOperationValidator() throws Exception {
+    TestRefOperationValidationListener listener = new TestRefOperationValidationListener();
+    listener.doReject = true;
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      RestResponse r = adminRestSession.post(commitUrl(), write("Nope", "refblocked.txt", "x"));
+      assertThat(r.getStatusCode()).isEqualTo(409);
+    }
+  }
+
+  @Test
+  public void createCommitViaJavaApi() throws Exception {
+    CreateCommitInput input = write("Via Java API", "api.txt", "x");
+    CommitInfo commit = gApi.projects().name(project.get()).branch(BRANCH).createCommit(input);
+    assertThat(commit.subject).isEqualTo("Via Java API");
+    assertThat(branchTip()).isEqualTo(commit.commit);
+  }
+
+  @Test
+  public void validationOptionsReachCommitValidator() throws Exception {
+    TestCommitValidationListener listener = new TestCommitValidationListener();
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      CreateCommitInput input = write("With options", "commitopt.txt", "x");
+      input.validationOptions = ImmutableMap.of("key", "value");
+      adminRestSession.post(commitUrl(), input).assertOK();
+      assertThat(listener.receiveEvent.pushOptions).containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void validationOptionsReachRefOperationValidator() throws Exception {
+    TestRefOperationValidationListener listener = new TestRefOperationValidationListener();
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      CreateCommitInput input = write("With options", "refopt.txt", "x");
+      input.validationOptions = ImmutableMap.of("key", "value");
+      adminRestSession.post(commitUrl(), input).assertOK();
+      assertThat(listener.refReceivedEvent.pushOptions).containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void directDeleteMissingPathInMixedBatchIsRejected() throws Exception {
+    // A real write plus a delete of a path that does not exist: the delete would otherwise be
+    // silently dropped while the write succeeds.
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Delete missing";
+    input.files = new HashMap<>();
+    input.files.put("real.txt", contentChange("real"));
+    input.files.put("ghost.txt", deleteChange());
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directRenameMissingSourceInMixedBatchIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename missing";
+    input.files = new HashMap<>();
+    input.files.put("kept.txt", contentChange("kept"));
+    input.files.put("moved.txt", renameChange("ghost-src.txt"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directRenameFromDirectoryIsRejected() throws Exception {
+    // A directory source would produce a malformed tree entry (500) if it reached the rename
+    // modification; it must be rejected up front with a 400.
+    adminRestSession.post(commitUrl(), write("Add nested", "dir/a.txt", "body")).assertOK();
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename dir";
+    input.files = new HashMap<>();
+    input.files.put("moved", renameChange("dir"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directDeleteDirectoryIsRejected() throws Exception {
+    adminRestSession.post(commitUrl(), write("Add nested", "ddir/a.txt", "body")).assertOK();
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Delete dir";
+    input.files = new HashMap<>();
+    input.files.put("ddir", deleteChange());
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directFileModeOnDeleteIsRejected() throws Exception {
+    FileChange fc = deleteChange();
+    fc.fileMode = 100644;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Delete with mode";
+    input.files = new HashMap<>();
+    input.files.put(PushOneCommit.FILE_NAME, fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directFileModeOnRenameIsRejected() throws Exception {
+    adminRestSession.post(commitUrl(), write("Add rm src", "rm-src.txt", "body")).assertOK();
+    FileChange fc = renameChange("rm-src.txt");
+    fc.fileMode = 100644;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename with mode";
+    input.files = new HashMap<>();
+    input.files.put("rm-dst.txt", fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directSymlinkFileModeSucceeds() throws Exception {
+    FileChange symlink = contentChange("target/path.txt");
+    symlink.fileMode = 120000;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Symlink";
+    input.files = new HashMap<>();
+    input.files.put("link.txt", symlink);
+    adminRestSession.post(commitUrl(), input).assertOK();
+    assertThat(readFile("link.txt")).isEqualTo("target/path.txt");
+  }
+
+  @Test
+  public void directGitlinkFileModeIsRejected() throws Exception {
+    FileChange gitlink = contentChange("0123456789012345678901234567890123456789");
+    gitlink.fileMode = 160000;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Gitlink";
+    input.files = new HashMap<>();
+    input.files.put("sub", gitlink);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  private String commitUrl() {
+    return String.format("/projects/%s/branches/%s/commit", project.get(), BRANCH);
+  }
+
+  private static FileChange contentChange(String content) {
+    FileChange fc = new FileChange();
+    fc.content = Base64.getEncoder().encodeToString(content.getBytes(UTF_8));
+    return fc;
+  }
+
+  private static FileChange deleteChange() {
+    FileChange fc = new FileChange();
+    fc.delete = true;
+    return fc;
+  }
+
+  private static FileChange renameChange(String from) {
+    FileChange fc = new FileChange();
+    fc.renameFrom = from;
+    return fc;
+  }
+
+  private static CreateCommitInput write(String message, String path, String content) {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = message;
+    input.files = new HashMap<>();
+    input.files.put(path, contentChange(content));
+    return input;
+  }
+
+  private String readFile(String path) throws Exception {
+    return gApi.projects().name(project.get()).branch(BRANCH).file(path).asString();
+  }
+
+  private String branchTip() throws Exception {
+    return gApi.projects().name(project.get()).branch(BRANCH).get().revision;
+  }
+
+  private static class TestRefOperationValidationListener
+      implements RefOperationValidationListener {
+    boolean doReject;
+    RefReceivedEvent refReceivedEvent;
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refReceivedEvent)
+        throws ValidationException {
+      this.refReceivedEvent = refReceivedEvent;
+      if (doReject) {
+        throw new ValidationException("rejected by test ref validator");
+      }
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DiffCommitsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DiffCommitsIT.java
new file mode 100644
index 0000000..3461682
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DiffCommitsIT.java
@@ -0,0 +1,596 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.restapi.project.GetDiffFile;
+import com.google.gerrit.server.restapi.project.ListDiffFiles;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for project-level diff endpoints: {@link ListDiffFiles} and {@link GetDiffFile}. */
+public class DiffCommitsIT extends AbstractDaemonTest {
+  private static final String R_HEADS_MASTER = "refs/heads/master";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private ObjectId initialCommit;
+
+  @Before
+  public void setUp() throws Exception {
+    initialCommit = testRepo.getRepository().resolve("HEAD");
+  }
+
+  @Test
+  public void listDiffFiles_success() throws Exception {
+    // Create a commit with file changes
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Test commit",
+                ImmutableMap.of("file1.txt", "content1", "file2.txt", "content2"))
+            .to("refs/heads/master");
+    result.assertOkStatus();
+
+    ObjectId newCommit = result.getCommit();
+
+    Map<String, FileInfo> files =
+        gApi.projects().name(project.get()).diffFiles(initialCommit.name(), newCommit.name(), true);
+
+    assertThat(files).isNotEmpty();
+    assertThat(files.keySet()).contains("file1.txt");
+    assertThat(files.keySet()).contains("file2.txt");
+  }
+
+  @Test
+  public void listDiffFiles_missingOldParam() throws Exception {
+    PushOneCommit.Result result = createChange();
+    ObjectId newCommit = result.getCommit();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).diffFiles(null, newCommit.name(), true));
+    assertThat(thrown).hasMessageThat().contains("base");
+  }
+
+  @Test
+  public void listDiffFiles_missingNewParam() throws Exception {
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).diffFiles(initialCommit.name(), null, true));
+    assertThat(thrown).hasMessageThat().contains("new commit SHA1");
+  }
+
+  @Test
+  public void listDiffFiles_invalidSha1Format() throws Exception {
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .diffFiles("invalid", initialCommit.name(), true));
+    assertThat(thrown).hasMessageThat().contains("40-character SHA1");
+  }
+
+  @Test
+  public void listDiffFiles_commitNotFound() throws Exception {
+    String nonExistentSha = "0000000000000000000000000000000000000000";
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .diffFiles(initialCommit.name(), nonExistentSha, true));
+    assertThat(thrown).hasMessageThat().contains("Not found");
+  }
+
+  @Test
+  public void listDiffFiles_commitsNotInAncestorRelationship() throws Exception {
+    // Create branch1 at the initial commit first
+    createBranch(BranchNameKey.create(project, "branch1"));
+
+    // Create a commit on master
+    PushOneCommit.Result result1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit 1", "file1.txt", "content1")
+            .to("refs/heads/master");
+    result1.assertOkStatus();
+
+    // Reset to initial commit and push to branch1
+    testRepo.reset(initialCommit);
+
+    PushOneCommit.Result result2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit 2", "file2.txt", "content2")
+            .to("refs/heads/branch1");
+    result2.assertOkStatus();
+
+    // These two commits are siblings (share common ancestor) but neither is ancestor of the other
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .diffFiles(result1.getCommit().name(), result2.getCommit().name(), true));
+    assertThat(thrown).hasMessageThat().contains("ancestor/descendant");
+  }
+
+  @Test
+  public void listDiffFiles_verifyPathVisibility() throws Exception {
+    // Create branch 'visible' at initial commit
+    createBranch(BranchNameKey.create(project, "visible"));
+
+    // Create a commit on master
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Test commit", "file.txt", "content")
+            .to("refs/heads/master");
+    result.assertOkStatus();
+
+    ObjectId newCommit = result.getCommit();
+
+    // Block read permission on master, but keep it on 'visible'
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(R_HEADS_MASTER).group(REGISTERED_USERS))
+        .update();
+
+    // Use non-admin user
+    requestScopeOperations.setApiUser(user.id());
+
+    // Fails because target commit (newCommit) is on a hidden ref
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .diffFiles(initialCommit.name(), newCommit.name(), true));
+    // This fails in CommitsCollection.parse which returns "Not found: <sha1>"
+    assertThat(thrown).hasMessageThat().contains("Not found");
+  }
+
+  @Test
+  public void listDiffFiles_reverseDiff_verifyPathVisibility() throws Exception {
+    // Create branch 'visible' at initial commit
+    createBranch(BranchNameKey.create(project, "visible"));
+
+    // Create a commit on master
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Test commit", "file.txt", "content")
+            .to("refs/heads/master");
+    result.assertOkStatus();
+
+    ObjectId newCommit = result.getCommit();
+
+    // Block read permission on master, but keep it on 'visible'
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(R_HEADS_MASTER).group(REGISTERED_USERS))
+        .update();
+
+    // Use non-admin user
+    requestScopeOperations.setApiUser(user.id());
+
+    // In a reverse diff (comparing descendant to ancestor):
+    // target = initialCommit (visible via 'visible' branch)
+    // base = newCommit (hidden because it's only on 'master')
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .diffFiles(newCommit.name(), initialCommit.name(), true));
+    // This should be caught by verifyPathVisibility which throws "Commit not visible"
+    assertThat(thrown).hasMessageThat().contains("not visible");
+  }
+
+  @Test
+  public void getDiffFile_success() throws Exception {
+    String fileContent = "Line 1\nLine 2\nLine 3\n";
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Test commit", "test-file.txt", fileContent)
+            .to("refs/heads/master");
+    result.assertOkStatus();
+
+    ObjectId newCommit = result.getCommit();
+
+    DiffInfo diffInfo =
+        gApi.projects()
+            .name(project.get())
+            .diffFile(initialCommit.name(), newCommit.name(), "test-file.txt");
+
+    assertThat(diffInfo).isNotNull();
+    assertThat(diffInfo.metaB).isNotNull();
+    assertThat(diffInfo.metaB.name).isEqualTo("test-file.txt");
+  }
+
+  @Test
+  public void getDiffFile_unchangedFile() throws Exception {
+    // When requesting a file that exists but hasn't changed between commits, we should get an
+    // empty diff (no changes)
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Test commit", "existing-file.txt", "content")
+            .to("refs/heads/master");
+    result.assertOkStatus();
+
+    ObjectId commit1 = result.getCommit();
+
+    // Create another commit that doesn't change existing-file.txt
+    PushOneCommit.Result result2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Second commit", "other-file.txt", "other content")
+            .to("refs/heads/master");
+    result2.assertOkStatus();
+    ObjectId commit2 = result2.getCommit();
+
+    // Request diff for existing-file.txt which hasn't changed
+    DiffInfo diffInfo =
+        gApi.projects()
+            .name(project.get())
+            .diffFile(commit1.name(), commit2.name(), "existing-file.txt");
+
+    // Should return a diff result (possibly empty)
+    assertThat(diffInfo).isNotNull();
+  }
+
+  @Test
+  public void getDiffFile_missingOldParam() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Test commit", "file.txt", "content")
+            .to("refs/heads/master");
+    result.assertOkStatus();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .diffFile(null, result.getCommit().name(), "file.txt"));
+    assertThat(thrown).hasMessageThat().contains("base");
+  }
+
+  @Test
+  public void getDiffFile_verifyPathVisibility() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Test commit", "file.txt", "content")
+            .to("refs/heads/master");
+    result.assertOkStatus();
+
+    ObjectId newCommit = result.getCommit();
+
+    // Block read permission on master
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(R_HEADS_MASTER).group(REGISTERED_USERS))
+        .update();
+
+    // Use non-admin user
+    requestScopeOperations.setApiUser(user.id());
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .diffFile(initialCommit.name(), newCommit.name(), "file.txt"));
+    assertThat(thrown).hasMessageThat().contains("not visible");
+  }
+
+  @Test
+  public void listDiffFiles_modifiedFile() throws Exception {
+    // First create a file
+    PushOneCommit.Result result1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Initial commit", "file.txt", "initial content")
+            .to("refs/heads/master");
+    result1.assertOkStatus();
+    ObjectId commit1 = result1.getCommit();
+
+    // Then modify the file
+    PushOneCommit.Result result2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Modify file", "file.txt", "modified content")
+            .to("refs/heads/master");
+    result2.assertOkStatus();
+    ObjectId commit2 = result2.getCommit();
+
+    Map<String, FileInfo> files =
+        gApi.projects().name(project.get()).diffFiles(commit1.name(), commit2.name(), true);
+
+    assertThat(files).containsKey("file.txt");
+    FileInfo fileInfo = files.get("file.txt");
+    // Modified files should not have a status (status is null for MODIFIED)
+    assertThat(fileInfo.status).isNull();
+  }
+
+  @Test
+  public void listDiffFiles_deletedFile() throws Exception {
+    // Create a file
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Add file", "file-to-check.txt", "content")
+            .to("refs/heads/master");
+    result.assertOkStatus();
+    ObjectId commitWithFile = result.getCommit();
+
+    // When comparing from the commit with the file to initialCommit (which doesn't have it),
+    // the file should appear as deleted
+    Map<String, FileInfo> files =
+        gApi.projects()
+            .name(project.get())
+            .diffFiles(commitWithFile.name(), initialCommit.name(), true);
+
+    assertThat(files).containsKey("file-to-check.txt");
+    FileInfo fileInfo = files.get("file-to-check.txt");
+    assertThat(fileInfo.status).isEqualTo('D');
+  }
+
+  @Test
+  public void listDiffFiles_addedFile() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Add file", "new-file.txt", "new content")
+            .to("refs/heads/master");
+    result.assertOkStatus();
+    ObjectId newCommit = result.getCommit();
+
+    Map<String, FileInfo> files =
+        gApi.projects().name(project.get()).diffFiles(initialCommit.name(), newCommit.name(), true);
+
+    assertThat(files).containsKey("new-file.txt");
+    FileInfo fileInfo = files.get("new-file.txt");
+    assertThat(fileInfo.status).isEqualTo('A');
+  }
+
+  @Test
+  public void listDiffFiles_includesFilesFromIntermediateCommits() throws Exception {
+    // Create a chain of commits: initial -> commit1 (adds file1) -> commit2 (adds file2)
+    // When comparing initial to commit2, we should see both file1 and file2
+
+    // Commit 1: Add file1.txt
+    PushOneCommit.Result result1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Add file1", "file1.txt", "content of file1")
+            .to("refs/heads/master");
+    result1.assertOkStatus();
+
+    // Commit 2: Add file2.txt (doesn't touch file1.txt)
+    PushOneCommit.Result result2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Add file2", "file2.txt", "content of file2")
+            .to("refs/heads/master");
+    result2.assertOkStatus();
+    ObjectId commit2 = result2.getCommit();
+
+    // Compare initial (grandparent) to commit2 (grandchild)
+    // Should include file1.txt from commit1 and file2.txt from commit2
+    Map<String, FileInfo> files =
+        gApi.projects().name(project.get()).diffFiles(initialCommit.name(), commit2.name(), true);
+
+    // Both files should be present
+    assertThat(files.keySet()).contains("file1.txt");
+    assertThat(files.keySet()).contains("file2.txt");
+
+    // Both should be marked as added
+    assertThat(files.get("file1.txt").status).isEqualTo('A');
+    assertThat(files.get("file2.txt").status).isEqualTo('A');
+  }
+
+  @Test
+  public void listDiffFiles_includesFilesFromMultipleIntermediateCommits() throws Exception {
+    // Create a longer chain: initial -> c1 -> c2 -> c3 -> c4
+    // When comparing initial to c4, all files from intermediate commits should be visible
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit 1", "a.txt", "a")
+            .to("refs/heads/master");
+    r1.assertOkStatus();
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit 2", "b.txt", "b")
+            .to("refs/heads/master");
+    r2.assertOkStatus();
+
+    PushOneCommit.Result r3 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit 3", "c.txt", "c")
+            .to("refs/heads/master");
+    r3.assertOkStatus();
+
+    PushOneCommit.Result r4 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit 4", "d.txt", "d")
+            .to("refs/heads/master");
+    r4.assertOkStatus();
+    ObjectId commit4 = r4.getCommit();
+
+    // Compare initial to commit4 - should see all 4 files
+    Map<String, FileInfo> files =
+        gApi.projects().name(project.get()).diffFiles(initialCommit.name(), commit4.name(), true);
+
+    assertThat(files.keySet()).contains("a.txt");
+    assertThat(files.keySet()).contains("b.txt");
+    assertThat(files.keySet()).contains("c.txt");
+    assertThat(files.keySet()).contains("d.txt");
+  }
+
+  @Test
+  public void listDiffFiles_includesFilesAfterRebase() throws Exception {
+    // Scenario: Create parent change, child change, amend parent, rebase child.
+    // The project-level diff should show ALL files, not filter out "rebase-only" changes.
+    //
+    // Setup:
+    // 1. Create change1 with fileA.txt
+    // 2. Create change2 (child of change1) with fileB.txt
+    // 3. Amend change1 to add fileC.txt
+    // 4. Rebase change2 on the amended change1
+    // 5. Compare initialCommit to rebased change2 - should see fileA.txt, fileB.txt, fileC.txt
+
+    // Create change1 with fileA.txt
+    PushOneCommit.Result change1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Change 1", "fileA.txt", "content A")
+            .to("refs/for/master");
+    change1.assertOkStatus();
+    String change1Id = change1.getChangeId();
+
+    // Create change2 (child of change1) with fileB.txt
+    PushOneCommit.Result change2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Change 2", "fileB.txt", "content B")
+            .to("refs/for/master");
+    change2.assertOkStatus();
+    String change2Id = change2.getChangeId();
+
+    // Amend change1 to add fileC.txt
+    testRepo.reset(change1.getCommit());
+    PushOneCommit.Result amendResult =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Change 1 amended",
+                ImmutableMap.of("fileA.txt", "content A", "fileC.txt", "content C"),
+                change1Id)
+            .to("refs/for/master");
+    amendResult.assertOkStatus();
+
+    // Approve and submit change1
+    gApi.changes()
+        .id(change1Id)
+        .current()
+        .review(com.google.gerrit.extensions.api.changes.ReviewInput.approve());
+    gApi.changes().id(change1Id).current().submit();
+
+    // Rebase change2 on top of the submitted change1
+    gApi.changes().id(change2Id).rebase();
+
+    // Get the rebased commit SHA
+    String rebasedCommitSha = gApi.changes().id(change2Id).get().getCurrentRevision().commit.commit;
+
+    // Compare initialCommit to rebased change2
+    // Should see all files: fileA.txt, fileB.txt, fileC.txt
+    Map<String, FileInfo> files =
+        gApi.projects().name(project.get()).diffFiles(initialCommit.name(), rebasedCommitSha, true);
+
+    // All three files should be present, including fileC.txt which was added
+    // in the amended parent change and might be flagged as "due to rebase"
+    assertThat(files.keySet()).contains("fileA.txt");
+    assertThat(files.keySet()).contains("fileB.txt");
+    assertThat(files.keySet()).contains("fileC.txt");
+  }
+
+  @Test
+  public void getDiffFile_showsContentForRebasedFile() throws Exception {
+    // Similar to above, but verify that the file diff endpoint also shows content
+    // for files that might be flagged as "due to rebase"
+
+    // Create change1 with fileA.txt
+    PushOneCommit.Result change1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Change 1", "fileA.txt", "content A")
+            .to("refs/for/master");
+    change1.assertOkStatus();
+    String change1Id = change1.getChangeId();
+
+    // Create change2 (child of change1) with fileB.txt
+    PushOneCommit.Result change2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Change 2", "fileB.txt", "content B")
+            .to("refs/for/master");
+    change2.assertOkStatus();
+    String change2Id = change2.getChangeId();
+
+    // Amend change1 to add fileC.txt
+    testRepo.reset(change1.getCommit());
+    PushOneCommit.Result amendResult =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Change 1 amended",
+                ImmutableMap.of("fileA.txt", "content A", "fileC.txt", "rebase content"),
+                change1Id)
+            .to("refs/for/master");
+    amendResult.assertOkStatus();
+
+    // Approve and submit change1
+    gApi.changes()
+        .id(change1Id)
+        .current()
+        .review(com.google.gerrit.extensions.api.changes.ReviewInput.approve());
+    gApi.changes().id(change1Id).current().submit();
+
+    // Rebase change2 on top of the submitted change1
+    gApi.changes().id(change2Id).rebase();
+
+    // Get the rebased commit SHA
+    String rebasedCommitSha = gApi.changes().id(change2Id).get().getCurrentRevision().commit.commit;
+
+    // Get diff for fileC.txt - this file was added in the amended parent
+    // and might be flagged as "due to rebase", but should still have content
+    DiffInfo diffInfo =
+        gApi.projects()
+            .name(project.get())
+            .diffFile(initialCommit.name(), rebasedCommitSha, "fileC.txt");
+
+    assertThat(diffInfo).isNotNull();
+    assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
+    // Verify the content is present (not filtered out)
+    assertThat(diffInfo.content).isNotEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 56c27a0..3f9767f 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index a3b7d63..9216712 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1874,6 +1874,60 @@
   }
 
   @Test
+  public void replyToCommentInDifferentPatchsetFailsWhenCreatingDraft() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId1 = r.getCommit().getName();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput parentComment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, 1, "parent comment", false);
+    input.comments = new HashMap<>();
+    input.comments.put(FILE_NAME, ImmutableList.of(parentComment));
+    revision(r).review(input);
+
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId1);
+    CommentInfo parentInfo = Iterables.getOnlyElement(result.get(FILE_NAME));
+
+    PushOneCommit.Result r2 = amendChange(changeId);
+    String revId2 = r2.getCommit().getName();
+
+    DraftInput replyDraft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "reply draft");
+    replyDraft.inReplyTo = parentInfo.id;
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId2, replyDraft));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Invalid comment in_reply_to. Comment replies must be submitted on the same patchset as"
+                + " the parent comment");
+  }
+
+  @Test
+  public void replyToCommentInSamePatchsetSucceedsWhenCreatingDraft() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId1 = r.getCommit().getName();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput parentComment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, 1, "parent comment", false);
+    input.comments = new HashMap<>();
+    input.comments.put(FILE_NAME, ImmutableList.of(parentComment));
+    revision(r).review(input);
+
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId1);
+    CommentInfo parentInfo = Iterables.getOnlyElement(result.get(FILE_NAME));
+
+    DraftInput replyDraft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "reply draft");
+    replyDraft.inReplyTo = parentInfo.id;
+
+    CommentInfo addedDraft = addDraft(changeId, revId1, replyDraft);
+    assertThat(addedDraft.inReplyTo).isEqualTo(parentInfo.id);
+  }
+
+  @Test
   public void queryChangesWithCommentCount() throws Exception {
     // PS1 has three comments in three different threads, PS2 has one comment in one thread.
     PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
@@ -2125,6 +2179,68 @@
   }
 
   @Test
+  public void replyToCommentInDifferentPatchsetFailsWhenPublishing() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId1 = r.getCommit().getName();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput parentComment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, 1, "parent comment", false);
+    input.comments = new HashMap<>();
+    input.comments.put(FILE_NAME, ImmutableList.of(parentComment));
+    revision(r).review(input);
+
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId1);
+    CommentInfo parentInfo = Iterables.getOnlyElement(result.get(FILE_NAME));
+
+    PushOneCommit.Result r2 = amendChange(changeId);
+
+    ReviewInput replyInput = new ReviewInput();
+    CommentInput replyComment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, 1, "reply comment", false);
+    replyComment.inReplyTo = parentInfo.id;
+    replyInput.comments = new HashMap<>();
+    replyInput.comments.put(FILE_NAME, ImmutableList.of(replyComment));
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> revision(r2).review(replyInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Invalid comment in_reply_to. Comment replies must be submitted on the same patchset as"
+                + " the parent comment");
+  }
+
+  @Test
+  public void replyToCommentInSamePatchsetSucceedsWhenPublishing() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId1 = r.getCommit().getName();
+
+    ReviewInput input = new ReviewInput();
+    CommentInput parentComment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, 1, "parent comment", false);
+    input.comments = new HashMap<>();
+    input.comments.put(FILE_NAME, ImmutableList.of(parentComment));
+    revision(r).review(input);
+
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId1);
+    CommentInfo parentInfo = Iterables.getOnlyElement(result.get(FILE_NAME));
+
+    ReviewInput replyInput = new ReviewInput();
+    CommentInput replyComment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, 1, "reply comment", false);
+    replyComment.inReplyTo = parentInfo.id;
+    replyInput.comments = new HashMap<>();
+    replyInput.comments.put(FILE_NAME, ImmutableList.of(replyComment));
+
+    revision(r).review(replyInput);
+    Map<String, List<CommentInfo>> resultAfterReply = getPublishedComments(changeId, revId1);
+    assertThat(resultAfterReply.get(FILE_NAME).get(1).inReplyTo).isEqualTo(parentInfo.id);
+  }
+
+  @Test
   public void commentsOnRootCommitsAreIncludedInEmails() throws Exception {
     // Create a change in a new branch, making the patch-set commit a root commit.
     ChangeInfo changeInfo = createChangeInNewBranch("newBranch");
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index daaa201..f57a16c 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -52,6 +52,7 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Ignore;
 import org.junit.Test;
 
 public class SubmittedTogetherIT extends AbstractDaemonTest {
@@ -350,7 +351,6 @@
     assertSubmittedTogether(id2, id2, id1);
   }
 
-  @Test
   @GerritConfig(name = "change.submitWholeTopic", value = "true")
   public void mergedChangesDoNotExposeNonVisibleChanges() throws Exception {
     TestAccount readBlockedUser = accountCreator.user2();
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index e172153..e011ffc 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -29,11 +29,6 @@
 
   @Inject ExperimentFeatures experimentFeatures;
 
-  @Override
-  public boolean enableExperimentsRejectImplicitMergesOnMerge() {
-    return false;
-  }
-
   @Test
   public void emptyConfig_defaultFeatures_enabled() {
     for (String defaultFeature : ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) {
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
index 6945329..1fc0ebf 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
@@ -96,6 +96,22 @@
     assertThat(pushResult.getMessage()).matches(missingChangeIdRegex);
   }
 
+  @Test
+  public void pushWithNonColonTagsInFooter_changeIdAccepted() throws Exception {
+    RevCommit parent = createParentCommit();
+    String changeId = "I0000000000000000000000000000000000000012";
+
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "Subject line\n\nTAG=agy\nCONV=123\nChange-Id: " + changeId,
+            ImmutableMap.of("foo.txt", "content"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertOkStatus();
+  }
+
   @CanIgnoreReturnValue
   private RevCommit createParentCommit() throws Exception {
     RevCommit parent = commitBuilder().add("f.txt", "content").message("base commit").create();
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
index eab0d39..2b6c371 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
@@ -98,4 +98,52 @@
         .to("refs/for/master")
         .assertErrorStatus("Exceeding maximum number of files per change (2 > 1)");
   }
+
+  @Test
+  @GerritConfig(name = "change.maxFiles", value = "1")
+  public void limitFileCount_renameWithoutRenameDetectionExceedsLimit() throws Exception {
+    RevCommit parent =
+        commitBuilder()
+            .add("foo.txt", "same old, same old")
+            .add("bar.txt", "bar")
+            .message("blah")
+            .create();
+    testRepo.reset(parent);
+
+    // Renaming foo.txt to renamed.txt without rename detection counts as 2 changed files
+    // (1 deletion + 1 addition) and is rejected when maxFiles is 1.
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of("bar.txt", "bar", "renamed.txt", "same old, same old"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertErrorStatus("Exceeding maximum number of files per change (2 > 1)");
+  }
+
+  @Test
+  @GerritConfig(name = "change.maxFiles", value = "2")
+  public void limitFileCount_renameWithoutRenameDetectionWithinLimit() throws Exception {
+    RevCommit parent =
+        commitBuilder()
+            .add("foo.txt", "same old, same old")
+            .add("bar.txt", "bar")
+            .message("blah")
+            .create();
+    testRepo.reset(parent);
+
+    // Renaming foo.txt to renamed.txt without rename detection counts as 2 changed files
+    // (1 deletion + 1 addition) and is accepted when maxFiles is 2.
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of("bar.txt", "bar", "renamed.txt", "same old, same old"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertOkStatus();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/index/change/PendingIndexUpdateIT.java b/javatests/com/google/gerrit/acceptance/server/index/change/PendingIndexUpdateIT.java
new file mode 100644
index 0000000..78a5cd9
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/change/PendingIndexUpdateIT.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2026 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.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.WaitUtil.waitUntil;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.index.change.PendingIndexUpdate;
+import com.google.inject.Inject;
+import java.time.Duration;
+import org.junit.Test;
+
+public class PendingIndexUpdateIT extends AbstractDaemonTest {
+  private static final long DEAD_THREAD_ID = Long.MAX_VALUE;
+  @Inject private PendingIndexUpdate pendingIndexUpdate;
+
+  @Test
+  @GerritConfig(name = "index.staleChangeRecovery", value = "true")
+  @GerritConfig(name = "index.staleChangeRecoveryInterval", value = "1s")
+  @GerritConfig(name = "index.changes.commitWithin", value = "0")
+  public void scannerRecoversMissedIndexWrite() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id changeId = r.getChange().getId();
+
+    // Simulate a crash: the change is in NoteDb but the index write was missed.
+    indexer.delete(project, changeId);
+    pendingIndexUpdate.write(DEAD_THREAD_ID, project, changeId, /* delete= */ false);
+    assertThat(gApi.changes().query("change:" + changeId).get()).isEmpty();
+
+    waitUntil(
+        () -> {
+          try {
+            return gApi.changes().query("change:" + changeId).get().size() == 1;
+          } catch (Exception e) {
+            return false;
+          }
+        },
+        Duration.ofSeconds(5));
+    assertThat(gApi.changes().query("change:" + changeId).get()).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "index.staleChangeRecovery", value = "true")
+  @GerritConfig(name = "index.staleChangeRecoveryInterval", value = "1s")
+  @GerritConfig(name = "index.changes.commitWithin", value = "0")
+  public void scannerRecoversMissedIndexDelete() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id changeId = r.getChange().getId();
+    pendingIndexUpdate.write(DEAD_THREAD_ID, project, changeId, /* delete= */ true);
+
+    assertThat(gApi.changes().query("change:" + changeId).get()).hasSize(1);
+    waitUntil(
+        () -> {
+          try {
+            return gApi.changes().query("change:" + changeId).get().isEmpty();
+          } catch (Exception e) {
+            return false;
+          }
+        },
+        Duration.ofSeconds(5));
+    assertThat(gApi.changes().query("change:" + changeId).get()).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 792e3b1..6826ccc 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.server.mail;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
@@ -29,10 +30,12 @@
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -61,10 +64,14 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.server.restapi.change.PostReviewOp;
 import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.List;
 import java.util.UUID;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -1130,6 +1137,34 @@
   }
 
   @Test
+  public void createReviewableChangeWithSilentReviewerAndCcPushOptions() throws Exception {
+    StagedPreChange spc =
+        stagePreChangeWithPushOptions(
+            "refs/for/master",
+            users ->
+                ImmutableList.of(
+                    "r=" + users.reviewer.username() + ":silent",
+                    "cc=" + users.ccer.username() + ":silent"));
+    assertThat(sender)
+        .sent("newchange", spc)
+        .bcc(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+
+    // Verify NoteDb state
+    ChangeInfo ci = get(spc.changeId, DETAILED_LABELS);
+    Collection<AccountInfo> reviewers =
+        firstNonNull(ci.reviewers.get(ReviewerState.REVIEWER), ImmutableList.<AccountInfo>of());
+    Truth.assertThat(reviewers.stream().map(a -> a._accountId).collect(toList()))
+        .containsExactly(spc.reviewer.id().get());
+    Collection<AccountInfo> ccs =
+        firstNonNull(ci.reviewers.get(ReviewerState.CC), ImmutableList.<AccountInfo>of());
+    Truth.assertThat(ccs.stream().map(a -> a._accountId).collect(toList()))
+        .containsExactly(spc.ccer.id().get());
+  }
+
+  @Test
   public void createReviewableChangeWithReviewersAndCcsByEmail() throws Exception {
     StagedPreChange spc =
         stagePreChange(
@@ -2046,6 +2081,84 @@
   }
 
   @Test
+  public void newPatchSetOnReviewableChangeAddingSilentReviewerPushOption() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(
+        sc,
+        "refs/for/master",
+        sc.owner,
+        ImmutableList.of("r=" + newReviewer.username() + ":silent"));
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+
+    // Verify NoteDb state
+    ChangeInfo ci = get(sc.changeId, DETAILED_LABELS);
+    Collection<AccountInfo> reviewers =
+        firstNonNull(ci.reviewers.get(ReviewerState.REVIEWER), ImmutableList.<AccountInfo>of());
+    List<Integer> reviewerIds =
+        reviewers.stream().map(a -> a._accountId).filter(id -> id != null).collect(toList());
+    Truth.assertThat(reviewerIds).containsExactly(sc.reviewer.id().get(), newReviewer.id().get());
+  }
+
+  @Test
+  public void newPatchSetOnReviewableChangeAddingSilentReviewerAndRegularReviewerPushOption()
+      throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    TestAccount newReviewer2 = sc.testAccount("newReviewer2");
+    pushTo(
+        sc,
+        "refs/for/master",
+        sc.owner,
+        ImmutableList.of(
+            "r=" + newReviewer.username() + ":silent", "r=" + newReviewer2.username()));
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, newReviewer2)
+        .cc(sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void newPatchSetOnReviewableChangePromotingCcToSilentReviewer() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    // sc.ccer is already CC. Push new patch set promoting sc.ccer to silent reviewer.
+    pushTo(
+        sc, "refs/for/master", sc.owner, ImmutableList.of("r=" + sc.ccer.username() + ":silent"));
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+
+    // Verify sc.ccer is now REVIEWER in NoteDb and no longer in CC
+    ChangeInfo ci = get(sc.changeId, DETAILED_LABELS);
+    Collection<AccountInfo> reviewers =
+        firstNonNull(ci.reviewers.get(ReviewerState.REVIEWER), ImmutableList.<AccountInfo>of());
+    List<Integer> reviewerIds =
+        reviewers.stream().map(a -> a._accountId).filter(id -> id != null).collect(toList());
+    Truth.assertThat(reviewerIds).containsExactly(sc.reviewer.id().get(), sc.ccer.id().get());
+    // Filter out null account IDs since CC_BY_EMAIL does not have an account ID
+    Collection<AccountInfo> ccs =
+        firstNonNull(ci.reviewers.get(ReviewerState.CC), ImmutableList.<AccountInfo>of());
+    List<Integer> ccIds =
+        ccs.stream().map(a -> a._accountId).filter(id -> id != null).collect(toList());
+    Truth.assertThat(ccIds).doesNotContain(sc.ccer.id().get());
+  }
+
+  @Test
   public void newPatchSetOnWipChangeAddingReviewer() throws Exception {
     StagedChange sc = stageWipChange();
     TestAccount newReviewer = sc.testAccount("newReviewer");
@@ -2086,6 +2199,23 @@
     pushTo(sc, ref, by, ENABLED);
   }
 
+  private void pushTo(StagedChange sc, String ref, TestAccount by, List<String> pushOptions)
+      throws Exception {
+    setEmailStrategy(by, ENABLED);
+
+    String randomContent = UUID.randomUUID().toString();
+    PushOneCommit push =
+        pushFactory.create(
+            by.newIdent(),
+            sc.repo,
+            "New Patch Set",
+            PushOneCommit.FILE_NAME,
+            randomContent,
+            sc.changeId);
+    push.setPushOptions(pushOptions);
+    push.to(ref).assertOkStatus();
+  }
+
   private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 0e13732..8cf8206 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -51,10 +51,10 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.util.Collection;
 import java.util.stream.StreamSupport;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
index 4ad1caa..f0dd3a7 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -42,8 +42,8 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.query.change.GroupBackedUser;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
-import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/RefControlIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/RefControlIT.java
new file mode 100644
index 0000000..8c7c7c4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/RefControlIT.java
@@ -0,0 +1,431 @@
+// Copyright (C) 2026 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.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.entities.Permission.READ;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Integration tests for ref-based permission settings.
+ *
+ * <p>Tests use PermissionBackend.WithUser#filter(ProjectPermission, Collection)} directly to verify
+ * which refs are visible, covering scenarios not already tested by {@link
+ * com.google.gerrit.acceptance.git.RefAdvertisementIT} (which uses the git wire protocol) or {@link
+ * com.google.gerrit.server.permissions.RefControlTest} (which tests at the unit level).
+ */
+public class RefControlIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+
+  private AccountGroup.UUID privileged;
+  private TestAccount privilegedUser;
+  private AccountGroup.UUID unprivileged;
+
+  @Before
+  public void setUpGroups() throws Exception {
+    privileged = AccountGroup.uuid(gApi.groups().create(name("privileged")).get().id);
+    privilegedUser = accountCreator.create(name("privileged-user"), "priv@test.com", "Priv", null);
+    gApi.groups().id(privileged.get()).addMembers(privilegedUser.username());
+
+    unprivileged = AccountGroup.uuid(gApi.groups().create(name("unprivileged")).get().id);
+    gApi.groups().id(unprivileged.get()).addMembers(user.username());
+
+    // Remove All-Projects default READ grants so each test controls ACLs precisely.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(READ).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .remove(permissionKey(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .remove(permissionKey(READ).ref("refs/meta/version").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Test
+  public void perProjectDeny_hidesProjectOnPublicServer() throws Exception {
+    // Simulate a public server: All-Projects grants READ to Anonymous Users.
+    // The per-project DENY on refs/* makes the project invisible to everyone
+    // except users with an explicit ALLOW in that project.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+
+    Project.NameKey hidden = projectOperations.newProject().create();
+    gApi.projects().name(hidden.get()).branch("main").create(new BranchInput());
+
+    // Deny read for anonymous (= everyone) in the project itself.
+    projectOperations
+        .project(hidden)
+        .forUpdate()
+        .add(deny(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+
+    // Regular user sees no refs.
+    assertThat(visibleRefs(hidden, user)).isEmpty();
+
+    // Granting READ explicitly in the same project still works.
+    projectOperations
+        .project(hidden)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(privileged))
+        .update();
+    assertThat(visibleRefs(hidden, privilegedUser)).contains("refs/heads/main");
+  }
+
+  @Test
+  public void blockAnonymousUsers_blocksEveryone_connotBeOverridenInChild() throws Exception {
+    // Blocking Anonymous Users blocks all users (registered too) since every
+    // user is a member of Anonymous Users. Without an ALLOW in the same section,
+    // no group can bypass the block.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .update();
+
+    Project.NameKey p = projectOperations.newProject().create();
+    gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    assertThat(visibleRefs(p, user)).isEmpty();
+    assertThat(visibleRefs(p, privilegedUser)).isEmpty();
+  }
+
+  @Test
+  public void blockAnonymous_allowPrivileged_inSameSection_unblocks() throws Exception {
+    // ALLOW in the same AccessSection cancels the BLOCK for members of
+    // the allowed group.
+    Project.NameKey p = projectOperations.newProject().create();
+    gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/*").group(privileged))
+        .update();
+
+    // Unprivileged registered user is still blocked (ALLOW is only for privileged group).
+    assertThat(visibleRefs(p, user)).isEmpty();
+    // Privileged user: ALLOW in same section cancels the BLOCK.
+    assertThat(visibleRefs(p, privilegedUser)).contains("refs/heads/main");
+  }
+
+  @Test
+  public void blockWithExclusiveAllowOnMoreSpecificRef_unblocks() throws Exception {
+    // Documented example:
+    //   [access "refs/*"]        read = block group X
+    //   [access "refs/heads/*"]  exclusiveGroupPermissions = read
+    //                            read = group Y
+    // Members of Y can read refs/heads/* but not other refs.
+    Project.NameKey p = projectOperations.newProject().create();
+
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(READ).ref("refs/heads/*").group(privileged))
+        .setExclusiveGroup(permissionKey(READ).ref("refs/heads/*"), true)
+        .update();
+
+    ImmutableList<String> visible = visibleRefs(p, privilegedUser);
+    // Branches are visible via the exclusive ALLOW.
+    assertThat(visible).containsExactlyElementsIn(List.of("HEAD", "refs/heads/master"));
+    assertThat(visibleRefs(p, user)).isEmpty();
+  }
+
+  @Test
+  public void blockWithNonExclusiveAllowOnMoreSpecificRef_doesNotUnblock() throws Exception {
+    // Without the exclusive flag on refs/heads/*, the ALLOW on the more specific
+    // ref does not override the parent BLOCK.
+    Project.NameKey p = projectOperations.newProject().create();
+    gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(privileged))
+        .add(allow(READ).ref("refs/heads/*").group(privileged))
+        // NOTE: no setExclusiveGroup — non-exclusive ALLOW cannot unblock
+        .update();
+
+    assertThat(visibleRefs(p, privilegedUser)).isEmpty();
+  }
+
+  @Test
+  public void deny_onlyAffectsSpecificGroup_otherGroupUnaffected() throws Exception {
+    // DENY for unprivileged group on refs/heads/secret.
+    // All-Projects ALLOW for REGISTERED_USERS still applies to the privileged user.
+    Project.NameKey p = projectOperations.newProject().create();
+    gApi.projects().name(p.get()).branch("secret").create(new BranchInput());
+    gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(deny(READ).ref("refs/heads/secret").group(unprivileged))
+        .update();
+
+    // Privileged user can still see refs/heads/secret.
+    assertThat(visibleRefs(p, privilegedUser)).contains("refs/heads/secret");
+    // User has the DENY on refs/heads/secret but ALLOW on refs/*.
+    // The DENY cancels the ALLOW for the same (ref-pattern, group) via SeenRule,
+    // but the ALLOW on refs/* has a different ref pattern so it still applies.
+    assertThat(visibleRefs(p, user)).contains("refs/heads/secret");
+  }
+
+  @Test
+  public void deny_doesNotPreventAccessViaInheritedDifferentRefPattern() throws Exception {
+    // Doc: "DENY/ALLOW example" — child DENY on refs/heads/secret for REGISTERED_USERS,
+    // but the parent also has ALLOW on refs/heads/* for REGISTERED_USERS.
+    // The DENY only cancels (refs/heads/secret, REGISTERED_USERS) via SeenRule,
+    // but the parent ALLOW covers refs/heads/* which is a different ref pattern,
+    // so access is still granted.
+    Project.NameKey parent = projectOperations.newProject().create();
+    Project.NameKey child = projectOperations.newProject().parent(parent).create();
+    gApi.projects().name(child.get()).branch("secret").create(new BranchInput());
+
+    projectOperations
+        .project(parent)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(child)
+        .forUpdate()
+        .add(deny(READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+        .update();
+
+    // The parent ALLOW on refs/heads/* (different pattern) still applies.
+    assertThat(visibleRefs(child, user)).contains("refs/heads/secret");
+  }
+
+  @Test
+  public void grantReadOnRefsTagsOnly_doesNotMakeTagsVisible() throws Exception {
+    // Granting READ on refs/tags/* alone has no effect; tags are visible only
+    // when reachable from a readable branch.
+    Project.NameKey p = projectOperations.newProject().create();
+    gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+    gApi.projects().name(p.get()).tag("v1.0").create(new TagInput());
+
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(READ).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+
+    // No branches are readable, so no tags are visible either.
+    assertThat(visibleRefs(p, user)).containsNoneIn(ImmutableList.of("refs/tags/v1.0"));
+  }
+
+  @Test
+  public void tagVisibleWhenReachableFromReadableBranch() throws Exception {
+    // A tag is visible if and only if it is reachable from a branch the user can read.
+    Project.NameKey p = projectOperations.newProject().create();
+    gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Create a tag pointing at HEAD
+    gApi.projects().name(p.get()).tag("v1.0").create(new TagInput());
+
+    assertThat(visibleRefs(p, user)).contains("refs/tags/v1.0");
+
+    // Now restrict READ to a subset of branches that does not include main.
+    // Remove the broad allow and grant only on refs/heads/other/*.
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .remove(permissionKey(READ).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/heads/other/*").group(REGISTERED_USERS))
+        .update();
+
+    // Tag is no longer reachable from any visible ref, so it becomes invisible.
+    assertThat(visibleRefs(p, user)).doesNotContain("refs/tags/v1.0");
+  }
+
+  @Test
+  public void blockInParent_childCannotUnblockWithExclusive() throws Exception {
+    // An exclusive read access in a child project does not unblock
+    // read access blocked in a parent repository
+    Project.NameKey parent = projectOperations.newProject().create();
+    Project.NameKey child = projectOperations.newProject().parent(parent).create();
+    gApi.projects().name(child.get()).branch("main").create(new BranchInput());
+
+    projectOperations
+        .project(parent)
+        .forUpdate()
+        .add(block(READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(child)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(REGISTERED_USERS))
+        .setExclusiveGroup(permissionKey(READ).ref("refs/*"), true)
+        .update();
+
+    // The parent's BLOCK cannot be overridden by the child's exclusive ALLOW.
+    assertThat(visibleRefs(child, user)).isEmpty();
+  }
+
+  @Test
+  public void regexRefPattern_matchesOnlyMatchingBranches() throws Exception {
+    Project.NameKey p = projectOperations.newProject().create();
+    gApi.projects().name(p.get()).branch("short").create(new BranchInput());
+    gApi.projects().name(p.get()).branch("UPPERCASE").create(new BranchInput());
+    gApi.projects().name(p.get()).branch("verylongbranchname").create(new BranchInput());
+
+    // Allow read only on lowercase branches of 1-8 characters.
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/[a-z]{1,8}").group(REGISTERED_USERS))
+        .update();
+
+    ImmutableList<String> visible = visibleRefs(p, user);
+    assertThat(visible).contains("refs/heads/short");
+    assertThat(visible).doesNotContain("refs/heads/UPPERCASE");
+    assertThat(visible).doesNotContain("refs/heads/verylongbranchname");
+  }
+
+  @Test
+  public void usernamePattern_userSeesOnlyOwnBranch() throws Exception {
+    Project.NameKey p = projectOperations.newProject().create();
+    // Branch matching the regular user's username.
+    String userBranch = "sandbox/" + user.username() + "/feature";
+    // Branch matching the privileged user's username.
+    String privilegedBranch = "sandbox/" + privilegedUser.username() + "/feature";
+    gApi.projects().name(p.get()).branch(userBranch).create(new BranchInput());
+    gApi.projects().name(p.get()).branch(privilegedBranch).create(new BranchInput());
+
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/sandbox/${username}/*").group(REGISTERED_USERS))
+        .update();
+
+    ImmutableList<String> visibleToUser = visibleRefs(p, user);
+    assertThat(visibleToUser).contains("refs/heads/" + userBranch);
+    assertThat(visibleToUser).doesNotContain("refs/heads/" + privilegedBranch);
+
+    ImmutableList<String> visibleToPrivileged = visibleRefs(p, privilegedUser);
+    assertThat(visibleToPrivileged).contains("refs/heads/" + privilegedBranch);
+    assertThat(visibleToPrivileged).doesNotContain("refs/heads/" + userBranch);
+  }
+
+  @Test
+  public void childAllow_moreSpecific_overridesNarrowerParentAllow() throws Exception {
+    // Parent allows only refs/heads/main; child additionally allows refs/heads/feature/*.
+    Project.NameKey parent = projectOperations.newProject().create();
+    Project.NameKey child = projectOperations.newProject().parent(parent).create();
+    gApi.projects().name(child.get()).branch("main").create(new BranchInput());
+    gApi.projects().name(child.get()).branch("feature/foo").create(new BranchInput());
+
+    projectOperations
+        .project(parent)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/main").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(child)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/feature/*").group(REGISTERED_USERS))
+        .update();
+
+    ImmutableList<String> visible = visibleRefs(child, user);
+    assertThat(visible).contains("refs/heads/main");
+    assertThat(visible).contains("refs/heads/feature/foo");
+  }
+
+  @Test
+  public void exclusiveAllow_preventsOtherGroupsFromInheritingAccess() throws Exception {
+    // All-Projects ALLOW read for REGISTERED_USERS on refs/heads/*. Exclusive read
+    // permission is set for the privileged group on refs/heads/restricted/* in project.
+    // Regular registered users lose access to refs/heads/restricted/* because
+    // the exclusive flag stops the upward search before reaching REGISTERED_USERS.
+    Project.NameKey p = projectOperations.newProject().parent(allProjects).create();
+    gApi.projects().name(p.get()).branch("main").create(new BranchInput());
+    gApi.projects().name(p.get()).branch("restricted/secret").create(new BranchInput());
+
+    // All-Projects grants broad READ to all registered users.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    // Child grants exclusive READ on restricted/* only to privileged group.
+    // The exclusive flag stops the search before the parent ALLOW is reached.
+    projectOperations
+        .project(p)
+        .forUpdate()
+        .add(allow(READ).ref("refs/heads/restricted/*").group(privileged))
+        .setExclusiveGroup(permissionKey(READ).ref("refs/heads/restricted/*"), true)
+        .update();
+
+    // Regular user cannot see restricted branch (exclusive stops inherited ALLOW).
+    assertThat(visibleRefs(p, user)).doesNotContain("refs/heads/restricted/secret");
+    // Non-restricted branches are still accessible via inherited parent ALLOW.
+    assertThat(visibleRefs(p, user)).contains("refs/heads/main");
+    // Privileged user can see the restricted branch.
+    assertThat(visibleRefs(p, privilegedUser)).contains("refs/heads/restricted/secret");
+  }
+
+  private ImmutableList<String> visibleRefs(Project.NameKey project, TestAccount account)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend
+          .user(identifiedUserFactory.create(account.id()))
+          .project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ListSubmitRequirementTemplatesIT.java b/javatests/com/google/gerrit/acceptance/server/project/ListSubmitRequirementTemplatesIT.java
new file mode 100644
index 0000000..1c5176a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ListSubmitRequirementTemplatesIT.java
@@ -0,0 +1,186 @@
+// Copyright (C) 2026 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.ListSubmitRequirementTemplates;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class ListSubmitRequirementTemplatesIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Provider<ListSubmitRequirementTemplates> listSubmitRequirementTemplatesProvider;
+  @Inject private ProjectsCollection projects;
+
+  @Test
+  public void noTemplates() throws Exception {
+    assertThat(listTemplates(project)).isEmpty();
+  }
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> listSubmitRequirementTemplates(project));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void readsTemplatesFromCurrentProjectAndParents() throws Exception {
+    putTemplate(allProjects, "Parent-Template", "label:Code-Review=+1");
+    putTemplate(project, "Child-Template", "label:Code-Review=+2");
+
+    List<SubmitRequirementInfo> response = listTemplates(project);
+
+    assertThat(response).hasSize(2);
+    assertTemplateInfo(response.get(0), "Parent-Template", allProjects);
+    assertTemplateInfo(response.get(1), "Child-Template", project);
+  }
+
+  @Test
+  public void returnsCurrentProjectTemplatesWhenParentIsUnreadable() throws Exception {
+    putTemplate(allProjects, "Parent-Template", "label:Code-Review=+1");
+    putTemplate(project, "Child-Template", "label:Code-Review=+2");
+    grantReadConfigToRegisteredUsers(project);
+    requestScopeOperations.setApiUser(user.id());
+
+    List<SubmitRequirementInfo> response = listTemplates(project);
+
+    assertThat(response).hasSize(1);
+    assertTemplateInfo(response.get(0), "Child-Template", project);
+  }
+
+  @Test
+  public void parentReadBlockAlsoBlocksChildConfigRead() throws Exception {
+    Project.NameKey grandparent = projectOperations.newProject().name(name("grandparent")).create();
+    Project.NameKey parent =
+        projectOperations.newProject().name(name("parent")).parent(grandparent).create();
+    Project.NameKey child =
+        projectOperations.newProject().name(name("child")).parent(parent).create();
+
+    putTemplate(grandparent, "Grandparent-Template", "label:Code-Review=+1");
+    putTemplate(parent, "Parent-Template", "label:Verified=+1");
+    putTemplate(child, "Child-Template", "label:Code-Review=+2");
+
+    grantReadConfigToRegisteredUsers(grandparent);
+    projectOperations
+        .project(parent)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(child)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS).force(true))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException thrown = assertThrows(AuthException.class, () -> listTemplates(child));
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void nonOverridableParentTemplateWinsOverChildTemplate() throws Exception {
+    putTemplate(allProjects, "Shared-Template", "label:Code-Review=+1", false);
+    putTemplate(project, "Shared-Template", "label:Code-Review=+2", true);
+
+    List<SubmitRequirementInfo> response = listTemplates(project);
+
+    assertThat(response).hasSize(1);
+    assertTemplateInfo(response.get(0), "Shared-Template", allProjects);
+    assertThat(response.get(0).submittabilityExpression).isEqualTo("label:Code-Review=+1");
+  }
+
+  private void putTemplate(Project.NameKey projectName, String templateName, String expression) {
+    putTemplate(projectName, templateName, expression, true);
+  }
+
+  private void putTemplate(
+      Project.NameKey projectName,
+      String templateName,
+      String expression,
+      boolean allowOverrideInChildProjects) {
+    projectOperations
+        .project(projectName)
+        .forInvalidation()
+        .addProjectConfigUpdater(
+            cfg -> {
+              cfg.setString(
+                  ProjectConfig.SUBMIT_REQUIREMENT_TEMPLATE,
+                  templateName,
+                  ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                  expression);
+              cfg.setBoolean(
+                  ProjectConfig.SUBMIT_REQUIREMENT_TEMPLATE,
+                  templateName,
+                  ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+                  allowOverrideInChildProjects);
+            })
+        .invalidate();
+  }
+
+  private void grantReadConfigToRegisteredUsers(Project.NameKey projectName) throws Exception {
+    projectOperations
+        .project(projectName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+  }
+
+  private List<SubmitRequirementInfo> listTemplates(Project.NameKey projectName) throws Exception {
+    return listSubmitRequirementTemplates(projectName).value();
+  }
+
+  private Response<List<SubmitRequirementInfo>> listSubmitRequirementTemplates(
+      Project.NameKey projectName) throws Exception {
+    return listSubmitRequirementTemplatesProvider.get().apply(projectResource(projectName));
+  }
+
+  private ProjectResource projectResource(Project.NameKey projectName) throws Exception {
+    return projects.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(projectName.get()));
+  }
+
+  private static void assertTemplateInfo(
+      SubmitRequirementInfo submitRequirementInfo, String name, Project.NameKey projectName) {
+    assertThat(submitRequirementInfo.name).isEqualTo(name);
+    assertThat(submitRequirementInfo.projectName).isEqualTo(projectName.get());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
index d10d559..2418172 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -26,9 +26,9 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
 import com.google.inject.name.Named;
 import java.util.Optional;
-import javax.inject.Inject;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 77fd750..47f4083 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -28,12 +28,18 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.server.config.RegexAllowedGroupsProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -794,4 +800,143 @@
     // assert that there was no email notification for user
     assertThat(sender.getMessages()).isEmpty();
   }
+
+  // Unlike private changes (which notify users with VIEW_PRIVATE_CHANGES permission as tested
+  // in watchProjectNotifyOnPrivateChange), WIP changes are visible to all readers but suppress
+  // notifications by defaulting notify handling to NotifyHandling.OWNER unless overridden.
+  @Test
+  public void watchProjectNoNotificationForWipChange_createdViaRest() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    watch(watchedProject);
+
+    requestScopeOperations.setApiUser(admin.id());
+    ChangeInput input = new ChangeInput();
+    input.project = watchedProject;
+    input.branch = "master";
+    input.subject = "wip change";
+    input.workInProgress = true;
+    gApi.changes().create(input);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNoNotificationForWipChange_pushedWithoutNotify() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    watch(watchedProject);
+
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "wip change", "a", "a1")
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNotificationForWipChange_createdViaRestWithNotifyAll() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    watch(watchedProject);
+
+    requestScopeOperations.setApiUser(admin.id());
+    ChangeInput input = new ChangeInput();
+    input.project = watchedProject;
+    input.branch = "master";
+    input.subject = "wip change";
+    input.workInProgress = true;
+    input.notify = NotifyHandling.ALL;
+    gApi.changes().create(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(user.getNameEmail());
+  }
+
+  @Test
+  public void watchProjectNoNotificationForWipChange_projectWipByDefault() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    try (ProjectConfigUpdate u = updateProject(Project.nameKey(watchedProject))) {
+      u.getConfig()
+          .updateProject(
+              b ->
+                  b.setBooleanConfig(
+                      BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT, InheritableBoolean.TRUE));
+      u.save();
+    }
+    requestScopeOperations.setApiUser(user.id());
+    watch(watchedProject);
+
+    requestScopeOperations.setApiUser(admin.id());
+    ChangeInput input = new ChangeInput();
+    input.project = watchedProject;
+    input.branch = "master";
+    input.subject = "default wip change";
+    gApi.changes().create(input);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNoNotificationForWipChange_addedAsReviewerOrCc() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    watch(watchedProject);
+
+    requestScopeOperations.setApiUser(admin.id());
+    ChangeInput input = new ChangeInput();
+    input.project = watchedProject;
+    input.branch = "master";
+    input.subject = "wip change";
+    input.workInProgress = true;
+    String changeId = gApi.changes().create(input).get().id;
+    assertThat(sender.getMessages()).isEmpty();
+
+    // Adding user as a reviewer on the WIP change does not send watch/reviewer notifications
+    gApi.changes().id(changeId).addReviewer(user.email());
+    assertThat(sender.getMessages()).isEmpty();
+
+    // Adding user2 as CC on the WIP change also does not send notifications
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    watch(watchedProject);
+    requestScopeOperations.setApiUser(admin.id());
+    ReviewerInput ccInput = new ReviewerInput();
+    ccInput.reviewer = user2.email();
+    ccInput.state = ReviewerState.CC;
+    gApi.changes().id(changeId).addReviewer(ccInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNoNotificationForWipChange_newPatchsetWhenReviewer() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+    watch(watchedProject);
+
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "wip change", "a", "a1")
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    assertThat(sender.getMessages()).isEmpty();
+
+    // Pushing a new patchset on the WIP change does not notify user (who is both watcher and
+    // reviewer)
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "wip change", "a", "a2", r.getChangeId())
+            .to("refs/for/master%wip");
+    r2.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 0bed233..004d51d 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -20,6 +20,10 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.MoreCollectors;
@@ -47,18 +51,30 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.SubmitRequirementEvaluationException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl.SubmitRequirementRegexQueryPermissionChecker;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
 import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.util.Providers;
 import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.Mockito;
 
 @NoHttpd
 public class SubmitRequirementsEvaluatorIT extends AbstractDaemonTest {
@@ -68,6 +84,10 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
+  @Inject SubmitRequirementChangeQueryBuilder.Factory queryBuilderFactory;
+  @Inject PluginSetContext<SubmitRequirement> globalSubmitRequirements;
+  @Inject OneOffRequestContext oneOffRequestContext;
+  @Inject SubmitRequirementsEvaluatorImpl.Metrics metrics;
 
   private ChangeData changeData;
   private String changeId;
@@ -998,6 +1018,29 @@
     assertThat(srResult.errorMessage().get()).isEqualTo("Invalid content pattern.");
   }
 
+  @Test
+  public void invalidPathRegex_returnsErrorExpressionResult() throws Exception {
+    SubmitRequirementExpression exp = SubmitRequirementExpression.create("path:\"^foo[\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).contains("unexpected end-of-string");
+  }
+
+  @Test
+  public void invalidPathRegex_returnsErrorRequirementResult() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "path:\"^foo[\"",
+            /* overrideExpr= */ null);
+
+    SubmitRequirementResult srResult = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult().get().errorMessage().get())
+        .contains("unexpected end-of-string");
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
@@ -1078,4 +1121,67 @@
       return this;
     }
   }
+
+  @Test
+  @GerritConfig(name = "submitRequirement.executionTimeout", value = "2")
+  @GerritConfig(name = "submitRequirement.evaluationThreads", value = "2")
+  public void evaluateRequirement_timesOut_returnsTimeoutResult() throws Exception {
+    ExecutorService mockExecutor = Mockito.mock(ExecutorService.class);
+    Future<SubmitRequirementResult> timedOutFuture = Mockito.mock(Future.class);
+    SubmitRequirementRegexQueryPermissionChecker regexQueryPermissionChecker =
+        Mockito.mock(SubmitRequirementRegexQueryPermissionChecker.class);
+    SubmitRequirementsEvaluatorImpl evaluatorWithMockedExecutor =
+        new SubmitRequirementsEvaluatorImpl(
+            queryBuilderFactory,
+            projectCache,
+            globalSubmitRequirements,
+            cfg,
+            Providers.of(new InternalUser()),
+            oneOffRequestContext,
+            mockExecutor,
+            metrics,
+            regexQueryPermissionChecker);
+
+    SubmitRequirement sr =
+        SubmitRequirement.builder()
+            .setName("timeout-test")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+
+    when(mockExecutor.submit((Callable<SubmitRequirementResult>) any())).thenReturn(timedOutFuture);
+    when(timedOutFuture.get(anyLong(), any(TimeUnit.class)))
+        .thenThrow(new TimeoutException("Simulated timeout"));
+
+    SubmitRequirementResult result =
+        evaluatorWithMockedExecutor.evaluateRequirement(sr, changeData);
+
+    verify(timedOutFuture).cancel(true);
+    assertThat(result.submittabilityExpressionResult()).isPresent();
+    assertThat(result.submittabilityExpressionResult().get().status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.TIMEOUT);
+    assertThat(result.submitRequirement()).isEqualTo(sr);
+  }
+
+  @Test
+  @GerritConfig(name = "submitRequirement.executionTimeout", value = "2")
+  @GerritConfig(name = "submitRequirement.evaluationThreads", value = "-1")
+  public void evaluateRequirement_negativeThreads_runsDirectExecutor_noTimeout() {
+    SubmitRequirement sr =
+        SubmitRequirement.builder()
+            .setName("direct-exec-test")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+
+    assertThat(result.submittabilityExpressionResult()).isPresent();
+
+    SubmitRequirementExpressionResult exprResult = result.submittabilityExpressionResult().get();
+
+    assertThat(exprResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
index 448897c..4b7519a 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.index.testing.FakeIndexVersionManager;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.AbstractIndexModule;
+import com.google.gerrit.server.index.IndexDir;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
@@ -36,6 +36,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
+import java.nio.file.Path;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -106,11 +107,11 @@
 
   @com.google.inject.Inject
   CustomModuleFakeIndexChange(
-      SitePaths sitePaths,
+      @IndexDir Path indexDir,
       ChangeData.Factory changeDataFactory,
       @Assisted Schema<ChangeData> schema,
       @GerritServerConfig Config cfg,
       IndexConfig indexConfig) {
-    super(sitePaths, changeDataFactory, schema, cfg, indexConfig);
+    super(indexDir, changeDataFactory, schema, cfg, indexConfig);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
index caec581..3a6be28 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
@@ -22,12 +22,19 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.sshd.BaseCommand;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Module;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -71,6 +78,60 @@
     }
   }
 
+  @Test
+  public void clientDisconnectDoesNotLogInternalServerError() throws Exception {
+    List<LogRecord> severeRecords = new CopyOnWriteArrayList<>();
+    Handler captureHandler =
+        new Handler() {
+          @Override
+          public void publish(LogRecord r) {
+            if (r.getLevel().intValue() >= Level.SEVERE.intValue()) {
+              severeRecords.add(r);
+            }
+          }
+
+          @Override
+          public void flush() {}
+
+          @Override
+          public void close() {}
+        };
+    Logger baseCommandLogger = Logger.getLogger(BaseCommand.class.getName());
+    baseCommandLogger.addHandler(captureHandler);
+
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    try {
+      Future<Integer> commandFuture =
+          executor.submit(() -> userSshSession.execAndReturnStatus("interrupted"));
+
+      // Wait until the command is running and parked in Thread.sleep().
+      InterruptedCommand.syncPoint.await(30, TimeUnit.SECONDS);
+
+      // Simulate the client dropping the connection. sshd calls destroy(), which
+      // interrupts the worker thread.
+      userSshSession.close();
+
+      // Positive signal: prove the interrupt actually reached the command and was
+      // rethrown wrapped, so a green test cannot mean "the path never ran".
+      assertThat(InterruptedCommand.threwWrapped.await(30, TimeUnit.SECONDS)).isTrue();
+
+      // handleError() runs just after the throw. Poll rather than sleeping a fixed
+      // interval: fail fast on a bad log, and do not burn wall-clock on success.
+      long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+      while (System.nanoTime() < deadline && severeRecords.isEmpty()) {
+        Thread.sleep(50);
+      }
+
+      // Surface any failure from the command thread rather than discarding it.
+      commandFuture.get(30, TimeUnit.SECONDS);
+    } finally {
+      baseCommandLogger.removeHandler(captureHandler);
+      executor.shutdownNow();
+    }
+
+    assertThat(severeRecords).isEmpty();
+  }
+
   private Future<Integer> startCommand(ExecutorService executor, boolean graceful)
       throws Exception {
     Future<Integer> future =
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
index f6e5fb3..107d241 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.SystemPropertiesTestRule;
-import javax.inject.Inject;
+import com.google.inject.Inject;
 import org.junit.ClassRule;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
index acb2e5a..d7afb69 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.SystemPropertiesTestRule;
-import javax.inject.Inject;
+import com.google.inject.Inject;
 import org.junit.ClassRule;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
index 5dd6f01..36ea5d1 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.lucene.LuceneChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.SystemPropertiesTestRule;
-import javax.inject.Inject;
+import com.google.inject.Inject;
 import org.junit.ClassRule;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/auth/BUILD b/javatests/com/google/gerrit/auth/BUILD
index 517f942..4f85b31 100644
--- a/javatests/com/google/gerrit/auth/BUILD
+++ b/javatests/com/google/gerrit/auth/BUILD
@@ -27,6 +27,7 @@
         "//lib:protobuf",
         "//lib/flogger:api",
         "//lib/guice",
+        "//lib/mockito",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/auth/oauth/OAuthTokenAesGcmEncrypterTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthTokenAesGcmEncrypterTest.java
new file mode 100644
index 0000000..ecf0955
--- /dev/null
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthTokenAesGcmEncrypterTest.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static org.junit.Assert.assertThrows;
+
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import org.junit.Test;
+
+public class OAuthTokenAesGcmEncrypterTest {
+  private final OAuthTokenAesGcmEncrypter enc = new OAuthTokenAesGcmEncrypter(key());
+
+  private static byte[] key() {
+    return "0123456789abcdef0123456789abcdef".getBytes(US_ASCII); // 32 B
+  }
+
+  private static OAuthToken sample() {
+    return new OAuthToken("at", "bearer", "{\"refresh_token\":\"r-1\"}", 12345L, "p:e-oauth");
+  }
+
+  @Test
+  public void encryptThenDecrypt_roundTrips() {
+    OAuthToken original = sample();
+    assertThat(enc.decrypt(enc.encrypt(original))).isEqualTo(original);
+  }
+
+  @Test
+  public void encrypt_hidesSecrets_keepsExpiresAtAndProviderId() {
+    OAuthToken e = enc.encrypt(sample());
+    assertThat(e.getToken()).startsWith("gcm:v1:");
+    assertThat(e.getRaw()).doesNotContain("r-1"); // refresh token is not left in cleartext
+    assertThat(e.getExpiresAt()).isEqualTo(12345L);
+    assertThat(e.getProviderId()).isEqualTo("p:e-oauth");
+  }
+
+  @Test
+  public void decrypt_cleartext_passesThrough() {
+    OAuthToken cleartext = sample(); // no encrypted prefix
+    assertThat(enc.decrypt(cleartext)).isEqualTo(cleartext);
+  }
+
+  @Test
+  public void decrypt_tamperedProviderId_throws() {
+    OAuthToken e = enc.encrypt(sample());
+    OAuthToken t =
+        new OAuthToken(
+            e.getToken(), e.getSecret(), e.getRaw(), e.getExpiresAt(), "different:e-oauth");
+    assertThrows(IllegalStateException.class, () -> enc.decrypt(t));
+  }
+
+  @Test
+  public void decrypt_tamperedExpiresAt_throws() {
+    OAuthToken e = enc.encrypt(sample());
+    OAuthToken t =
+        new OAuthToken(
+            e.getToken(), e.getSecret(), e.getRaw(), e.getExpiresAt() + 1, e.getProviderId());
+    assertThrows(IllegalStateException.class, () -> enc.decrypt(t));
+  }
+
+  @Test
+  public void decrypt_swappedEncryptedFields_throws() {
+    OAuthToken e = enc.encrypt(sample());
+    OAuthToken t =
+        new OAuthToken(
+            e.getRaw(), e.getSecret(), e.getToken(), e.getExpiresAt(), e.getProviderId());
+    assertThrows(IllegalStateException.class, () -> enc.decrypt(t));
+  }
+}
diff --git a/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
index e3357b8..e9e62e7 100644
--- a/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
@@ -18,13 +18,18 @@
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.lang.reflect.Type;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -32,6 +37,32 @@
 @RunWith(JUnit4.class)
 public final class OAuthTokenCacheTest {
   @Test
+  public void cacheIsEnabledByDefault() {
+    OAuthToken token = new OAuthToken("token", "secret", "raw", 4102444800000L, "provider");
+    OAuthTokenCache cache = newCache(new Config());
+
+    cache.put(Account.id(1001), token);
+
+    assertThat(cache.isDisabled()).isFalse();
+    assertThat(cache.getEvenIfExpired(Account.id(1001))).isEqualTo(token);
+  }
+
+  @Test
+  public void cacheIsDisabledWhenMemoryLimitIsZero() {
+    Config cfg = new Config();
+    cfg.setLong("cache", OAuthTokenCache.OAUTH_TOKENS, "memoryLimit", 0);
+    Cache<Account.Id, OAuthToken> backingCache = CacheBuilder.newBuilder().build();
+    OAuthTokenCache cache = newCache(backingCache, cfg);
+
+    cache.put(
+        Account.id(1001), new OAuthToken("token", "secret", "raw", 4102444800000L, "provider"));
+
+    assertThat(cache.isDisabled()).isTrue();
+    assertThat(cache.getEvenIfExpired(Account.id(1001))).isNull();
+    assertThat(backingCache.getIfPresent(Account.id(1001))).isNull();
+  }
+
+  @Test
   public void oAuthTokenSerializer() throws Exception {
     OAuthToken token = new OAuthToken("token", "secret", "raw", 12345L, "provider");
     CacheSerializer<OAuthToken> s = new OAuthTokenCache.Serializer();
@@ -89,6 +120,67 @@
         .isNotEmpty();
   }
 
+  @Test
+  public void getEvenIfExpired_returnsExpiredEntry_withoutEvicting() {
+    OAuthTokenCache cache = newCache();
+    Account.Id id = Account.id(1);
+    OAuthToken expired = tokenExpiringAt(System.currentTimeMillis() - 1000);
+    cache.put(id, expired);
+
+    // Returns the expired token, and a second call still returns it: no eviction.
+    assertThat(cache.getEvenIfExpired(id)).isEqualTo(expired);
+    assertThat(cache.getEvenIfExpired(id)).isEqualTo(expired);
+  }
+
+  @Test
+  public void getEvenIfExpired_missing_returnsNull() {
+    assertThat(newCache().getEvenIfExpired(Account.id(999))).isNull();
+  }
+
+  @Test
+  public void hasExpiredToken_trueForExpired_falseForValidOrAbsent() {
+    OAuthTokenCache cache = newCache();
+    Account.Id id = Account.id(1);
+    assertThat(cache.hasExpiredToken(id)).isFalse(); // absent
+    cache.put(id, tokenExpiringAt(Long.MAX_VALUE));
+    assertThat(cache.hasExpiredToken(id)).isFalse(); // valid
+    cache.put(id, tokenExpiringAt(System.currentTimeMillis() - 1000));
+    assertThat(cache.hasExpiredToken(id)).isTrue(); // expired
+  }
+
+  @Test
+  public void getEvenIfExpired_decryptsWithBoundEncrypter() {
+    Cache<Account.Id, OAuthToken> backing = CacheBuilder.newBuilder().build();
+    DynamicItem<OAuthTokenEncrypter> encrypter =
+        DynamicItem.itemOf(OAuthTokenEncrypter.class, new FakeEncrypter());
+    OAuthTokenCache cache = new OAuthTokenCache(backing, encrypter, new Config());
+    Account.Id id = Account.id(1);
+    cache.put(id, tokenExpiringAt(Long.MAX_VALUE)); // stored encrypted
+
+    // Backing cache holds the encrypted form; getEvenIfExpired returns the decrypted original.
+    assertThat(backing.getIfPresent(id).getToken()).isEqualTo("enc:t");
+    assertThat(cache.getEvenIfExpired(id).getToken()).isEqualTo("t");
+  }
+
+  /** Reversible, non-crypto stand-in that prefixes the token so decrypt is observable. */
+  private static final class FakeEncrypter implements OAuthTokenEncrypter {
+    @Override
+    public OAuthToken encrypt(OAuthToken t) {
+      return new OAuthToken(
+          "enc:" + t.getToken(), t.getSecret(), t.getRaw(), t.getExpiresAt(), t.getProviderId());
+    }
+
+    @Override
+    public OAuthToken decrypt(OAuthToken t) {
+      return new OAuthToken(
+          t.getToken().substring("enc:".length()),
+          t.getSecret(),
+          t.getRaw(),
+          t.getExpiresAt(),
+          t.getProviderId());
+    }
+  }
+
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void oAuthTokenFields() throws Exception {
@@ -102,4 +194,20 @@
                 .put("providerId", String.class)
                 .build());
   }
+
+  private static OAuthTokenCache newCache() {
+    return newCache(new Config());
+  }
+
+  private static OAuthTokenCache newCache(Config cfg) {
+    return newCache(CacheBuilder.newBuilder().build(), cfg);
+  }
+
+  private static OAuthTokenCache newCache(Cache<Account.Id, OAuthToken> cache, Config cfg) {
+    return new OAuthTokenCache(cache, DynamicItem.itemOf(OAuthTokenEncrypter.class, null), cfg);
+  }
+
+  private static OAuthToken tokenExpiringAt(long expiresAtMillis) {
+    return new OAuthToken("t", "s", "raw", expiresAtMillis, "provider");
+  }
 }
diff --git a/javatests/com/google/gerrit/auth/oauth/OAuthTokenRefresherTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthTokenRefresherTest.java
new file mode 100644
index 0000000..e4bb8c5
--- /dev/null
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthTokenRefresherTest.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.auth.oauth.OAuthRevokedException;
+import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class OAuthTokenRefresherTest {
+  private static final String PLUGIN = "gerrit-oauth-provider";
+  private static final String EXPORT = "google-oauth";
+  private static final String PROVIDER_ID = PLUGIN + ":" + EXPORT;
+  private static final Account.Id ACCOUNT = Account.id(1);
+
+  @Mock private OAuthTokenCache tokenCache;
+  @Mock private DynamicMap<OAuthServiceProvider> providers;
+  @Mock private OAuthServiceProvider provider;
+
+  private OAuthTokenRefresher refresher;
+
+  @Before
+  public void setUp() {
+    refresher =
+        new OAuthTokenRefresher(
+            tokenCache,
+            providers,
+            DynamicItem.itemOf(OAuthTokenEncrypter.class, null),
+            new Config());
+    lenient().when(providers.get(PLUGIN, EXPORT)).thenReturn(provider);
+    lenient().when(provider.supportsRefresh()).thenReturn(true);
+  }
+
+  private static OAuthToken token(long expiresAt, String providerId) {
+    return new OAuthToken("at", "bearer", "{}", expiresAt, providerId);
+  }
+
+  private static OAuthToken expired() {
+    return token(System.currentTimeMillis() - 1000, PROVIDER_ID);
+  }
+
+  /** Marks a cached, expired token that the hot-path peek will report as expired. */
+  private void cachedExpired() {
+    when(tokenCache.hasExpiredToken(ACCOUNT)).thenReturn(true);
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(expired());
+  }
+
+  @Test
+  public void expiredRefreshableToken_isReplaced() throws Exception {
+    OAuthToken refreshed = token(System.currentTimeMillis() + 3_600_000, PROVIDER_ID);
+    cachedExpired();
+    when(provider.refresh(any())).thenReturn(refreshed);
+
+    refresher.refreshIfExpired(ACCOUNT);
+
+    verify(tokenCache).put(ACCOUNT, refreshed);
+  }
+
+  @Test
+  public void notExpiredOrAbsent_doesNothing_withoutDecrypting() throws Exception {
+    when(tokenCache.hasExpiredToken(ACCOUNT)).thenReturn(false);
+
+    refresher.refreshIfExpired(ACCOUNT);
+
+    // Hot path must not decrypt or refresh when nothing is expired.
+    verify(tokenCache, never()).getEvenIfExpired(any());
+    verify(provider, never()).refresh(any());
+    verify(tokenCache, never()).put(any(), any());
+  }
+
+  @Test
+  public void invalidGrant_removesToken_andThrows() throws Exception {
+    cachedExpired();
+    when(provider.refresh(any())).thenThrow(new OAuthRevokedException("grant gone"));
+
+    assertThrows(OAuthRevokedException.class, () -> refresher.refreshIfExpired(ACCOUNT));
+    verify(tokenCache).remove(ACCOUNT);
+    verify(tokenCache, never()).put(any(), any());
+  }
+
+  @Test
+  public void transientIOException_noCacheMutation() throws Exception {
+    cachedExpired();
+    when(provider.refresh(any())).thenThrow(new IOException("timeout"));
+
+    refresher.refreshIfExpired(ACCOUNT);
+
+    verify(tokenCache, never()).put(any(), any());
+    verify(tokenCache, never()).remove(any());
+  }
+
+  @Test
+  public void transientFailure_backsOff_skipsRetryUntilInterval() throws Exception {
+    cachedExpired();
+    when(provider.refresh(any())).thenThrow(new IOException("idp down"));
+
+    refresher.refreshIfExpired(ACCOUNT);
+    refresher.refreshIfExpired(ACCOUNT);
+
+    // First call attempts refresh and fails; the second is backed off within the interval, so
+    // refresh runs only once across both reads.
+    verify(provider, times(1)).refresh(any());
+  }
+
+  @Test
+  public void unexpectedRuntimeException_noCacheMutation() throws Exception {
+    cachedExpired();
+    when(provider.refresh(any())).thenThrow(new RuntimeException("bug"));
+
+    refresher.refreshIfExpired(ACCOUNT);
+
+    verify(tokenCache, never()).put(any(), any());
+  }
+
+  @Test
+  public void nullProviderId_noRefresh() throws Exception {
+    when(tokenCache.hasExpiredToken(ACCOUNT)).thenReturn(true);
+    when(tokenCache.getEvenIfExpired(ACCOUNT))
+        .thenReturn(token(System.currentTimeMillis() - 1000, null));
+
+    refresher.refreshIfExpired(ACCOUNT);
+
+    verify(provider, never()).refresh(any());
+  }
+
+  @Test
+  public void providerDoesNotSupportRefresh_noRefresh() throws Exception {
+    cachedExpired();
+    when(provider.supportsRefresh()).thenReturn(false);
+
+    refresher.refreshIfExpired(ACCOUNT);
+
+    verify(provider, never()).refresh(any());
+    verify(tokenCache, never()).put(any(), any());
+  }
+}
diff --git a/javatests/com/google/gerrit/auth/oauth/OAuthTokenRevokerTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthTokenRevokerTest.java
new file mode 100644
index 0000000..799010a
--- /dev/null
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthTokenRevokerTest.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2026 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.auth.oauth;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.auth.oauth.OAuthTokenRevoker.Result;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.auth.oauth.OAuthTokenRevokedListener;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import java.io.IOException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class OAuthTokenRevokerTest {
+  private static final String PLUGIN = "oauth-plugin";
+  private static final String EXPORT = "google-oauth";
+  private static final String PROVIDER_ID = PLUGIN + ":" + EXPORT;
+  private static final Account.Id ACCOUNT = Account.id(1);
+
+  @Mock private OAuthTokenCache tokenCache;
+  @Mock private DynamicMap<OAuthServiceProvider> providers;
+  @Mock private OAuthServiceProvider provider;
+  @Mock private PluginSetContext<OAuthTokenRevokedListener> revokedListeners;
+
+  private OAuthTokenRevoker revoker;
+
+  @Before
+  public void setUp() {
+    revoker = new OAuthTokenRevoker(tokenCache, providers, revokedListeners);
+    lenient().when(providers.get(PLUGIN, EXPORT)).thenReturn(provider);
+    lenient().when(provider.supportsRevoke()).thenReturn(true);
+  }
+
+  private static OAuthToken token(String providerId) {
+    return new OAuthToken("at", "bearer", "{}", 0, providerId);
+  }
+
+  @Test
+  public void revoke_providerSupportsRevoke_revokedAndEvicted() throws Exception {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(PROVIDER_ID));
+
+    Result result = revoker.revoke(ACCOUNT);
+
+    assertThat(result).isEqualTo(Result.REVOKED);
+    verify(provider).revoke(any());
+    verify(tokenCache).remove(ACCOUNT);
+    verify(revokedListeners).runEach(any());
+  }
+
+  @Test
+  public void revoke_noToken_isNoOp() {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(null);
+
+    Result result = revoker.revoke(ACCOUNT);
+
+    assertThat(result).isEqualTo(Result.NO_TOKEN);
+    verify(tokenCache, never()).remove(any());
+    verify(revokedListeners, never()).runEach(any());
+  }
+
+  @Test
+  public void revoke_providerDoesNotSupportRevoke_evictedOnly() {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(PROVIDER_ID));
+    when(provider.supportsRevoke()).thenReturn(false);
+
+    Result result = revoker.revoke(ACCOUNT);
+
+    assertThat(result).isEqualTo(Result.EVICTED_ONLY);
+    verify(tokenCache).remove(ACCOUNT);
+    verify(revokedListeners).runEach(any());
+  }
+
+  @Test
+  public void revoke_idpFailure_evictedOnly_stillEvictsAndFires() throws Exception {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(PROVIDER_ID));
+    doThrow(new IOException("idp down")).when(provider).revoke(any());
+
+    Result result = revoker.revoke(ACCOUNT);
+
+    assertThat(result).isEqualTo(Result.EVICTED_ONLY);
+    verify(tokenCache).remove(ACCOUNT);
+    verify(revokedListeners).runEach(any());
+  }
+
+  @Test
+  public void revoke_nullProviderId_evictedOnly() {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(null));
+
+    Result result = revoker.revoke(ACCOUNT);
+
+    assertThat(result).isEqualTo(Result.EVICTED_ONLY);
+    verify(tokenCache).remove(ACCOUNT);
+  }
+
+  @Test
+  public void revoke_supportsRevokeThrows_stillEvictsAndFires() throws Exception {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(PROVIDER_ID));
+    when(provider.supportsRevoke()).thenThrow(new RuntimeException("provider broken"));
+
+    Result result = revoker.revoke(ACCOUNT);
+
+    assertThat(result).isEqualTo(Result.EVICTED_ONLY);
+    verify(tokenCache).remove(ACCOUNT); // a broken provider must not block local eviction
+    verify(revokedListeners).runEach(any());
+  }
+
+  @Test
+  public void revoke_undecryptableEntry_stillEvictsAndFires() throws Exception {
+    when(tokenCache.getEvenIfExpired(ACCOUNT))
+        .thenThrow(new IllegalStateException("wrong key or tampered"));
+
+    Result result = revoker.revoke(ACCOUNT);
+
+    assertThat(result).isEqualTo(Result.EVICTED_ONLY);
+    verify(tokenCache).remove(ACCOUNT); // purge locally even though it could not be decrypted
+    verify(revokedListeners).runEach(any());
+    verify(provider, never()).revoke(any()); // cannot revoke upstream without the token
+  }
+
+  @Test
+  public void revokeAll_undecryptableEntry_doesNotAbort_stillPurges() {
+    Account.Id other = Account.id(2);
+    when(tokenCache.accountsWithCachedToken()).thenReturn(ImmutableSet.of(ACCOUNT, other));
+    when(tokenCache.getEvenIfExpired(ACCOUNT))
+        .thenThrow(new IllegalStateException("wrong key or tampered"));
+    when(tokenCache.getEvenIfExpired(other)).thenReturn(token(PROVIDER_ID));
+
+    int processed = revoker.revokeAll();
+
+    assertThat(processed).isEqualTo(2);
+    verify(tokenCache).remove(ACCOUNT); // undecryptable entry still evicted, loop not aborted
+    verify(tokenCache).remove(other);
+    verify(tokenCache).removeAll(); // post-loop purge still runs
+    verify(revokedListeners, times(2)).runEach(any()); // both global flushes still fire
+  }
+
+  @Test
+  public void revokeAll_bracketsGlobalFlush_evictsEach_andPurges() {
+    when(tokenCache.accountsWithCachedToken()).thenReturn(ImmutableSet.of(ACCOUNT));
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(PROVIDER_ID));
+
+    int processed = revoker.revokeAll();
+
+    assertThat(processed).isEqualTo(1);
+    verify(tokenCache).remove(ACCOUNT); // per-account evict
+    verify(tokenCache).removeAll(); // disk-only stragglers
+    // onAllTokensRevoked fired before AND after the loop; the per-account callback is suppressed.
+    verify(revokedListeners, times(2)).runEach(any());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelTypesTest.java b/javatests/com/google/gerrit/entities/LabelTypesTest.java
new file mode 100644
index 0000000..bf4237a
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/LabelTypesTest.java
@@ -0,0 +1,181 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class LabelTypesTest {
+  private static final LabelType CODE_REVIEW =
+      LabelType.create("Code-Review", ImmutableList.of(LabelValue.create((short) 0, "No score")));
+  private static final LabelType VERIFIED =
+      LabelType.create("Verified", ImmutableList.of(LabelValue.create((short) 0, "No score")));
+  private static final LabelType CI_PASS =
+      LabelType.create("CI-Pass", ImmutableList.of(LabelValue.create((short) 0, "No score")));
+
+  @Test
+  public void emptyLabelTypes() {
+    LabelTypes labelTypes = new LabelTypes(Collections.emptyList());
+    assertThat(labelTypes.getLabelTypes()).isEmpty();
+    assertThat(labelTypes.byLabel("Code-Review")).isEmpty();
+    assertThat(labelTypes.byLabel(LabelId.create("Code-Review"))).isEmpty();
+  }
+
+  @Test
+  @SuppressWarnings("DoNotCall")
+  public void getLabelTypesReturnsImmutableListInOrder() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED, CI_PASS));
+    assertThat(labelTypes.getLabelTypes())
+        .containsExactly(CODE_REVIEW, VERIFIED, CI_PASS)
+        .inOrder();
+
+    assertThrows(
+        UnsupportedOperationException.class, () -> labelTypes.getLabelTypes().add(CODE_REVIEW));
+  }
+
+  @Test
+  public void byLabelCaseInsensitiveString() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED));
+    assertThat(labelTypes.byLabel("Code-Review")).hasValue(CODE_REVIEW);
+    assertThat(labelTypes.byLabel("code-review")).hasValue(CODE_REVIEW);
+    assertThat(labelTypes.byLabel("CODE-REVIEW")).hasValue(CODE_REVIEW);
+    assertThat(labelTypes.byLabel("VERIFIED")).hasValue(VERIFIED);
+    assertThat(labelTypes.byLabel("Non-Existent")).isEmpty();
+  }
+
+  @Test
+  public void byLabelCaseInsensitiveLabelId() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED));
+    assertThat(labelTypes.byLabel(LabelId.create("Code-Review"))).hasValue(CODE_REVIEW);
+    assertThat(labelTypes.byLabel(LabelId.create("code-review"))).hasValue(CODE_REVIEW);
+    assertThat(labelTypes.byLabel(LabelId.create("Unknown"))).isEmpty();
+  }
+
+  @Test
+  public void duplicateLabelNamesRetainsLastOccurrence() {
+    LabelType codeReview1 =
+        LabelType.create(
+            "Code-Review", ImmutableList.of(LabelValue.create((short) 0, "Initial score")));
+    LabelType codeReview2 =
+        LabelType.create(
+            "Code-Review", ImmutableList.of(LabelValue.create((short) 1, "Updated score")));
+
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(codeReview1, codeReview2));
+    assertThat(labelTypes.getLabelTypes()).containsExactly(codeReview1, codeReview2).inOrder();
+    assertThat(labelTypes.byLabel("Code-Review")).hasValue(codeReview2);
+  }
+
+  @Test
+  public void duplicateLabelNamesDifferingCaseRetainsLastOccurrence() {
+    LabelType lower =
+        LabelType.create("code-review", ImmutableList.of(LabelValue.create((short) 0, "Lower")));
+    LabelType upper =
+        LabelType.create("CODE-REVIEW", ImmutableList.of(LabelValue.create((short) 1, "Upper")));
+
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(lower, upper));
+    assertThat(labelTypes.byLabel("Code-Review")).hasValue(upper);
+  }
+
+  @Test
+  public void nameComparatorSortsByConfiguredOrder() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED, CI_PASS));
+    Comparator<String> comparator = labelTypes.nameComparator();
+
+    List<String> names = Arrays.asList("CI-Pass", "Code-Review", "Verified");
+    names.sort(comparator);
+    assertThat(names).containsExactly("Code-Review", "Verified", "CI-Pass").inOrder();
+  }
+
+  @Test
+  public void nameComparatorPlacesUnknownLabelsAtEndInAlphabeticalOrder() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(VERIFIED, CODE_REVIEW));
+    Comparator<String> comparator = labelTypes.nameComparator();
+
+    List<String> names = Arrays.asList("Unknown-Z", "Code-Review", "Unknown-A", "Verified");
+    names.sort(comparator);
+    assertThat(names)
+        .containsExactly("Verified", "Code-Review", "Unknown-A", "Unknown-Z")
+        .inOrder();
+  }
+
+  @Test
+  public void nameComparatorWithEmptyLabelTypesSortsAlphabetically() {
+    LabelTypes labelTypes = new LabelTypes(Collections.emptyList());
+    Comparator<String> comparator = labelTypes.nameComparator();
+
+    List<String> names = Arrays.asList("Z", "B", "A");
+    names.sort(comparator);
+    assertThat(names).containsExactly("A", "B", "Z").inOrder();
+  }
+
+  @Test
+  public void nameComparatorReturnsZeroForIdenticalNames() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED));
+    Comparator<String> comparator = labelTypes.nameComparator();
+
+    assertThat(comparator.compare("Code-Review", "Code-Review")).isEqualTo(0);
+    assertThat(comparator.compare("Unknown", "Unknown")).isEqualTo(0);
+  }
+
+  @Test
+  public void nameComparatorReturnsCachedInstance() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED));
+    assertThat(labelTypes.nameComparator()).isSameInstanceAs(labelTypes.nameComparator());
+  }
+
+  @SuppressWarnings({"EqualsNull", "EqualsIncompatibleType"})
+  @Test
+  public void equalsAndHashCodeContract() {
+    LabelTypes labelTypes1 = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED));
+    LabelTypes labelTypes2 = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED));
+    LabelTypes labelTypes3 = new LabelTypes(ImmutableList.of(VERIFIED, CODE_REVIEW));
+
+    assertThat(labelTypes1.equals(labelTypes1)).isTrue();
+    assertThat(labelTypes1.equals(labelTypes2)).isTrue();
+    assertThat(labelTypes2.equals(labelTypes1)).isTrue();
+    assertThat(labelTypes1.hashCode()).isEqualTo(labelTypes2.hashCode());
+
+    assertThat(labelTypes1.equals(labelTypes3)).isFalse();
+    assertThat(labelTypes1.equals(null)).isFalse();
+    assertThat(labelTypes1.equals("some string")).isFalse();
+  }
+
+  @Test
+  public void testToString() {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of(CODE_REVIEW, VERIFIED));
+    assertThat(labelTypes.toString()).isEqualTo(ImmutableList.of(CODE_REVIEW, VERIFIED).toString());
+  }
+
+  @Test
+  public void constructFromMap() {
+    Map<String, LabelType> map = new LinkedHashMap<>();
+    map.put("code-review", CODE_REVIEW);
+    map.put("verified", VERIFIED);
+
+    LabelTypes labelTypes = new LabelTypes(map);
+    assertThat(labelTypes.getLabelTypes()).containsExactly(CODE_REVIEW, VERIFIED).inOrder();
+    assertThat(labelTypes.byLabel("Code-Review")).hasValue(CODE_REVIEW);
+    assertThat(labelTypes.byLabel("Verified")).hasValue(VERIFIED);
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/AccountProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountProtoConverterTest.java
index 756a266..e447365 100644
--- a/javatests/com/google/gerrit/entities/converter/AccountProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/AccountProtoConverterTest.java
@@ -23,6 +23,8 @@
 import java.lang.reflect.Type;
 import java.time.Instant;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
 /**
  * Tests for {@link AccountProtoConverter}.
@@ -32,8 +34,9 @@
  * {@code SafeProtoConverter} because proto3 cannot distinguish between unset and empty string
  * fields, while Account treats null and empty string differently.
  */
+@RunWith(JUnit4.class)
 public class AccountProtoConverterTest {
-  private final AccountProtoConverter converter = AccountProtoConverter.INSTANCE;
+  private static final AccountProtoConverter CONVERTER = AccountProtoConverter.INSTANCE;
 
   @Test
   public void allFieldsConvertedToProtoAndBack() {
@@ -49,7 +52,7 @@
             .setUniqueTag("unique-123")
             .build();
 
-    Account roundTripped = converter.fromProto(converter.toProto(account));
+    Account roundTripped = CONVERTER.fromProto(CONVERTER.toProto(account));
 
     assertThat(roundTripped).isEqualTo(account);
   }
@@ -61,7 +64,7 @@
             .setInactive(false)
             .build();
 
-    Account roundTripped = converter.fromProto(converter.toProto(account));
+    Account roundTripped = CONVERTER.fromProto(CONVERTER.toProto(account));
 
     assertThat(roundTripped.fullName()).isNull();
     assertThat(roundTripped.displayName()).isNull();
@@ -79,7 +82,7 @@
             .setInactive(false)
             .build();
 
-    Account roundTripped = converter.fromProto(converter.toProto(account));
+    Account roundTripped = CONVERTER.fromProto(CONVERTER.toProto(account));
 
     assertThat(roundTripped.avatarEmail()).isEqualTo("avatar@example.com");
     assertThat(roundTripped.preferredEmail()).isEqualTo("preferred@example.com");
@@ -94,7 +97,7 @@
             .setInactive(false)
             .build();
 
-    Account roundTripped = converter.fromProto(converter.toProto(account));
+    Account roundTripped = CONVERTER.fromProto(CONVERTER.toProto(account));
 
     assertThat(roundTripped.avatarEmail()).isNull();
     assertThat(roundTripped.effectiveAvatarEmail()).isEqualTo("preferred@example.com");
@@ -107,8 +110,8 @@
     Account inactive =
         Account.builder(Account.id(2), Instant.ofEpochMilli(1L)).setInactive(true).build();
 
-    assertThat(converter.fromProto(converter.toProto(active)).inactive()).isFalse();
-    assertThat(converter.fromProto(converter.toProto(inactive)).inactive()).isTrue();
+    assertThat(CONVERTER.fromProto(CONVERTER.toProto(active)).inactive()).isFalse();
+    assertThat(CONVERTER.fromProto(CONVERTER.toProto(inactive)).inactive()).isTrue();
   }
 
   /**
@@ -133,6 +136,6 @@
                 .put("status", String.class)
                 .put("metaId", String.class)
                 .put("uniqueTag", String.class)
-                .build());
+                .buildOrThrow());
   }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
index e64a9e5..05ffa4d 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
@@ -177,7 +177,6 @@
             .setProject("test-project")
             .setBranch("test-branch")
             .setSubject("test-subject")
-            .setNotify(Entities.NotifyHandling.ALL)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
diff --git a/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
index ce7664a..a89d75d 100644
--- a/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
@@ -292,7 +292,7 @@
           } else {
             res.setField(f, defaultInstance.getField(f));
           }
-        } catch (Exception e) {
+        } catch (RuntimeException e) {
           throw new IllegalStateException("Failed to fill default instance for " + f.getName(), e);
         }
       }
diff --git a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
index f8945b5..3c13053 100644
--- a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
+++ b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
@@ -158,4 +158,24 @@
             BooleanCondition.valueOf(true));
     assertEquals(nonReduced.reduce(), reduced);
   }
+
+  @Test
+  public void lazyCondition_NotEvaluatedWhenShortCircuitedByFalseAnd() throws Exception {
+    BooleanCondition lazy =
+        BooleanCondition.lazy(
+            () -> {
+              throw new AssertionError("supplier should not be evaluated");
+            });
+    BooleanCondition combined = BooleanCondition.and(false, lazy);
+    assertEquals(BooleanCondition.valueOf(false), combined.reduce());
+    assertEquals(false, combined.value());
+  }
+
+  @Test
+  public void lazyCondition_EvaluatedWhenNeeded() throws Exception {
+    BooleanCondition lazyTrue = BooleanCondition.lazy(() -> true);
+    BooleanCondition lazyFalse = BooleanCondition.lazy(() -> false);
+    assertEquals(true, BooleanCondition.and(true, lazyTrue).value());
+    assertEquals(false, BooleanCondition.and(true, lazyFalse).value());
+  }
 }
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
index 5ca382a..a2c9ef7 100644
--- a/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
@@ -28,7 +28,10 @@
 import com.google.inject.TypeLiteral;
 import java.util.function.Consumer;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
+@RunWith(JUnit4.class)
 public class DynamicItemTest {
   private static final String PLUGIN_NAME = "plugin-name";
   private static final String ANOTHER_PLUGIN = "another-plugin";
@@ -57,12 +60,12 @@
   }
 
   @Test
-  public void shouldAssignDynamicItemTwice_GerritCoreThenPlugin() {
+  public void shouldAssignDynamicItemTwice_gerritCoreThenPlugin() {
     shouldAssignDynamicItemTwice(PluginName.GERRIT, ANOTHER_PLUGIN);
   }
 
   @Test
-  public void shouldAssignDynamicItemTwice_PluginOverridesPlugin() {
+  public void shouldAssignDynamicItemTwice_pluginOverridesPlugin() {
     shouldAssignDynamicItemTwice(PLUGIN_NAME, ANOTHER_PLUGIN);
   }
 
diff --git a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 41b2b9f..02e30a1 100644
--- a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -20,6 +20,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -39,6 +40,9 @@
 import org.mockito.InOrder;
 
 public class AllRequestFilterFilterProxyTest {
+  public static final String PLUGIN_NAME = "plugin";
+  public static final String OTHER_PLUGIN_NAME = "other-plugin";
+
   /**
    * Set of filters for FilterProxy
    *
@@ -79,8 +83,14 @@
    */
   @CanIgnoreReturnValue
   private ReloadableRegistrationHandle<AllRequestFilter> addFilter(AllRequestFilter filter) {
+    return addFilter(PLUGIN_NAME, filter);
+  }
+
+  @CanIgnoreReturnValue
+  private ReloadableRegistrationHandle<AllRequestFilter> addFilter(
+      String pluginName, AllRequestFilter filter) {
     Key<AllRequestFilter> key = Key.get(AllRequestFilter.class);
-    return filters.add("gerrit", key, Providers.of(filter));
+    return filters.add(pluginName, key, Providers.of(filter));
   }
 
   @Test
@@ -270,19 +280,20 @@
     HttpServletResponse res3 = new FakeHttpServletResponse();
 
     Plugin plugin = mock(Plugin.class);
+    when(plugin.getName()).thenReturn(PLUGIN_NAME);
 
     FilterChain chain = mock(FilterChain.class);
 
     ArgumentCaptor<FilterChain> capturedChainA1 = ArgumentCaptor.forClass(FilterChain.class);
     ArgumentCaptor<FilterChain> capturedChainB1 = ArgumentCaptor.forClass(FilterChain.class);
-    ArgumentCaptor<FilterChain> capturedChainB2 = ArgumentCaptor.forClass(FilterChain.class);
 
     AllRequestFilter filterA = mock(AllRequestFilter.class);
     AllRequestFilter filterB = mock(AllRequestFilter.class);
 
     AllRequestFilter.FilterProxy filterProxy = getFilterProxy();
     ReloadableRegistrationHandle<AllRequestFilter> handleFilterA = addFilter(filterA);
-    ReloadableRegistrationHandle<AllRequestFilter> handleFilterB = addFilter(filterB);
+    ReloadableRegistrationHandle<AllRequestFilter> handleFilterB =
+        addFilter(OTHER_PLUGIN_NAME, filterB);
 
     InOrder inorder = inOrder(filterA, filterB, chain);
 
@@ -299,25 +310,16 @@
     capturedChainB1.getValue().doFilter(req1, res1);
     inorder.verify(chain).doFilter(req1, res1);
 
-    // Unloading filterA
-    handleFilterA.remove();
-    filterProxy.onStopPlugin(plugin);
+    // Preparing to stop plugin
+    filterProxy.beforeStopPlugin(plugin);
 
+    // The registered filters have been destroyed
     inorder.verify(filterA).destroy(); // Cleaning up of filterA after it got unloaded
+    inorder.verify(filterB, never()).destroy(); // Cleaning up of filterA after it got unloaded
 
-    // Request #2 only with filterB
-    filterProxy.doFilter(req2, res2, chain);
-
-    inorder.verify(filterB).doFilter(eq(req2), eq(res2), capturedChainB2.capture());
-    inorder.verify(filterA, never()).doFilter(eq(req2), eq(res2), any(FilterChain.class));
-    capturedChainB2.getValue().doFilter(req2, res2);
-    inorder.verify(chain).doFilter(req2, res2);
-
-    // Unloading filterB
+    // Unload filters
+    handleFilterA.remove();
     handleFilterB.remove();
-    filterProxy.onStopPlugin(plugin);
-
-    inorder.verify(filterB).destroy(); // Cleaning up of filterA after it got unloaded
 
     // Request #3 with no additional filters
     filterProxy.doFilter(req3, res3, chain);
diff --git a/javatests/com/google/gerrit/httpd/BUILD b/javatests/com/google/gerrit/httpd/BUILD
index 2b93045..0252635 100644
--- a/javatests/com/google/gerrit/httpd/BUILD
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -4,11 +4,15 @@
     name = "httpd_tests",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/testing:gerrit-junit",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:gson",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/httpd/auth/restapi/GetOAuthTokenTest.java b/javatests/com/google/gerrit/httpd/auth/restapi/GetOAuthTokenTest.java
new file mode 100644
index 0000000..e178e09
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/auth/restapi/GetOAuthTokenTest.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.auth.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.auth.oauth.OAuthTokenRefresher;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.auth.oauth.OAuthRevokedException;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.httpd.auth.restapi.GetOAuthToken.OAuthTokenInfo;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.inject.util.Providers;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GetOAuthTokenTest {
+  private static final Account.Id ACCOUNT = Account.id(1);
+
+  @Mock private CurrentUser self;
+  @Mock private IdentifiedUser user;
+  @Mock private AccountResource rsrc;
+  @Mock private OAuthTokenCache tokenCache;
+  @Mock private OAuthTokenRefresher refresher;
+
+  @Before
+  public void setUp() {
+    when(rsrc.getUser()).thenReturn(user);
+    when(user.getAccountId()).thenReturn(ACCOUNT);
+    when(self.hasSameAccountId(user)).thenReturn(true);
+  }
+
+  private GetOAuthToken command() {
+    return new GetOAuthToken(
+        Providers.of(self), tokenCache, refresher, Providers.of("https://gerrit.example.org/"));
+  }
+
+  private static OAuthToken token(long expiresAt) {
+    return new OAuthToken("at", "bearer", "{}", expiresAt, "p:x");
+  }
+
+  @Test
+  public void validToken_refreshedOnRead_returned() throws Exception {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(Long.MAX_VALUE));
+    when(user.getUserName()).thenReturn(Optional.of("jdoe"));
+
+    Response<OAuthTokenInfo> res = command().apply(rsrc);
+
+    assertThat(res.value().accessToken).isEqualTo("at");
+    verify(refresher).refreshIfExpired(ACCOUNT);
+  }
+
+  @Test
+  public void expiredToken_refreshedSuccessfully_returned() throws Exception {
+    // refreshIfExpired renews the expired token in place, so the read-after-refresh sees a valid
+    // one: getEvenIfExpired only returns a token because refresh ran (absent it, the read would
+    // miss and this would 404), which is exactly the "expired -> refreshed -> returned" path.
+    doAnswer(
+            inv -> {
+              when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(Long.MAX_VALUE));
+              return null;
+            })
+        .when(refresher)
+        .refreshIfExpired(ACCOUNT);
+    when(user.getUserName()).thenReturn(Optional.of("jdoe"));
+
+    Response<OAuthTokenInfo> res = command().apply(rsrc);
+
+    assertThat(res.value().accessToken).isEqualTo("at");
+    verify(refresher).refreshIfExpired(ACCOUNT);
+  }
+
+  @Test
+  public void expiredUnrefreshableToken_notFound() throws Exception {
+    // refresher is a no-op mock (could not refresh); the token is still expired.
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(token(System.currentTimeMillis() - 1000));
+
+    assertThrows(ResourceNotFoundException.class, () -> command().apply(rsrc));
+  }
+
+  @Test
+  public void absentToken_notFound() throws Exception {
+    when(tokenCache.getEvenIfExpired(ACCOUNT)).thenReturn(null);
+
+    assertThrows(ResourceNotFoundException.class, () -> command().apply(rsrc));
+  }
+
+  @Test
+  public void revokedGrant_notFound() throws Exception {
+    doThrow(new OAuthRevokedException("revoked")).when(refresher).refreshIfExpired(ACCOUNT);
+
+    assertThrows(ResourceNotFoundException.class, () -> command().apply(rsrc));
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index d582b4b..3b7f439 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,27 +15,181 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.Accounts;
 import com.google.gerrit.extensions.api.config.Config;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.config.CloneCommand;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.restapi.config.GetServerInfo;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
+import org.junit.Before;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class IndexServletTest {
+  private static final String FAKE_USER1 = "user1";
+  private static final String FAKE_USER2 = "user2";
+  private static final FakeCurrentUser FAKE_CURRENT_USER1 = new FakeCurrentUser(FAKE_USER1);
+  private static final FakeCurrentUser FAKE_CURRENT_USER2 = new FakeCurrentUser(FAKE_USER2);
+
+  @Mock Server serverApi;
+
+  @Mock GerritApi gerritApi;
+
+  @Mock Config configApi;
+
+  @Mock Accounts accountsApi;
+
+  @Inject ThreadLocalRequestContext threadLocalRequestContext;
+
+  @Inject GetServerInfo getServerInfo;
+
+  private static class FakeCurrentUser extends CurrentUser {
+    private static final GroupMembership groups = new ListGroupMembership(List.of());
+
+    private final String username;
+
+    FakeCurrentUser(String name) {
+      username = name;
+    }
+
+    @Override
+    public GroupMembership getEffectiveGroups() {
+      return groups;
+    }
+
+    @Override
+    public Object getCacheKey() {
+      return username;
+    }
+
+    @Override
+    public Optional<String> getUserName() {
+      return Optional.ofNullable(username);
+    }
+
+    @Override
+    public boolean isIdentifiedUser() {
+      return true;
+    }
+  }
+
+  private static class FakeDownloadScheme extends DownloadScheme {
+    @Override
+    public String getUrl(String project) {
+      return "some-protocol://" + project;
+    }
+
+    @Override
+    public boolean isAuthRequired() {
+      return true;
+    }
+
+    @Override
+    public boolean isAuthSupported() {
+      return true;
+    }
+
+    @Override
+    public boolean isEnabled() {
+      return true;
+    }
+
+    @Override
+    public boolean isHidden() {
+      return false;
+    }
+  }
+
+  private static class FakeDownloadCommand extends DownloadCommand {
+
+    @Inject Provider<CurrentUser> currentUserProvider;
+
+    @Override
+    public String getCommand(DownloadScheme scheme, String project, String ref) {
+      CurrentUser currentUser = currentUserProvider.get();
+      String url = scheme.getUrl(project);
+      if (currentUser.isIdentifiedUser()) {
+        url = url + "/" + currentUser.getUserName().orElseThrow();
+      }
+      return String.format("fake git fetch %s %s", url, ref);
+    }
+  }
+
+  private static class FakeCloneCommand extends CloneCommand {
+
+    @Inject Provider<CurrentUser> currentUserProvider;
+
+    @Override
+    public String getCommand(DownloadScheme scheme, String project) {
+      CurrentUser currentUser = currentUserProvider.get();
+      String url = scheme.getUrl(project);
+      if (currentUser.isIdentifiedUser()) {
+        url = url + "/" + currentUser.getUserName().orElseThrow();
+      }
+      return String.format("fake git clone %s", url);
+    }
+  }
+
+  @Before
+  public void setup() throws RestApiException {
+    Injector injector =
+        Guice.createInjector(
+            new InMemoryModule() {
+              @Override
+              protected void configure() {
+                configure(false);
+                bind(GerritApi.class).toInstance(gerritApi);
+                bind(DownloadScheme.class)
+                    .annotatedWith(Exports.named("testscheme"))
+                    .to(FakeDownloadScheme.class);
+                bind(DownloadCommand.class)
+                    .annotatedWith(Exports.named("checkout"))
+                    .to(FakeDownloadCommand.class);
+                bind(CloneCommand.class)
+                    .annotatedWith(Exports.named("clone"))
+                    .to(FakeCloneCommand.class);
+              }
+            });
+    injector.injectMembers(this);
+  }
 
   @Test
   public void renderTemplate() throws Exception {
@@ -105,4 +259,77 @@
                 + String.join("\\x22,\\x22", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
+
+  @Test
+  public void downloadInfoIsTheSameForTwoAnonymousUsers() throws Exception {
+    try (ManualRequestContext ctx = mockGerritApi(new AnonymousUser())) {
+
+      IndexServlet servlet =
+          new IndexServlet(
+              null,
+              null,
+              null,
+              gerritApi,
+              new ConfigExperimentFeatures(new org.eclipse.jgit.lib.Config()));
+
+      FakeHttpServletResponse indexHtmlResponse1 = new FakeHttpServletResponse();
+      servlet.doGet(new FakeHttpServletRequest(), indexHtmlResponse1);
+      FakeHttpServletResponse indexHtmlResponse2 = new FakeHttpServletResponse();
+      servlet.doGet(new FakeHttpServletRequest(), indexHtmlResponse2);
+
+      assertThat(indexHtmlResponse1.getActualBodyString())
+          .isEqualTo(indexHtmlResponse2.getActualBodyString());
+    }
+  }
+
+  @Test
+  public void downloadInfoIsNotCachedForIdentifiedUsers() throws Exception {
+    FakeHttpServletResponse indexHtmlResponse1 = new FakeHttpServletResponse();
+    FakeHttpServletResponse indexHtmlResponse2 = new FakeHttpServletResponse();
+
+    IndexServlet servlet =
+        new IndexServlet(
+            null,
+            null,
+            null,
+            gerritApi,
+            new ConfigExperimentFeatures(new org.eclipse.jgit.lib.Config()));
+
+    try (ManualRequestContext ctx = mockGerritApi(FAKE_CURRENT_USER1)) {
+      servlet.doGet(new FakeHttpServletRequest(), indexHtmlResponse1);
+    }
+
+    try (ManualRequestContext ctx = mockGerritApi(FAKE_CURRENT_USER2)) {
+      servlet.doGet(new FakeHttpServletRequest(), indexHtmlResponse2);
+    }
+
+    String indexBodyUser1 = indexHtmlResponse1.getActualBodyString();
+    String indexBodyUser2 = indexHtmlResponse2.getActualBodyString();
+    assertThat(indexBodyUser1).contains(FAKE_USER1);
+    assertThat(indexBodyUser2).contains(FAKE_USER2);
+    assertThat(indexBodyUser1).isNotEqualTo(indexBodyUser2);
+    verify(serverApi, times(2)).getInfo();
+  }
+
+  private ManualRequestContext mockGerritApi(CurrentUser currentUser) throws RestApiException {
+    ManualRequestContext ctx = new ManualRequestContext(currentUser, threadLocalRequestContext);
+    if (currentUser.isIdentifiedUser()) {
+      AccountApi accountApi = mock(AccountApi.class);
+      lenient().when(accountsApi.self()).thenReturn(accountApi);
+    } else {
+      lenient()
+          .when(accountsApi.self())
+          .thenThrow(new AuthException("user needs to be authenticated"));
+    }
+
+    lenient().when(serverApi.getVersion()).thenReturn("123");
+    lenient().when(serverApi.topMenus()).thenReturn(ImmutableList.of(), ImmutableList.of());
+    lenient()
+        .when(serverApi.getInfo())
+        .thenAnswer((m) -> getServerInfo.apply(new ConfigResource()).value());
+    lenient().when(configApi.server()).thenReturn(serverApi);
+    lenient().when(gerritApi.accounts()).thenReturn(accountsApi);
+    lenient().when(gerritApi.config()).thenReturn(configApi);
+    return ctx;
+  }
 }
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index fc53031..95533b1 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -88,9 +88,11 @@
     final TestPredicate<String> b = f("author", "bob");
     final TestPredicate<String> c = f("author", "charlie");
 
-    assertTrue(and(a, b).hashCode() == and(a, b).hashCode());
-    assertTrue(and(a, b, c).hashCode() == and(a, b, c).hashCode());
+    assertEquals(new AndPredicate<>(a).hashCode(), new AndPredicate<>(a).hashCode());
+    assertEquals(and(a, b).hashCode(), and(a, b).hashCode());
+    assertEquals(and(a, b, c).hashCode(), and(a, b, c).hashCode());
     assertFalse(and(a, c).hashCode() == and(a, b).hashCode());
+    assertFalse(and(a, b).hashCode() == new OrPredicate<>(a, b).hashCode());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index 4b00a52..0db840a 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -88,9 +88,11 @@
     final TestPredicate<String> b = f("author", "bob");
     final TestPredicate<String> c = f("author", "charlie");
 
-    assertTrue(or(a, b).hashCode() == or(a, b).hashCode());
-    assertTrue(or(a, b, c).hashCode() == or(a, b, c).hashCode());
+    assertEquals(new OrPredicate<>(a).hashCode(), new OrPredicate<>(a).hashCode());
+    assertEquals(or(a, b).hashCode(), or(a, b).hashCode());
+    assertEquals(or(a, b, c).hashCode(), or(a, b, c).hashCode());
     assertFalse(or(a, c).hashCode() == or(a, b).hashCode());
+    assertFalse(or(a, b).hashCode() == new AndPredicate<>(a, b).hashCode());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/index/query/PaginatingSourceTest.java b/javatests/com/google/gerrit/index/query/PaginatingSourceTest.java
new file mode 100644
index 0000000..5fed380
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/PaginatingSourceTest.java
@@ -0,0 +1,247 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeFalse;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.PaginationType;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.testing.ConfigSuite;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(ConfigSuite.class)
+public class PaginatingSourceTest extends PredicateTest {
+
+  @ConfigSuite.Parameter public Config config;
+
+  private static class TestPaginatedSource implements DataSource<String>, Paginated<String> {
+    private final List<String> allItems;
+    private final QueryOptions options;
+    private int restartCount = 0;
+    private int readCount = 0;
+
+    TestPaginatedSource(List<String> allItems, QueryOptions options) {
+      this.allItems = allItems;
+      this.options = options;
+    }
+
+    @Override
+    public QueryOptions getOptions() {
+      return options;
+    }
+
+    @Override
+    public ResultSet<String> read() {
+      readCount++;
+      return getSlice(0, options.pageSize());
+    }
+
+    @Override
+    public ResultSet<String> restart(int start) {
+      return restart(start, options.pageSize());
+    }
+
+    @Override
+    public ResultSet<String> restart(int start, int pageSize) {
+      restartCount++;
+      return getSlice(start, pageSize);
+    }
+
+    @Override
+    public ResultSet<String> restart(Object searchAfter, int pageSize) {
+      restartCount++;
+      int start = searchAfter == null ? 0 : ((Integer) searchAfter) + 1;
+      return getSlice(start, pageSize);
+    }
+
+    private ResultSet<String> getSlice(int start, int pageSize) {
+      if (start >= allItems.size()) {
+        return new ListResultSet<>(ImmutableList.of());
+      }
+      int end = Math.min(start + pageSize, allItems.size());
+      List<String> slice = new ArrayList<>(allItems.subList(start, end));
+      return new ListResultSet<String>(slice) {
+        @Override
+        public Object searchAfter() {
+          return end - 1;
+        }
+      };
+    }
+
+    @Override
+    public int getCardinality() {
+      return allItems.size();
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private QueryOptions createOptions(IndexConfig indexConfig, int pageSize, int limit) {
+    return QueryOptions.create(
+        indexConfig,
+        0,
+        null,
+        pageSize,
+        indexConfig.pageSizeMultiplier(),
+        limit,
+        /* allowIncompleteResults= */ false,
+        ImmutableSet.of());
+  }
+
+  @Test
+  public void read_doesNotRestartWhenFirstPageMeetsLimit() {
+    IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+    assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+    List<String> items =
+        IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+    // limit 26 (e.g. 25 + 1 probe), pageSize 26
+    QueryOptions options = createOptions(indexConfig, 26, 26);
+    TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+    PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+    ImmutableList<String> results = paginatingSource.read().toList();
+
+    assertThat(results).hasSize(26);
+    assertThat(source.readCount).isEqualTo(1);
+    // Crucial check: no second query issued since the first query already returned 26 items
+    assertThat(source.restartCount).isEqualTo(0);
+  }
+
+  @Test
+  public void read_doesNotRestartWhenResultsFewerThanLimit() {
+    IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+    assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+    List<String> items =
+        IntStream.range(0, 10).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+    QueryOptions options = createOptions(indexConfig, 26, 26);
+    TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+    PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+    ImmutableList<String> results = paginatingSource.read().toList();
+
+    assertThat(results).hasSize(10);
+    assertThat(source.readCount).isEqualTo(1);
+    assertThat(source.restartCount).isEqualTo(0);
+  }
+
+  @Test
+  public void read_restartsToBackfillWhenVisibleResultsLessThanLimit() {
+    IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+    assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+    // 100 items, only even-indexed items match
+    List<String> items =
+        IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+    // pageSize 10, limit 15
+    QueryOptions options = createOptions(indexConfig, 10, 15);
+    TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+    Predicate<String> filter = item -> Integer.parseInt(item.substring("item-".length())) % 2 == 0;
+
+    PaginatingSource<String> paginatingSource =
+        new PaginatingSource<>(source, 0, indexConfig) {
+          @Override
+          protected boolean match(String object) {
+            return filter.test(object);
+          }
+        };
+
+    ImmutableList<String> results = paginatingSource.read().toList();
+
+    // Page 1: 10 items (0..9) -> 5 match (item-0, 2, 4, 6, 8)
+    // Page 2: 10 items (10..19) -> 5 match (item-10, 12, 14, 16, 18) [total: 10]
+    // Page 3: 10 items (20..29) -> 5 match (item-20, 22, 24, 26, 28) [total: 15] -> limit reached!
+    assertThat(results).hasSize(15);
+    assertThat(source.readCount).isEqualTo(1);
+    // Restarted exactly 2 times to reach 15 items, never issued an unnecessary 3rd restart
+    assertThat(source.restartCount).isEqualTo(2);
+  }
+
+  @Test
+  public void read_breaksEarlyOnSubsequentPageWhenLimitReached() {
+    IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+    assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+    List<String> items =
+        IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+    // pageSize 10, limit 15 (page 1 gives 10 items, page 2 only needs to yield 5 items)
+    QueryOptions options = createOptions(indexConfig, 10, 15);
+    TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+    PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+    ImmutableList<String> results = paginatingSource.read().toList();
+
+    assertThat(results).hasSize(15);
+    assertThat(source.readCount).isEqualTo(1);
+    assertThat(source.restartCount).isEqualTo(1);
+  }
+
+  @Test
+  public void read_withStartOffsetDoesNotRestartWhenLimitMet() {
+    IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+    assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+    List<String> items =
+        IntStream.range(0, 100).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+    // start=10, limit=25 + 1 probe = 26. convertForBackend gives limit=36, pageSize=36
+    QueryOptions options = createOptions(indexConfig, 36, 36);
+    TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+    PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 10, indexConfig);
+    ImmutableList<String> results = paginatingSource.read().toList();
+
+    // 36 items read, start=10 dropped, leaving 26 items
+    assertThat(results).hasSize(26);
+    assertThat(source.readCount).isEqualTo(1);
+    assertThat(source.restartCount).isEqualTo(0);
+  }
+
+  @Test
+  public void read_noLimitQueryPaginatesUntilExhaustion() {
+    IndexConfig indexConfig = IndexConfig.fromConfig(config).build();
+    assumeFalse(PaginationType.NONE.equals(indexConfig.paginationType()));
+
+    List<String> items =
+        IntStream.range(0, 30).mapToObj(i -> "item-" + i).collect(Collectors.toList());
+    // pageSize 10, limit Integer.MAX_VALUE
+    QueryOptions options = createOptions(indexConfig, 10, Integer.MAX_VALUE);
+    TestPaginatedSource source = new TestPaginatedSource(items, options);
+
+    PaginatingSource<String> paginatingSource = new PaginatingSource<>(source, 0, indexConfig);
+    ImmutableList<String> results = paginatingSource.read().toList();
+
+    assertThat(results).hasSize(30);
+    assertThat(source.readCount).isEqualTo(1);
+    // Page 1: 0..9 (10 items), restart 1: 10..19 (10 items), restart 2: 20..29 (10 items), restart
+    // 3: empty (0 items)
+    assertThat(source.restartCount).isEqualTo(3);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index f726be3..5bcb6bb 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -130,4 +130,79 @@
     /* assert again to test cached email address by IdentifiedUser.invalidEmails */
     assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
   }
+
+  @Test
+  public void materializedCopyPreservesAccessPath() {
+    assertThat(identifiedUser.getAccessPath()).isEqualTo(AccessPath.UNKNOWN);
+    assertThat(identifiedUser.materializedCopy().getAccessPath()).isEqualTo(AccessPath.UNKNOWN);
+
+    for (AccessPath path : AccessPath.values()) {
+      identifiedUser.setAccessPath(path);
+      IdentifiedUser copy = identifiedUser.materializedCopy();
+      assertThat(copy.getAccessPath()).isEqualTo(path);
+    }
+  }
+
+  @Test
+  public void materializedCopyAccessPathMutationIsIsolated() {
+    identifiedUser.setAccessPath(AccessPath.GIT);
+    IdentifiedUser copy = identifiedUser.materializedCopy();
+    assertThat(copy.getAccessPath()).isEqualTo(AccessPath.GIT);
+
+    copy.setAccessPath(AccessPath.WEB_BROWSER);
+    assertThat(identifiedUser.getAccessPath()).isEqualTo(AccessPath.GIT);
+    assertThat(copy.getAccessPath()).isEqualTo(AccessPath.WEB_BROWSER);
+  }
+
+  @Test
+  public void materializedCopyPreservesPropertyMap() {
+    PropertyMap.Key<String> testKey = PropertyMap.key();
+    PropertyMap properties = PropertyMap.builder().put(testKey, "customValue").build();
+    IdentifiedUser userWithProps =
+        identifiedUserFactory.forTest(identifiedUser.getAccountId(), properties);
+    assertThat(userWithProps.get(testKey)).hasValue("customValue");
+
+    IdentifiedUser copy = userWithProps.materializedCopy();
+    assertThat(copy.get(testKey)).hasValue("customValue");
+    assertThat(copy.properties()).isSameInstanceAs(properties);
+  }
+
+  @Test
+  public void materializedCopyRealUserIsIsolatedAndSelfReferencing() {
+    assertThat(identifiedUser.getRealUser()).isSameInstanceAs(identifiedUser);
+
+    IdentifiedUser copy = identifiedUser.materializedCopy();
+    assertThat(copy.getRealUser()).isSameInstanceAs(copy);
+    assertThat(copy.getRealUser()).isNotSameInstanceAs(identifiedUser);
+  }
+
+  @Test
+  public void materializedCopyPreservesImpersonatedRealUser() {
+    Account.Id callerId = Account.id(2);
+    IdentifiedUser caller = identifiedUserFactory.create(callerId);
+    caller.setAccessPath(AccessPath.REST_API);
+
+    IdentifiedUser impersonated =
+        identifiedUserFactory.runAs(
+            /* remotePeer= */ null,
+            identifiedUser.getAccountId(),
+            caller,
+            IdentifiedUser.ImpersonationPermissionMode.THIS_USER);
+    assertThat(impersonated.isImpersonated()).isTrue();
+    assertThat(impersonated.getRealUser().getAccountId()).isEqualTo(callerId);
+
+    IdentifiedUser copy = impersonated.materializedCopy();
+    assertThat(copy.isImpersonated()).isTrue();
+    assertThat(copy.getRealUser().getAccountId()).isEqualTo(callerId);
+    assertThat(copy.getRealUser().getAccessPath()).isEqualTo(AccessPath.REST_API);
+    assertThat(copy.getRealUser()).isNotSameInstanceAs(caller);
+  }
+
+  @Test
+  public void materializedCopyWithUnloadedStateDoesNotThrowNpe() {
+    IdentifiedUser freshUser = identifiedUserFactory.create(Account.id(3));
+    IdentifiedUser copy = freshUser.materializedCopy();
+    assertThat(copy.getAccountId()).isEqualTo(Account.id(3));
+    assertThat(copy.getAccessPath()).isEqualTo(AccessPath.UNKNOWN);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 438990c..7b000653 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -4,11 +4,14 @@
     name = "tests",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
+        "//lib:jgit",
         "//lib:junit",
         "//lib/guice",
         "//lib/mockito",
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
new file mode 100644
index 0000000..9f55711
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.h2;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.cache.CacheDef;
+import com.google.gerrit.server.cache.MemoryCacheFactory;
+import com.google.gerrit.server.cache.PersistentCacheDef;
+import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.EnumSet;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nullable;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class H2CacheFactoryTest {
+  private static final TypeLiteral<String> STRING_TYPE = new TypeLiteral<>() {};
+
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void newSqlStore_setsUrl() throws Exception {
+    Path cacheDir = temporaryFolder.newFolder("cache").toPath();
+    H2CacheFactory factory =
+        new H2CacheFactory(
+            new NoOpMemoryCacheFactory(),
+            new Config(),
+            DynamicMap.emptyMap(),
+            null,
+            null,
+            null,
+            cacheDir,
+            EnumSet.noneOf(CacheOptions.class),
+            new AtomicBoolean(false));
+
+    PersistentCacheDef<String, String> def = new SimplePersistentCacheDef("demo-cache");
+    SqlStore<String, String> store = invokeNewSqlStore(factory, def, 1L);
+
+    assertThat(readField(store, "url"))
+        .isEqualTo(
+            "jdbc:h2:file:"
+                + cacheDir.resolve("demo-cache-v2").toAbsolutePath()
+                + ";DB_CLOSE_DELAY=-1");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static SqlStore<String, String> invokeNewSqlStore(
+      H2CacheFactory factory, PersistentCacheDef<String, String> def, long maxSize)
+      throws Exception {
+    Method method =
+        H2CacheFactory.class.getDeclaredMethod("newSqlStore", PersistentCacheDef.class, long.class);
+    method.setAccessible(true);
+    return (SqlStore<String, String>) method.invoke(factory, def, maxSize);
+  }
+
+  private static Object readField(Object target, String fieldName) throws Exception {
+    Field field = target.getClass().getDeclaredField(fieldName);
+    field.setAccessible(true);
+    return field.get(target);
+  }
+
+  private static class NoOpMemoryCacheFactory implements MemoryCacheFactory {
+    @Override
+    public <K, V> Cache<K, V> build(CacheDef<K, V> def) {
+      return CacheBuilder.newBuilder().build();
+    }
+
+    @Override
+    public <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader) {
+      return CacheBuilder.newBuilder().build(loader);
+    }
+  }
+
+  private static class SimplePersistentCacheDef implements PersistentCacheDef<String, String> {
+    private final String name;
+
+    private SimplePersistentCacheDef(String name) {
+      this.name = name;
+    }
+
+    @Override
+    public String name() {
+      return name;
+    }
+
+    @Override
+    public String configKey() {
+      return name;
+    }
+
+    @Override
+    public TypeLiteral<String> keyType() {
+      return STRING_TYPE;
+    }
+
+    @Override
+    public TypeLiteral<String> valueType() {
+      return STRING_TYPE;
+    }
+
+    @Override
+    public long maximumWeight() {
+      return 0;
+    }
+
+    @Override
+    @Nullable
+    public Duration expireAfterWrite() {
+      return null;
+    }
+
+    @Override
+    @Nullable
+    public Duration expireFromMemoryAfterAccess() {
+      return null;
+    }
+
+    @Override
+    @Nullable
+    public Duration refreshAfterWrite() {
+      return null;
+    }
+
+    @Override
+    @Nullable
+    public Weigher<String, String> weigher() {
+      return null;
+    }
+
+    @Override
+    @Nullable
+    public CacheLoader<String, String> loader() {
+      return null;
+    }
+
+    @Override
+    public long diskLimit() {
+      return 1;
+    }
+
+    @Override
+    public int version() {
+      return 1;
+    }
+
+    @Override
+    public CacheSerializer<String> keySerializer() {
+      return StringCacheSerializer.INSTANCE;
+    }
+
+    @Override
+    public CacheSerializer<String> valueSerializer() {
+      return StringCacheSerializer.INSTANCE;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index cf8bdda..b4228b6 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -75,7 +75,9 @@
         refreshAfterWrite,
         true,
         true,
-        new AtomicBoolean(false));
+        new AtomicBoolean(false),
+        false,
+        null);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
index ebd7d55..810cc38 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/BooleanCacheSerializerTest.java
@@ -23,10 +23,8 @@
 public class BooleanCacheSerializerTest {
   @Test
   public void serialize() throws Exception {
-    assertThat(BooleanCacheSerializer.INSTANCE.serialize(true))
-        .isEqualTo(new byte[] {'t', 'r', 'u', 'e'});
-    assertThat(BooleanCacheSerializer.INSTANCE.serialize(false))
-        .isEqualTo(new byte[] {'f', 'a', 'l', 's', 'e'});
+    assertThat(BooleanCacheSerializer.INSTANCE.serialize(true)).isEqualTo("true".getBytes(UTF_8));
+    assertThat(BooleanCacheSerializer.INSTANCE.serialize(false)).isEqualTo("false".getBytes(UTF_8));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
index dc22805..06af9f1 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/StringCacheSerializerTest.java
@@ -22,13 +22,14 @@
 import org.junit.Test;
 
 public class StringCacheSerializerTest {
+  private static final byte[] ABC = {'a', 'b', 'c'};
+  private static final byte[] A_1234_C = {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'};
+
   @Test
   public void serialize() {
     assertThat(StringCacheSerializer.INSTANCE.serialize("")).isEmpty();
-    assertThat(StringCacheSerializer.INSTANCE.serialize("abc"))
-        .isEqualTo(new byte[] {'a', 'b', 'c'});
-    assertThat(StringCacheSerializer.INSTANCE.serialize("a\u1234c"))
-        .isEqualTo(new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'});
+    assertThat(StringCacheSerializer.INSTANCE.serialize("abc")).isEqualTo(ABC);
+    assertThat(StringCacheSerializer.INSTANCE.serialize("a\u1234c")).isEqualTo(A_1234_C);
   }
 
   @Test
@@ -44,12 +45,8 @@
   @Test
   public void deserialize() {
     assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[0])).isEmpty();
-    assertThat(StringCacheSerializer.INSTANCE.deserialize(new byte[] {'a', 'b', 'c'}))
-        .isEqualTo("abc");
-    assertThat(
-            StringCacheSerializer.INSTANCE.deserialize(
-                new byte[] {'a', (byte) 0xe1, (byte) 0x88, (byte) 0xb4, 'c'}))
-        .isEqualTo("a\u1234c");
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(ABC)).isEqualTo("abc");
+    assertThat(StringCacheSerializer.INSTANCE.deserialize(A_1234_C)).isEqualTo("a\u1234c");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
index 00272112..1eb8e21 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -52,6 +52,7 @@
             .headerLines(ImmutableList.of("header line 1", "header line 2"))
             .edits(edits)
             .negative(Optional.of(true))
+            .diffsTooExpensiveToCompute(Optional.of(true))
             .build();
 
     byte[] serialized = FileDiffOutput.Serializer.INSTANCE.serialize(fileDiff);
diff --git a/javatests/com/google/gerrit/server/comment/CommentContextCacheImplTest.java b/javatests/com/google/gerrit/server/comment/CommentContextCacheImplTest.java
new file mode 100644
index 0000000..56c755d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/comment/CommentContextCacheImplTest.java
@@ -0,0 +1,304 @@
+// Copyright (C) 2026 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.comment;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.AbstractLoadingCache;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CommentContextCacheImplTest {
+
+  private static final Project.NameKey PROJECT = Project.nameKey("my-project");
+  private static final Change.Id CHANGE_ID = Change.id(123);
+
+  private CommentContextKey createKey(String id, String path, int patchset, int padding) {
+    return CommentContextKey.builder()
+        .project(PROJECT)
+        .changeId(CHANGE_ID)
+        .id(id)
+        .path(path)
+        .patchset(patchset)
+        .contextPadding(padding)
+        .build();
+  }
+
+  @Test
+  public void getAll_deduplicatesInputKeys() {
+    CommentContextKey key1 = createKey("c1", "FileA.java", 1, 3);
+    CommentContextKey key1Duplicate = createKey("c1", "FileA.java", 1, 3);
+    CommentContextKey key2 = createKey("c2", "FileB.java", 1, 3);
+
+    CommentContext ctx1 = CommentContext.create(ImmutableMap.of(10, "line 10"), "text/x-java");
+    CommentContext ctx2 = CommentContext.create(ImmutableMap.of(20, "line 20"), "text/x-java");
+
+    List<CommentContextKey> requestedKeys = new ArrayList<>();
+    LoadingCache<CommentContextKey, CommentContext> loadingCache =
+        new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+          @Override
+          public CommentContext get(CommentContextKey key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public CommentContext getIfPresent(Object key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public ImmutableMap<CommentContextKey, CommentContext> getAll(
+              Iterable<? extends CommentContextKey> keys) {
+            ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+                ImmutableMap.builder();
+            for (CommentContextKey k : keys) {
+              requestedKeys.add(k);
+              if (k.id().equals("c1")) {
+                builder.put(k, ctx1);
+              } else if (k.id().equals("c2")) {
+                builder.put(k, ctx2);
+              }
+            }
+            return builder.build();
+          }
+        };
+
+    CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+    ImmutableMap<CommentContextKey, CommentContext> result =
+        cache.getAll(ImmutableList.of(key1, key1Duplicate, key2));
+
+    // Verify cache was queried with only 2 unique keys
+    assertThat(requestedKeys).hasSize(2);
+
+    assertThat(result).hasSize(2);
+    assertThat(result.get(key1)).isEqualTo(ctx1);
+    assertThat(result.get(key2)).isEqualTo(ctx2);
+  }
+
+  @Test
+  public void getAll_handlesMissingCacheEntries() {
+    CommentContextKey key1 = createKey("c1", "FileA.java", 1, 3);
+    CommentContextKey key2 = createKey("c2", "FileB.java", 1, 3);
+
+    CommentContext ctx1 = CommentContext.create(ImmutableMap.of(10, "line 10"), "text/x-java");
+
+    LoadingCache<CommentContextKey, CommentContext> loadingCache =
+        new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+          @Override
+          public CommentContext get(CommentContextKey key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public CommentContext getIfPresent(Object key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public ImmutableMap<CommentContextKey, CommentContext> getAll(
+              Iterable<? extends CommentContextKey> keys) {
+            ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+                ImmutableMap.builder();
+            for (CommentContextKey k : keys) {
+              if (k.id().equals("c1")) {
+                builder.put(k, ctx1);
+              }
+            }
+            return builder.build();
+          }
+        };
+
+    CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+    ImmutableMap<CommentContextKey, CommentContext> result =
+        cache.getAll(ImmutableList.of(key1, key2));
+
+    assertThat(result).hasSize(1);
+    assertThat(result.get(key1)).isEqualTo(ctx1);
+    assertThat(result.containsKey(key2)).isFalse();
+  }
+
+  @Test
+  public void getAll_adjustsNegativeContextPaddingToZero() {
+    CommentContextKey key = createKey("c1", "FileA.java", 1, -5);
+
+    List<CommentContextKey> requestedKeys = new ArrayList<>();
+    LoadingCache<CommentContextKey, CommentContext> loadingCache =
+        new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+          @Override
+          public CommentContext get(CommentContextKey key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public CommentContext getIfPresent(Object key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public ImmutableMap<CommentContextKey, CommentContext> getAll(
+              Iterable<? extends CommentContextKey> keys) {
+            Iterables.addAll(requestedKeys, keys);
+            return ImmutableMap.of();
+          }
+        };
+
+    CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+    ImmutableMap<CommentContextKey, CommentContext> result = cache.getAll(ImmutableList.of(key));
+
+    assertThat(result).isEmpty();
+    assertThat(requestedKeys).hasSize(1);
+    assertThat(requestedKeys.get(0).contextPadding()).isEqualTo(0);
+  }
+
+  @Test
+  public void getAll_adjustsExcessiveContextPaddingToMax() {
+    CommentContextKey key =
+        createKey("c1", "FileA.java", 1, CommentContextCacheImpl.MAX_CONTEXT_PADDING + 20);
+
+    List<CommentContextKey> requestedKeys = new ArrayList<>();
+    LoadingCache<CommentContextKey, CommentContext> loadingCache =
+        new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+          @Override
+          public CommentContext get(CommentContextKey key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public CommentContext getIfPresent(Object key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public ImmutableMap<CommentContextKey, CommentContext> getAll(
+              Iterable<? extends CommentContextKey> keys) {
+            Iterables.addAll(requestedKeys, keys);
+            return ImmutableMap.of();
+          }
+        };
+
+    CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+    ImmutableMap<CommentContextKey, CommentContext> result = cache.getAll(ImmutableList.of(key));
+
+    assertThat(result).isEmpty();
+    assertThat(requestedKeys).hasSize(1);
+    assertThat(requestedKeys.get(0).contextPadding())
+        .isEqualTo(CommentContextCacheImpl.MAX_CONTEXT_PADDING);
+  }
+
+  @Test
+  public void get_singleKey() {
+    CommentContextKey key = createKey("c1", "FileA.java", 1, 3);
+    CommentContext ctx = CommentContext.create(ImmutableMap.of(5, "code line"), "text/x-java");
+
+    LoadingCache<CommentContextKey, CommentContext> loadingCache =
+        new AbstractLoadingCache<CommentContextKey, CommentContext>() {
+          @Override
+          public CommentContext get(CommentContextKey key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public CommentContext getIfPresent(Object key) {
+            throw new UnsupportedOperationException();
+          }
+
+          @Override
+          public ImmutableMap<CommentContextKey, CommentContext> getAll(
+              Iterable<? extends CommentContextKey> keys) {
+            ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+                ImmutableMap.builder();
+            for (CommentContextKey k : keys) {
+              builder.put(k, ctx);
+            }
+            return builder.build();
+          }
+        };
+
+    CommentContextCacheImpl cache = new CommentContextCacheImpl(loadingCache);
+    CommentContext result = cache.get(key);
+    assertThat(result).isEqualTo(ctx);
+  }
+
+  @Test
+  public void commentContextSerializer_roundTrip_multiLineContext() {
+    CommentContext original =
+        CommentContext.create(
+            ImmutableMap.of(
+                1, "public class Foo {",
+                2, "  public void bar() {",
+                3, "    return;",
+                4, "  }",
+                5, "}"),
+            "text/x-java");
+
+    byte[] serialized =
+        CommentContextCacheImpl.CommentContextSerializer.INSTANCE.serialize(original);
+    assertThat(serialized).isNotEmpty();
+
+    CommentContext deserialized =
+        CommentContextCacheImpl.CommentContextSerializer.INSTANCE.deserialize(serialized);
+    assertThat(deserialized).isEqualTo(original);
+    assertThat(deserialized.lines()).isEqualTo(original.lines());
+    assertThat(deserialized.contentType()).isEqualTo("text/x-java");
+  }
+
+  @Test
+  public void commentContextSerializer_roundTrip_emptyContext() {
+    CommentContext original = CommentContext.empty();
+
+    byte[] serialized =
+        CommentContextCacheImpl.CommentContextSerializer.INSTANCE.serialize(original);
+    assertThat(serialized).isNotNull();
+
+    CommentContext deserialized =
+        CommentContextCacheImpl.CommentContextSerializer.INSTANCE.deserialize(serialized);
+    assertThat(deserialized).isEqualTo(original);
+    assertThat(deserialized.lines()).isEmpty();
+    assertThat(deserialized.contentType()).isEmpty();
+  }
+
+  @Test
+  public void commentContextSerializer_roundTrip_emptyLinesAndWhitespace() {
+    CommentContext original =
+        CommentContext.create(
+            ImmutableMap.of(
+                10, "",
+                11, "   ",
+                12, "\t\t",
+                13, "non-empty line"),
+            "text/plain");
+
+    byte[] serialized =
+        CommentContextCacheImpl.CommentContextSerializer.INSTANCE.serialize(original);
+    assertThat(serialized).isNotEmpty();
+
+    CommentContext deserialized =
+        CommentContextCacheImpl.CommentContextSerializer.INSTANCE.deserialize(serialized);
+    assertThat(deserialized).isEqualTo(original);
+    assertThat(deserialized.lines()).isEqualTo(original.lines());
+    assertThat(deserialized.contentType()).isEqualTo("text/plain");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
index 7d1f13e..da4e1da 100644
--- a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -121,6 +121,30 @@
   }
 
   @Test
+  public void minimumInitialDelayAdvancesInitialDelayByWholeIntervals() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1d");
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "20:00");
+    rc.setString("a", null, ScheduleConfig.KEY_MINIMUM_INITIAL_DELAY, "1d");
+
+    Optional<Schedule> schedule = ScheduleConfig.builder(rc, "a").setNow(NOW).buildSchedule();
+
+    assertThat(schedule).isPresent();
+    assertThat(schedule.get().initialDelay()).isEqualTo(ms(1, DAYS) + ms(10, HOURS));
+    assertThat(schedule.get().interval()).isEqualTo(ms(1, DAYS));
+  }
+
+  @Test
+  public void invalidConfigNegativeMinimumInitialDelay() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1d");
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "20:00");
+    rc.setString("a", null, ScheduleConfig.KEY_MINIMUM_INITIAL_DELAY, "-1h");
+
+    assertThat(ScheduleConfig.builder(rc, "a").setNow(NOW).buildSchedule()).isEmpty();
+  }
+
+  @Test
   public void invalidConfigBadJitter() {
     Config rc = new Config();
     rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1h");
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
index 16be1e4..1477df7 100644
--- a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -290,6 +290,7 @@
             .setHideEmptyPane(true)
             .setMatchBrackets(false)
             .setLineWrapping(true)
+            .setResponsiveMode(UserPreferences.DiffPreferencesInfo.ResponsiveMode.FULL_RESPONSIVE)
             .setIgnoreWhitespace(Whitespace.IGNORE_TRAILING)
             .setRetainHeader(true)
             .setSkipDeleted(false)
@@ -327,6 +328,19 @@
   }
 
   @Test
+  public void diffPreferencesInfo_migrationFromLineWrapping() {
+    UserPreferences.DiffPreferencesInfo protoWithLineWrapping =
+        UserPreferences.DiffPreferencesInfo.newBuilder().setLineWrapping(true).build();
+    DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.fromProto(protoWithLineWrapping);
+    assertThat(res.responsiveMode).isEqualTo(DiffPreferencesInfo.ResponsiveMode.FULL_RESPONSIVE);
+
+    UserPreferences.DiffPreferencesInfo protoWithNoLineWrapping =
+        UserPreferences.DiffPreferencesInfo.newBuilder().setLineWrapping(false).build();
+    res = DIFF_PREFERENCES_INFO_CONVERTER.fromProto(protoWithNoLineWrapping);
+    assertThat(res.responsiveMode).isEqualTo(DiffPreferencesInfo.ResponsiveMode.NONE);
+  }
+
+  @Test
   public void editPreferencesInfo_compareEnumNames() {
     // The converter assumes that the enum type equivalents have exactly the same values in both
     // classes. This test goes over all the enums to verify this assumption.
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 53431d1..6fcbf5d 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -105,6 +105,29 @@
   }
 
   @Test
+  public void unmetRequirementField() {
+    SubmitRequirementResult sr1 =
+        submitRequirementResult(
+            "Code-Review", "label:CR=+1", SubmitRequirementExpressionResult.Status.PASS);
+    SubmitRequirementResult sr2 =
+        submitRequirementResult(
+            "Verified", "label:V=+1", SubmitRequirementExpressionResult.Status.FAIL);
+
+    assertThat(sr1.fulfilled()).isTrue();
+    assertThat(sr2.fulfilled()).isFalse();
+
+    ChangeData cd = org.mockito.Mockito.mock(ChangeData.class);
+    org.mockito.Mockito.when(cd.submitRequirementsIncludingLegacy())
+        .thenReturn(
+            com.google.common.collect.ImmutableMap.of(
+                sr1.submitRequirement(), sr1,
+                sr2.submitRequirement(), sr2));
+
+    assertThat(ChangeField.UNMET_REQUIREMENT_FIELD.get(cd)).containsExactly("verified");
+    assertThat(ChangeField.UNSATISFIED_REQUIREMENT_COUNT_FIELD.get(cd)).isEqualTo(1);
+  }
+
+  @Test
   public void storedSubmitRecords() {
     assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
 
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index b953406..0f63628 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -142,6 +142,22 @@
   }
 
   @Test
+  public void nonIndexAndWithRewrittenOrSourcePredicates() throws Exception {
+    Predicate<ChangeData> in = parse("baz:a (baz:b OR baz:c)");
+    Predicate<ChangeData> out = rewrite(in);
+
+    // The OR is rewritten into an OrSource while baz:a remains a non-indexed datasource. No
+    // indexed child exists, so the rewriter must not insert a match-all IndexedChangeQuery.
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
+    assertThat(out.getChildCount()).isEqualTo(2);
+    assertThat(out.getChild(0)).isEqualTo(parse("baz:a"));
+    assertThat(out.getChild(1).getClass()).isSameInstanceAs(OrSource.class);
+    assertThat(out.getChild(1).getChildren())
+        .containsExactly(parse("baz:b"), parse("baz:c"))
+        .inOrder();
+  }
+
+  @Test
   public void oneIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
diff --git a/javatests/com/google/gerrit/server/index/change/PendingIndexUpdateScannerTest.java b/javatests/com/google/gerrit/server/index/change/PendingIndexUpdateScannerTest.java
new file mode 100644
index 0000000..4a23d46
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/change/PendingIndexUpdateScannerTest.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2026 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.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.ScheduledExecutorService;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class PendingIndexUpdateScannerTest {
+  private static final long DEAD_THREAD_ID = Long.MAX_VALUE;
+  private static final Project.NameKey PROJECT = Project.nameKey("test-project");
+  private static final Change.Id CHANGE_ID = Change.id(42);
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+  @Rule public final TemporaryFolder tempDir = new TemporaryFolder();
+
+  @Mock private ChangeIndexer indexer;
+  @Mock private WorkQueue workQueue;
+  @Mock private ScheduledExecutorService fakeQueue;
+
+  private SitePaths sitePaths;
+  private PendingIndexUpdate pendingIndexUpdate;
+  private PendingIndexUpdateScanner scanner;
+
+  @Before
+  public void setUp() throws Exception {
+    lenient().doNothing().when(indexer).index(any(), any());
+    lenient().doNothing().when(indexer).delete(any(), any());
+    // Run submitted tasks synchronously so startup recovery completes inline.
+    doAnswer(
+            inv -> {
+              ((Runnable) inv.getArgument(0)).run();
+              return null;
+            })
+        .when(fakeQueue)
+        .submit(any(Runnable.class));
+    when(workQueue.getDefaultQueue()).thenReturn(fakeQueue);
+    sitePaths = new SitePaths(tempDir.getRoot().toPath());
+    pendingIndexUpdate = new PendingIndexUpdate(sitePaths, indexer, recoveryConfig());
+    scanner = new PendingIndexUpdateScanner(pendingIndexUpdate, workQueue, recoveryConfig());
+  }
+
+  @Test
+  public void scannerIndexesStaleFile() throws Exception {
+    pendingIndexUpdate.write(DEAD_THREAD_ID, PROJECT, CHANGE_ID, /* delete= */ false);
+
+    scanner.run();
+
+    verify(indexer).index(PROJECT, CHANGE_ID);
+    assertThat(intentFile(DEAD_THREAD_ID, PROJECT, CHANGE_ID).toFile().exists()).isFalse();
+  }
+
+  @Test
+  public void scannerDeletesChangeWhenOperationIsDelete() throws Exception {
+    pendingIndexUpdate.write(DEAD_THREAD_ID, PROJECT, CHANGE_ID, /* delete= */ true);
+
+    scanner.run();
+
+    verify(indexer).delete(PROJECT, CHANGE_ID);
+    assertThat(intentFile(DEAD_THREAD_ID, PROJECT, CHANGE_ID).toFile().exists()).isFalse();
+  }
+
+  @Test
+  public void scannerSkipsIntentsForLiveThread() throws Exception {
+    long liveThreadId = Thread.currentThread().threadId();
+    pendingIndexUpdate.write(liveThreadId, PROJECT, CHANGE_ID, /* delete= */ false);
+
+    scanner.run();
+
+    verify(indexer, never()).index(any(), any());
+    assertThat(intentFile(liveThreadId, PROJECT, CHANGE_ID).toFile().exists()).isTrue();
+  }
+
+  @Test
+  public void scannerDeletesMalformedFile() throws Exception {
+    Path file = intentFile(DEAD_THREAD_ID, PROJECT, CHANGE_ID);
+    Files.createDirectories(file.getParent());
+    Files.writeString(file, "not-a-valid-blob");
+
+    scanner.run();
+
+    verify(indexer, never()).index(any(), any());
+    verify(indexer, never()).delete(any(), any());
+    assertThat(file.toFile().exists()).isFalse();
+  }
+
+  @Test
+  public void scannerDoesNothingWhenNoPendingFiles() throws Exception {
+    scanner.run();
+
+    verify(indexer, never()).index(any(), any());
+    verify(indexer, never()).delete(any(), any());
+  }
+
+  @Test
+  public void startRecoversPreviousProcessIntents() throws Exception {
+    // Write an intent under a foreign process marker dir, simulating a previous crash.
+    Path intentDir = sitePaths.data_dir.resolve("pending-index");
+    Path prevThreadDir = intentDir.resolve("99999_1234567890000").resolve("1");
+    Files.createDirectories(prevThreadDir);
+    Files.writeString(
+        prevThreadDir.resolve(pendingIndexUpdate.filename(PROJECT, CHANGE_ID)),
+        "{\"project\":\"test-project\",\"changeId\":42,\"operation\":\"index\"}");
+
+    scanner = new PendingIndexUpdateScanner(pendingIndexUpdate, workQueue, recoveryConfig());
+    scanner.start();
+
+    verify(indexer).index(PROJECT, CHANGE_ID);
+    assertThat(prevThreadDir.toFile().exists()).isFalse();
+  }
+
+  @Test
+  public void startCleansBuildingDir() throws Exception {
+    // Leave an orphaned temp file in buildingDir as if a crash happened mid-write.
+    Path buildingDir = sitePaths.data_dir.resolve("pending-index").resolve("building");
+    Files.createDirectories(buildingDir);
+    Path orphan = Files.createTempFile(buildingDir, null, null);
+
+    scanner = new PendingIndexUpdateScanner(pendingIndexUpdate, workQueue, recoveryConfig());
+    scanner.start();
+
+    assertThat(orphan.toFile().exists()).isFalse();
+  }
+
+  private static Config recoveryConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("index", null, "staleChangeRecovery", true);
+    cfg.setInt("index", "changes", "commitWithin", 0);
+    return cfg;
+  }
+
+  private Path intentFile(long threadId, Project.NameKey project, Change.Id changeId) {
+    return pendingIndexUpdate
+        .threadDir(threadId)
+        .resolve(pendingIndexUpdate.filename(project, changeId));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
index ed179a7..bd32241 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -30,11 +30,11 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Key;
+import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.nio.file.Path;
-import javax.inject.Provider;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index b3f96d4..f1e78ea 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -334,17 +334,19 @@
             + "Submitted-with: NOT_READY\n"
             + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
             + "Submitted-with: NEED: Alternative-Code-Review\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Subject: This is a test change\n"
-            + "Submitted-with: NOT_READY\n"
-            + "Submitted-with: Rule-Name: gerrit~PrologRule\n" // Rule-Name footer is ignored
-            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
-            + "Submitted-with: NEED: Code-Review\n");
+    ChangeNotesState stateWithRule =
+        assertParseSucceeds(
+            "Update change\n"
+                + "\n"
+                + "Branch: refs/heads/master\n"
+                + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+                + "Patch-set: 1\n"
+                + "Subject: This is a test change\n"
+                + "Submitted-with: NOT_READY\n"
+                + "Submitted-with: Rule-Name: gerrit~PrologRule  \r\n"
+                + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+                + "Submitted-with: NEED: Code-Review\n");
+    assertThat(stateWithRule.submitRecords().get(0).ruleName).isEqualTo("gerrit~PrologRule");
     assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: OOPS\n");
     assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: NEED: X+Y\n");
     assertParseFails(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index f482287..6f8400c 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -1256,6 +1256,7 @@
                 .put("parentUuid", String.class)
                 .put("range", Comment.Range.class)
                 .put("tag", String.class)
+                .put("isAi", Boolean.class)
                 .put("revId", String.class)
                 .put("serverId", String.class)
                 .put("unresolved", boolean.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
index 0bb0578..b879a03 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -246,7 +246,7 @@
    * Creates a change with an empty attention set
    *
    * <p>Method ensures that changeOwner and otherUser can be added to the attention set later. (only
-   * users active on the change can be added to the attention set - see {@link
+   * users active on the change can be added to the attention set - see {@code
    * ChangeUpdate#isActiveOnChange})
    */
   private Change newChangeWithEmptyAttentionSet() throws Exception {
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 7c8555e..c09c582 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -423,6 +424,108 @@
     }
   }
 
+  @Test
+  public void gitattributesDiffOverride() throws Exception {
+    String jsonFile = "file_2.json";
+    // 1. First, let's create a commit where the file is modified but there is no .gitattributes.
+    // It should be diffed as UNIFIED.
+    ObjectId oldCommitId1 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity(jsonFile, "{}")));
+    ObjectId newCommitId1 =
+        createCommit(
+            repo, oldCommitId1, ImmutableList.of(new FileEntity(jsonFile, "{\"foo\": \"bar\"}")));
+    FileDiffOutput diffOutput1 =
+        diffOperations.getModifiedFileAgainstParent(
+            testProjectName, newCommitId1, 0, jsonFile, null);
+    assertThat(diffOutput1.patchType()).hasValue(PatchType.UNIFIED);
+    assertThat(diffOutput1.edits()).isNotEmpty();
+
+    // 2. Now let's create a commit where .gitattributes specifies "-diff" for the file.
+    // It should be treated as BINARY.
+    ObjectId oldCommitId2 =
+        createCommit(
+            repo,
+            null,
+            ImmutableList.of(
+                new FileEntity(".gitattributes", jsonFile + " -diff"),
+                new FileEntity(jsonFile, "{}")));
+    ObjectId newCommitId2 =
+        createCommit(
+            repo,
+            oldCommitId2,
+            ImmutableList.of(
+                new FileEntity(".gitattributes", jsonFile + " -diff"),
+                new FileEntity(jsonFile, "{\"foo\": \"bar\"}")));
+    FileDiffOutput diffOutput2 =
+        diffOperations.getModifiedFileAgainstParent(
+            testProjectName, newCommitId2, 0, jsonFile, null);
+    assertThat(diffOutput2.patchType()).hasValue(PatchType.BINARY);
+    assertThat(diffOutput2.edits()).isEmpty();
+
+    // 3. Let's also test with "binary" macro attribute.
+    ObjectId oldCommitId3 =
+        createCommit(
+            repo,
+            null,
+            ImmutableList.of(
+                new FileEntity(".gitattributes", jsonFile + " binary"),
+                new FileEntity(jsonFile, "{}")));
+    ObjectId newCommitId3 =
+        createCommit(
+            repo,
+            oldCommitId3,
+            ImmutableList.of(
+                new FileEntity(".gitattributes", jsonFile + " binary"),
+                new FileEntity(jsonFile, "{\"foo\": \"bar\"}")));
+    FileDiffOutput diffOutput3 =
+        diffOperations.getModifiedFileAgainstParent(
+            testProjectName, newCommitId3, 0, jsonFile, null);
+    assertThat(diffOutput3.patchType()).hasValue(PatchType.BINARY);
+    assertThat(diffOutput3.edits()).isEmpty();
+  }
+
+  @Test
+  public void listModifiedFilesAgainstParentWithSkipDiffStat() throws Exception {
+    ObjectId parent1 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity(fileName1, fileContent1)));
+    ObjectId parent2 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity(fileName2, fileContent2)));
+    ObjectId merge =
+        createMergeCommit(
+            repo,
+            ImmutableList.of(
+                new FileEntity(fileName1, fileContent1 + "\nupdated"),
+                new FileEntity(fileName2, fileContent2),
+                new FileEntity("file_3.txt", "file 3 content")),
+            parent1,
+            parent2);
+
+    DiffOptions skipDiffStatOptions = DiffOptions.DEFAULTS.toBuilder().skipDiffStat(true).build();
+    Map<String, FileDiffOutput> modifiedFiles =
+        diffOperations.listModifiedFilesAgainstParent(
+            testProjectName, merge, /* parentNum= */ 0, skipDiffStatOptions);
+
+    assertThat(modifiedFiles.keySet()).containsExactly(fileName1, "file_3.txt");
+    FileDiffOutput file1Diff = modifiedFiles.get(fileName1);
+    assertThat(file1Diff.changeType()).isEqualTo(ChangeType.MODIFIED);
+    assertThat(file1Diff.oldSha()).isPresent();
+    assertThat(file1Diff.newSha()).isPresent();
+    assertThat(file1Diff.oldSha()).isNotEqualTo(file1Diff.newSha());
+    assertThat(file1Diff.oldMode())
+        .hasValue(com.google.gerrit.entities.Patch.FileMode.REGULAR_FILE);
+    assertThat(file1Diff.newMode())
+        .hasValue(com.google.gerrit.entities.Patch.FileMode.REGULAR_FILE);
+    assertThat(file1Diff.edits()).isEmpty();
+
+    FileDiffOutput file3Diff = modifiedFiles.get("file_3.txt");
+    assertThat(file3Diff.changeType()).isEqualTo(ChangeType.ADDED);
+    assertThat(file3Diff.oldSha()).isEmpty();
+    assertThat(file3Diff.newSha()).isPresent();
+    assertThat(file3Diff.newMode())
+        .hasValue(com.google.gerrit.entities.Patch.FileMode.REGULAR_FILE);
+    assertThat(file3Diff.edits()).isEmpty();
+  }
+
   static class FileEntity {
     String name;
     String content;
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 33698fe..0884bb7 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -616,6 +616,20 @@
   }
 
   @Test
+  public void regexWithEscapedDotMatchesLiteralDotOnly() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("^refs/heads/.*foo\\.bar").group(DEVS))
+        .update();
+
+    ProjectControl u = user(localKey, DEVS);
+    assertCanRead("refs/heads/bar-foo.bar", u);
+    // Without the escaping, '.' would be a wildcard and this would also match.
+    assertCannotRead("refs/heads/bar-fooXbar", u);
+  }
+
+  @Test
   public void blockRule_ParentBlocksChild() throws Exception {
     projectOperations
         .project(localKey)
diff --git a/javatests/com/google/gerrit/server/plugins/PluginGuiceEnvironmentTest.java b/javatests/com/google/gerrit/server/plugins/PluginGuiceEnvironmentTest.java
new file mode 100644
index 0000000..d70650e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/plugins/PluginGuiceEnvironmentTest.java
@@ -0,0 +1,200 @@
+// Copyright (C) 2026 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 static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.internal.UniqueAnnotations;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.mockito.verification.VerificationMode;
+
+@RunWith(MockitoJUnitRunner.class)
+public class PluginGuiceEnvironmentTest {
+  private static final MetricMaker DISABLED_METRIC_MAKER = new DisabledMetricMaker();
+  private static final Injector EMPTY_INJECTOR = Guice.createInjector();
+  private static final String TEST_PLUGIN_NAME = "testPlugin";
+  private static final String TEST_PLUGIN_LISTENER_NAME = "testPluginListener";
+
+  @Mock private ThreadLocalRequestContext requestContextMock;
+
+  @Mock private ServerInformation srvInfoMock;
+
+  @Mock private CopyConfigModule copyConfigModuleMock;
+
+  @Mock private StartPluginListener startPluginListenerMock;
+
+  @Mock private StopPluginListener stopPluginListenerMock;
+
+  @Mock private ReloadPluginListener reloadPluginListenerMock;
+
+  @Mock private StartPluginListener startPluginListenerReloadedMock;
+
+  @Mock private StopPluginListener stopPluginListenerReloadedMock;
+
+  @Mock private ReloadPluginListener reloadPluginListenerReloadedMock;
+
+  @Mock private Plugin pluginMock;
+
+  @Mock private Plugin reloadedPluginMock;
+
+  @Mock private Plugin pluginWithListenersMock;
+
+  @Mock private Plugin pluginWithListenersReloadedMock;
+
+  @Test
+  public void shouldAddStartStopReloadListener_GuiceEnvironmentIsCreated() {
+    mockPlugin(pluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+    mockPlugin(reloadedPluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+    PluginGuiceEnvironment env =
+        newPluginGuiceEnvironment(newInjectorWithStartStopReloadListeners());
+    verifyStartReloadStopListenersCalled(env);
+  }
+
+  @Test
+  public void shouldAddStartStopReloadListener_pluginIsStarted() {
+    Injector injectorWithListeners = newInjectorWithStartStopReloadListeners();
+    mockPlugin(pluginWithListenersMock, TEST_PLUGIN_LISTENER_NAME, injectorWithListeners);
+    mockPlugin(pluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+    mockPlugin(reloadedPluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+
+    PluginGuiceEnvironment env = newPluginGuiceEnvironment(EMPTY_INJECTOR);
+    env.onStartPlugin(pluginWithListenersMock);
+    verifyStartReloadStopListenersCalled(env);
+  }
+
+  @Test
+  public void shouldRemoveStartStopReloadListener_pluginStartedAndThenStopped() {
+    Injector injectorWithListeners = newInjectorWithStartStopReloadListeners();
+    mockPlugin(pluginWithListenersMock, TEST_PLUGIN_LISTENER_NAME, injectorWithListeners);
+    mockPlugin(pluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+    mockPlugin(reloadedPluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+
+    PluginGuiceEnvironment env = newPluginGuiceEnvironment(EMPTY_INJECTOR);
+    env.onStartPlugin(pluginWithListenersMock);
+    stopPlugin(env, pluginWithListenersMock);
+
+    verifyStartReloadStopListeners(env, never());
+  }
+
+  @Test
+  public void shouldRemoveStartStopReloadListener_pluginStartedAndThenReloaded() {
+    mockPlugin(
+        pluginWithListenersMock,
+        TEST_PLUGIN_LISTENER_NAME,
+        newInjectorWithStartStopReloadListeners());
+    mockPlugin(
+        pluginWithListenersReloadedMock,
+        TEST_PLUGIN_LISTENER_NAME,
+        newInjectorWithStartStopReloadListeners(
+            startPluginListenerReloadedMock,
+            stopPluginListenerReloadedMock,
+            reloadPluginListenerReloadedMock));
+    mockPlugin(pluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+    mockPlugin(reloadedPluginMock, TEST_PLUGIN_NAME, EMPTY_INJECTOR);
+
+    PluginGuiceEnvironment env = newPluginGuiceEnvironment(EMPTY_INJECTOR);
+    startPlugin(env, pluginWithListenersMock);
+    reloadPlugin(env, pluginWithListenersMock, pluginWithListenersReloadedMock);
+
+    startPlugin(env, pluginMock);
+    verify(startPluginListenerMock, never()).onStartPlugin(pluginMock);
+    verify(startPluginListenerReloadedMock).onStartPlugin(pluginMock);
+
+    reloadPlugin(env, pluginMock, reloadedPluginMock);
+    verify(reloadPluginListenerMock, never()).onReloadPlugin(pluginMock, reloadedPluginMock);
+    verify(reloadPluginListenerReloadedMock).onReloadPlugin(pluginMock, reloadedPluginMock);
+
+    stopPlugin(env, pluginMock);
+    verify(stopPluginListenerMock, never()).onStopPlugin(pluginMock);
+    verify(stopPluginListenerReloadedMock).onStopPlugin(pluginMock);
+  }
+
+  private void startPlugin(PluginGuiceEnvironment env, Plugin plugin) {
+    env.onStartPlugin(plugin);
+  }
+
+  private void reloadPlugin(PluginGuiceEnvironment env, Plugin oldPlugin, Plugin newPlugin) {
+    lenient().when(oldPlugin.getSysInjector()).thenReturn(null);
+    env.onReloadPlugin(oldPlugin, newPlugin);
+  }
+
+  private void stopPlugin(PluginGuiceEnvironment env, Plugin plugin) {
+    lenient().when(plugin.getSysInjector()).thenReturn(null);
+    env.onStopPlugin(plugin);
+  }
+
+  private void verifyStartReloadStopListenersCalled(PluginGuiceEnvironment env) {
+    verifyStartReloadStopListeners(env, times(1));
+  }
+
+  private void verifyStartReloadStopListeners(
+      PluginGuiceEnvironment env, VerificationMode verificationMode) {
+    startPlugin(env, pluginMock);
+    verify(startPluginListenerMock, verificationMode).onStartPlugin(pluginMock);
+    reloadPlugin(env, pluginMock, reloadedPluginMock);
+    verify(reloadPluginListenerMock, verificationMode)
+        .onReloadPlugin(pluginMock, reloadedPluginMock);
+    stopPlugin(env, pluginMock);
+    verify(stopPluginListenerMock, verificationMode).onStopPlugin(pluginMock);
+  }
+
+  private void mockPlugin(Plugin pluginMock, String pluginName, Injector sysInjector) {
+    when(pluginMock.getName()).thenReturn(pluginName);
+    when(pluginMock.getSysInjector()).thenReturn(sysInjector);
+  }
+
+  private PluginGuiceEnvironment newPluginGuiceEnvironment(Injector injector) {
+    return new PluginGuiceEnvironment(
+        injector, requestContextMock, srvInfoMock, copyConfigModuleMock, DISABLED_METRIC_MAKER);
+  }
+
+  private Injector newInjectorWithStartStopReloadListeners() {
+    return newInjectorWithStartStopReloadListeners(
+        startPluginListenerMock, stopPluginListenerMock, reloadPluginListenerMock);
+  }
+
+  private Injector newInjectorWithStartStopReloadListeners(
+      StartPluginListener startListener,
+      StopPluginListener stopListener,
+      ReloadPluginListener reloadListener) {
+    return Guice.createInjector(
+        newModuleWithListener(StartPluginListener.class, startListener),
+        newModuleWithListener(StopPluginListener.class, stopListener),
+        newModuleWithListener(ReloadPluginListener.class, reloadListener));
+  }
+
+  private <T> AbstractModule newModuleWithListener(Class<T> listenerClass, T listener) {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(listenerClass).annotatedWith(UniqueAnnotations.create()).toInstance(listener);
+      }
+    };
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/LockKeyTest.java b/javatests/com/google/gerrit/server/project/LockKeyTest.java
new file mode 100644
index 0000000..a823422
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/LockKeyTest.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2026 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.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+
+public class LockKeyTest {
+  @Test
+  public void toStringJoinsNamespaceNameAndArgsWithTilde() {
+    LockKey key = LockKey.of("gerrit", "create-project", "my-project");
+
+    assertThat(key.toString()).isEqualTo("gerrit~create-project~my-project");
+  }
+
+  @Test
+  public void toStringWithNoArgsJoinsNamespaceAndName() {
+    LockKey key = LockKey.of("gerrit", "change-cleanup");
+
+    assertThat(key.toString()).isEqualTo("gerrit~change-cleanup");
+  }
+
+  @Test
+  public void toStringWithMultipleArgsJoinsAllOfThem() {
+    LockKey key = LockKey.of("gerrit", "create-project", "my-project", "extra-scope");
+
+    assertThat(key.toString()).isEqualTo("gerrit~create-project~my-project~extra-scope");
+  }
+
+  @Test
+  public void coreUsesGerritNamespaceInStringForm() {
+    LockKey key = LockKey.core("change-cleanup");
+
+    assertThat(key.toString()).isEqualTo("gerrit~change-cleanup");
+  }
+
+  @Test
+  public void pluginNamespacesUnderPluginsSlashPluginNameInStringForm() {
+    LockKey key = LockKey.plugin("replication", "replicate-project", "my-project");
+
+    assertThat(key.toString()).isEqualTo("plugins/replication~replicate-project~my-project");
+  }
+
+  @Test
+  public void differentPluginsProduceDifferentStringsForTheSameName() {
+    LockKey a = LockKey.plugin("plugin-a", "cleanup");
+    LockKey b = LockKey.plugin("plugin-b", "cleanup");
+
+    assertThat(a.toString()).isNotEqualTo(b.toString());
+  }
+
+  @Test
+  public void equalKeysProduceTheSameString() {
+    LockKey a = LockKey.core("create-project", "my-project");
+    LockKey b = LockKey.core("create-project", "my-project");
+
+    assertThat(a.toString()).isEqualTo(b.toString());
+  }
+
+  @Test
+  public void argsAreDefensivelyCopiedBeforeStringFormatting() {
+    String[] args = {"project-a"};
+    LockKey key = LockKey.of("gerrit", "create-project", args);
+    args[0] = "mutated";
+
+    assertThat(key.toString()).isEqualTo("gerrit~create-project~project-a");
+  }
+
+  @Test
+  public void nullNamespaceIsRejected() {
+    assertThrows(NullPointerException.class, () -> LockKey.of(null, "change-cleanup"));
+  }
+
+  @Test
+  public void nullNameIsRejected() {
+    assertThrows(NullPointerException.class, () -> LockKey.of("gerrit", null));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/MigrateLabelFunctionsToSubmitRequirementTest.java b/javatests/com/google/gerrit/server/project/MigrateLabelFunctionsToSubmitRequirementTest.java
new file mode 100644
index 0000000..89c3a77
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/MigrateLabelFunctionsToSubmitRequirementTest.java
@@ -0,0 +1,283 @@
+// Copyright (C) 2026 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.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.restapi.project.MigrateLabelFunctionsToSubmitRequirement;
+import com.google.gerrit.server.restapi.project.MigrateLabelFunctionsToSubmitRequirement.Status;
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.TestUpdateUI;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests full migration behavior of {@link MigrateLabelFunctionsToSubmitRequirement}. */
+public class MigrateLabelFunctionsToSubmitRequirementTest {
+  private static final String LABEL_NAME = "Foo";
+
+  private static final ImmutableList<LabelValue> STANDARD_VALUES =
+      ImmutableList.of(
+          LabelValue.create((short) -1, "Looks Bad"),
+          LabelValue.create((short) 0, "No Score"),
+          LabelValue.create((short) 1, "Looks Good"));
+
+  private InMemoryRepositoryManager repoManager;
+  private MigrateLabelFunctionsToSubmitRequirement migrator;
+
+  @Before
+  public void setUp() {
+    repoManager = new InMemoryRepositoryManager();
+    migrator = new MigrateLabelFunctionsToSubmitRequirement(null, repoManager);
+  }
+
+  private record TestProjectConfig(
+      ProjectConfig config,
+      Map<String, LabelType> labels,
+      Map<String, SubmitRequirement> submitRequirements) {}
+
+  private TestProjectConfig newConfig() {
+    ProjectConfig config = mock(ProjectConfig.class);
+    Map<String, LabelType> labels = new LinkedHashMap<>();
+    Map<String, SubmitRequirement> submitRequirements = new LinkedHashMap<>();
+    when(config.getLabelSections()).thenReturn(labels);
+    when(config.getSubmitRequirementSections()).thenReturn(submitRequirements);
+    return new TestProjectConfig(config, labels, submitRequirements);
+  }
+
+  private LabelType.Builder labelBuilder(LabelFunction function) {
+    return LabelType.builder(LABEL_NAME, STANDARD_VALUES).setFunction(function);
+  }
+
+  private void createRepository(Project.NameKey project) throws Exception {
+    var repo = repoManager.createRepository(project);
+    assertThat(repo).isNotNull();
+  }
+
+  private SubmitRequirement requirementFor(ProjectConfig config) {
+    Map<String, SubmitRequirement> srs = config.getSubmitRequirementSections();
+    assertThat(srs).containsKey(LABEL_NAME);
+    return srs.get(LABEL_NAME);
+  }
+
+  @Test
+  public void maxWithBlock_createsSr_andResetsLabelFunction() throws Exception {
+    Project.NameKey project = Project.nameKey("p-max");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.MAX_WITH_BLOCK).build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(requirementFor(c.config()).submittabilityExpression().expressionString())
+        .isEqualTo("label:Foo=MAX AND -label:Foo=MIN");
+    assertThat(c.labels().get(LABEL_NAME).getFunction()).isEqualTo(LabelFunction.NO_BLOCK);
+  }
+
+  @Test
+  public void noBlock_doesNotCreateSr_andReturnsNoChange() throws Exception {
+    Project.NameKey project = Project.nameKey("p-noblock");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.NO_BLOCK).build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.NO_CHANGE);
+    assertThat(c.submitRequirements()).isEmpty();
+    verifyNoInteractions(ui);
+  }
+
+  @Test
+  public void noOp_resetsToNoBlock_withoutSr() throws Exception {
+    Project.NameKey project = Project.nameKey("p-noop");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.NO_OP).build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(c.submitRequirements()).isEmpty();
+    assertThat(c.labels().get(LABEL_NAME).getFunction()).isEqualTo(LabelFunction.NO_BLOCK);
+  }
+
+  @Test
+  public void existingSrWithSameName_isNotOverwritten_andWarningEmitted() throws Exception {
+    Project.NameKey project = Project.nameKey("p-existing");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels().put(LABEL_NAME, labelBuilder(LabelFunction.MAX_WITH_BLOCK).build());
+    c.submitRequirements()
+        .put(
+            LABEL_NAME,
+            SubmitRequirement.builder()
+                .setName(LABEL_NAME)
+                .setSubmittabilityExpression(SubmitRequirementExpression.create("project:foo"))
+                .setAllowOverrideInChildProjects(false)
+                .build());
+
+    TestUpdateUI ui = new TestUpdateUI();
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(c.labels().get(LABEL_NAME).getFunction()).isEqualTo(LabelFunction.NO_BLOCK);
+    assertThat(c.submitRequirements().get(LABEL_NAME).submittabilityExpression().expressionString())
+        .isEqualTo("project:foo");
+    assertThat(ui.getOutput()).contains("Warning");
+  }
+
+  @Test
+  public void branchPattern_regex_usedAsIs() throws Exception {
+    Project.NameKey project = Project.nameKey("p-regex");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels()
+        .put(
+            LABEL_NAME,
+            labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+                .setRefPatterns(ImmutableList.of("^refs/heads/main-.*"))
+                .build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(
+            requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+        .isEqualTo("branch:^refs/heads/main-.*");
+  }
+
+  @Test
+  public void branchPattern_wildcard_convertedToRegex() throws Exception {
+    Project.NameKey project = Project.nameKey("p-wildcard");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels()
+        .put(
+            LABEL_NAME,
+            labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+                .setRefPatterns(ImmutableList.of("refs/heads/release/*"))
+                .build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(
+            requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+        .isEqualTo("branch:^\\Qrefs/heads/release/\\E.*");
+  }
+
+  @Test
+  public void branchPattern_plain_wrappedInQuotes() throws Exception {
+    Project.NameKey project = Project.nameKey("p-plain");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels()
+        .put(
+            LABEL_NAME,
+            labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+                .setRefPatterns(ImmutableList.of("refs/heads/master"))
+                .build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(
+            requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+        .isEqualTo("branch:\"refs/heads/master\"");
+  }
+
+  @Test
+  public void branchPattern_plain_withQuote_isEscapedAndQuoted() throws Exception {
+    Project.NameKey project = Project.nameKey("p-quote");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels()
+        .put(
+            LABEL_NAME,
+            labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+                .setRefPatterns(ImmutableList.of("refs/heads/gerr\"it"))
+                .build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(
+            requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+        .isEqualTo("branch:\"refs/heads/gerr\\\"it\"");
+  }
+
+  @Test
+  public void branchPattern_plain_withHash_isQuoted() throws Exception {
+    Project.NameKey project = Project.nameKey("p-hash");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels()
+        .put(
+            LABEL_NAME,
+            labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+                .setRefPatterns(ImmutableList.of("refs/heads/gerr#it"))
+                .build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(
+            requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+        .isEqualTo("branch:\"refs/heads/gerr#it\"");
+  }
+
+  @Test
+  public void branchPattern_multiple_joinedWithOr() throws Exception {
+    Project.NameKey project = Project.nameKey("p-multi");
+    createRepository(project);
+    TestProjectConfig c = newConfig();
+    c.labels()
+        .put(
+            LABEL_NAME,
+            labelBuilder(LabelFunction.MAX_WITH_BLOCK)
+                .setRefPatterns(ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"))
+                .build());
+    UpdateUI ui = mock(UpdateUI.class);
+
+    Status status = migrator.updateConfig(project, c.config(), ui);
+
+    assertThat(status).isEqualTo(Status.MIGRATED);
+    assertThat(
+            requirementFor(c.config()).applicabilityExpression().orElseThrow().expressionString())
+        .isEqualTo("branch:\"refs/heads/master\" OR branch:^refs/heads/main-.*");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index aee925d..d4eb173 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -167,6 +167,32 @@
   }
 
   @Test
+  public void readConfigWithEscapedDotInRegexAccessSectionRef() throws Exception {
+    // In git config subsection syntax, "\\" encodes a literal backslash, so
+    // the file text [access "^refs/heads/.*foo\\.bar"] yields subsection name
+    // "^refs/heads/.*foo\.bar", which includes a literal dot match.
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[access \"^refs/heads/.*foo\\\\.bar\"]\n" + "  read = group Developers\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getAccessSection("^refs/heads/.*foo\\.bar")).isNotNull();
+    // Without proper escaping the dot would not be literal, so the unescaped
+    // form must not resolve to the same section.
+    assertThat(cfg.getAccessSection("^refs/heads/.*foo.bar")).isNull();
+
+    // Round-trip: the backslash must survive a write-back.
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[access \"^refs/heads/.*foo\\\\.bar\"]\n" + "  read = group Developers\n");
+  }
+
+  @Test
   public void readConfigLabelDefaultValue() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -315,6 +341,60 @@
   }
 
   @Test
+  public void readSubmitRequirementTemplates() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submit-requirement-template \"Code-Review\"]\n"
+                    + "  description = Require Code-Review +2 before submit\n"
+                    + "  applicableIf = -branch:refs/meta/config\n"
+                    + "  submittableIf = label(Code-Review, +2)\n"
+                    + "  overrideIf = is:false\n"
+                    + "  canOverrideInChildProjects = true\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+
+    assertThat(cfg.getSubmitRequirementTemplateSections())
+        .containsExactly(
+            "Code-Review",
+            SubmitRequirement.builder()
+                .setName("Code-Review")
+                .setDescription(Optional.of("Require Code-Review +2 before submit"))
+                .setApplicabilityExpression(
+                    SubmitRequirementExpression.of("-branch:refs/meta/config"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(Code-Review, +2)"))
+                .setOverrideExpression(SubmitRequirementExpression.of("is:false"))
+                .setAllowOverrideInChildProjects(true)
+                .build());
+  }
+
+  @Test
+  public void readSubmitRequirementTemplateNoSubmittabilityExpression() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submit-requirement-template \"Code-Review\"]\n"
+                    + "  applicableIf = -branch:refs/meta/config\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+
+    assertThat(cfg.getSubmitRequirementTemplateSections()).isEmpty();
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: Setting a submittability expression for submit requirement"
+                + " template 'Code-Review' is required: Missing"
+                + " submit-requirement-template.Code-Review.submittableIf");
+  }
+
+  @Test
   public void readConfigLabelOldStyleWithLeadingSpace() throws Exception {
     RevCommit rev =
         tr.commit()
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index cc73537..87bbdfe 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -2264,6 +2264,81 @@
   }
 
   @Test
+  public void byOnlyPathsLiteral() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change oneFile = insert(project, newChangeWithFiles(repo, "src/Foo.java"));
+    Change twoFiles = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java"));
+    Change otherFile = insert(project, newChangeWithFiles(repo, "src/Bar.java"));
+    Change threeFiles =
+        insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java", "src/Baz.java"));
+
+    // Single-file exact match
+    assertQuery("onlypaths:src/Foo.java", oneFile);
+    assertQuery("onlypaths:src/Bar.java", otherFile);
+
+    // Two-file exact match — query order must not matter
+    assertQuery("onlypaths:src/Foo.java,src/Bar.java", twoFiles);
+    assertQuery("onlypaths:src/Bar.java,src/Foo.java", twoFiles);
+
+    // Three-file exact match
+    assertQuery("onlypaths:src/Foo.java,src/Bar.java,src/Baz.java", threeFiles);
+
+    // Superset must NOT match
+    assertQuery("onlypaths:src/Foo.java,src/Bar.java,src/Baz.java,src/Extra.java");
+
+    // Inverse
+    assertQuery("-onlypaths:src/Foo.java", threeFiles, otherFile, twoFiles);
+  }
+
+  @Test
+  public void byOnlyPathsRegex() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change allJava = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java"));
+    Change mixed = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Foo.kt"));
+    Change allKt = insert(project, newChangeWithFiles(repo, "src/Foo.kt"));
+
+    // Only changes where every file matches the regex are returned.
+    // allJava: both files are .java — matches
+    // mixed: has a .kt file — must not match
+    // allKt: only .kt file — must not match
+    assertQuery("onlypaths:{^src/.*\\.java$}", allJava);
+
+    // Only changes where every file matches the regex are returned.
+    // allKt: only .kt file — matches
+    // mixed: has a .java file — must not match
+    // allJava: both files are .java — must not match
+    assertQuery("onlypaths:{^src/.*\\.kt$}", allKt);
+
+    // Regex covering both extensions matches all three changes
+    assertQuery("onlypaths:{^src/.*\\.(java|kt)$}", allKt, mixed, allJava);
+
+    // No real file matches
+    assertQuery("onlypaths:^test/.*");
+  }
+
+  @Test
+  public void byFileCount() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.FILE_COUNT_SPEC)).isTrue();
+
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change oneFile = insert(project, newChangeWithFiles(repo, "src/Foo.java"));
+    Change twoFiles = insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java"));
+    Change threeFiles =
+        insert(project, newChangeWithFiles(repo, "src/Foo.java", "src/Bar.java", "src/Baz.java"));
+
+    assertQuery("filecount:1", oneFile);
+    assertQuery("filecount:2", twoFiles);
+    assertQuery("filecount:3", threeFiles);
+    assertQuery("filecount:>1", threeFiles, twoFiles);
+    assertQuery("filecount:<2", oneFile);
+    assertQuery("filecount:<5", threeFiles, twoFiles, oneFile);
+    assertQuery("filecount:>3");
+  }
+
+  @Test
   public void byFooter() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     repo = createAndOpenProject(project);
@@ -2871,6 +2946,23 @@
   }
 
   @Test
+  public void byHasHashtag() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_HASHTAG)).isTrue();
+
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
+
+    addHashtags(change1, "foo");
+    addHashtags(change2, "foo", "bar");
+
+    assertQuery("has:hashtag", change2, change1);
+    assertQuery("-has:hashtag", change3);
+  }
+
+  @Test
   public void byHashtagFullText() throws Exception {
     assume().that(getSchema().hasField(ChangeField.FUZZY_HASHTAG)).isTrue();
     ImmutableList<Change> changes = setUpHashtagChanges();
@@ -4542,6 +4634,35 @@
   }
 
   @Test
+  public void bySubmitRequirement_unmet() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UNMET_REQUIREMENT_SPEC)).isTrue();
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    assertQuery("unmet_requirement:Code-Review", change1);
+
+    approve(change1);
+    assertQuery("unmet_requirement:Code-Review");
+  }
+
+  @Test
+  public void bySubmitRequirement_unsatisfiedCount() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UNSATISFIED_REQUIREMENT_COUNT_SPEC)).isTrue();
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    assertQuery("unsatisfied_requirement_count:>0", change1);
+    assertQuery("unsatisfied_requirement_count:1", change1);
+    assertQuery("unsatisfied_requirement_count:<=1", change1);
+    assertQuery("unmet_requirement:Code-Review", change1);
+
+    approve(change1);
+    assertQuery("unsatisfied_requirement_count:0", change1);
+    assertQuery("unsatisfied_requirement_count:<1", change1);
+    assertQuery("unmet_requirement:Code-Review");
+  }
+
+  @Test
   public void byUrlEncodedProject() throws Exception {
     Project.NameKey project = Project.nameKey("repo+foo");
     repo = createAndOpenProject(project);
@@ -5219,4 +5340,119 @@
   private ChangeApi getChangeApi(Change change) throws RestApiException {
     return gApi.changes().id(change.getProject().get(), change.getChangeId());
   }
+
+  @Test
+  public void byLegacyChangeIds() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
+
+    // Empty list
+    assertThat(queryProvider.get().byLegacyChangeIds(ImmutableList.of())).isEmpty();
+
+    // Single ID
+    List<ChangeData> cds = queryProvider.get().byLegacyChangeIds(ImmutableList.of(change1.getId()));
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId());
+
+    // Multiple IDs
+    cds =
+        queryProvider
+            .get()
+            .byLegacyChangeIds(ImmutableList.of(change1.getId(), change2.getId(), change3.getId()));
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId(), change2.getId(), change3.getId());
+
+    // Non-existent ID mixed with valid ID
+    cds =
+        queryProvider.get().byLegacyChangeIds(ImmutableList.of(change1.getId(), Change.id(999999)));
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId());
+
+    // Duplicate IDs in input are deduplicated
+    cds =
+        queryProvider
+            .get()
+            .byLegacyChangeIds(ImmutableList.of(change1.getId(), change1.getId(), change2.getId()));
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId(), change2.getId());
+  }
+
+  @Test
+  public void byProjectCommits() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(project, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert(project, ins2);
+    ChangeInserter ins3 = newChangeWithStatus(repo, Change.Status.ABANDONED);
+    Change change3 = insert(project, ins3);
+
+    String c1 = ins1.getCommitId().name();
+    String c2 = ins2.getCommitId().name();
+    String c3 = ins3.getCommitId().name();
+
+    // Empty list
+    assertThat(queryProvider.get().byProjectCommits(project, ImmutableList.of())).isEmpty();
+
+    // Single commit
+    List<ChangeData> cds = queryProvider.get().byProjectCommits(project, ImmutableList.of(c1));
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId());
+
+    // All commits
+    cds = queryProvider.get().byProjectCommits(project, ImmutableList.of(c1, c2, c3));
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId(), change2.getId(), change3.getId());
+
+    // Duplicate commit hashes are deduplicated
+    cds = queryProvider.get().byProjectCommits(project, ImmutableList.of(c1, c1, c2));
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId(), change2.getId());
+
+    // Other project returns empty
+    Project.NameKey otherProject = Project.nameKey("other-repo");
+    createProject(otherProject);
+    assertThat(queryProvider.get().byProjectCommits(otherProject, ImmutableList.of(c1, c2, c3)))
+        .isEmpty();
+  }
+
+  @Test
+  public void byLegacyChangeIdsAndByProjectCommitsPartitioning() throws Exception {
+    Project.NameKey project = Project.nameKey("partition-repo");
+    repo = createAndOpenProject(project);
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert(project, ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert(project, ins2);
+
+    String c1 = ins1.getCommitId().name();
+    String c2 = ins2.getCommitId().name();
+
+    int maxTerms = indexConfig.maxTerms();
+    List<Change.Id> largeIdList = new ArrayList<>(maxTerms + 50);
+    largeIdList.add(change1.getId());
+    largeIdList.add(change2.getId());
+    for (int i = 0; i < maxTerms + 48; i++) {
+      largeIdList.add(Change.id(1000000 + i));
+    }
+
+    List<ChangeData> cds = queryProvider.get().byLegacyChangeIds(largeIdList);
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId(), change2.getId());
+
+    List<String> largeHashList = new ArrayList<>(maxTerms + 50);
+    largeHashList.add(c1);
+    largeHashList.add(c2);
+    for (int i = 0; i < maxTerms + 48; i++) {
+      largeHashList.add(String.format("%040x", i + 1));
+    }
+
+    cds = queryProvider.get().byProjectCommits(project, largeHashList);
+    assertThat(cds.stream().map(ChangeData::getId).collect(toList()))
+        .containsExactly(change1.getId(), change2.getId());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 25a67a3..9e99928 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -79,6 +79,7 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/testing",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index 1e01155..f415926 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -92,6 +92,130 @@
     verify(changeNotesMock, never()).getServerId();
   }
 
+  @Test
+  public void notesDoesNotClearPrepopulatedPatchSets() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    Change.Id changeNum = Change.id(1);
+    Change testChange = TestChanges.newChange(project, Account.id(1000), 1);
+    ChangeData cd =
+        ChangeData.createForTest(
+            project,
+            changeNum,
+            1,
+            ObjectId.zeroId(),
+            new ChangeNumberNoopAlgorithm(),
+            null,
+            changeNotesMock);
+    cd.setChange(testChange);
+    PatchSet ps1 = newPatchSet(cd.getId(), 1);
+    cd.setPatchSets(ImmutableList.of(ps1));
+    assertThat(cd.patchSets()).containsExactly(ps1);
+
+    // Accessing notes() should not wipe the patchSets that were already set
+    ChangeNotes notes = cd.notes();
+    assertThat(notes).isSameInstanceAs(changeNotesMock);
+    assertThat(cd.patchSets()).containsExactly(ps1);
+    assertThat(cd.currentPatchSet()).isEqualTo(ps1);
+  }
+
+  @Test
+  public void reloadChangeClearsCachedFields() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    Change.Id changeNum = Change.id(1);
+    ChangeNotes.Factory notesFactoryMock = org.mockito.Mockito.mock(ChangeNotes.Factory.class);
+    when(notesFactoryMock.createChecked(project, changeNum, null)).thenReturn(changeNotesMock);
+    Change testChange = TestChanges.newChange(project, Account.id(1000));
+    when(changeNotesMock.getChange()).thenReturn(testChange);
+
+    ChangeData cd =
+        ChangeData.createForTest(
+            project,
+            changeNum,
+            1,
+            ObjectId.zeroId(),
+            new ChangeNumberNoopAlgorithm(),
+            notesFactoryMock,
+            null);
+    PatchSet ps1 = newPatchSet(cd.getId(), 1);
+    cd.setPatchSets(ImmutableList.of(ps1));
+    cd.setMessages(ImmutableList.of());
+    cd.setReviewedBy(java.util.Collections.singleton(Account.id(1000)));
+
+    assertThat(cd.patchSets()).containsExactly(ps1);
+    assertThat(cd.messages()).isEmpty();
+    assertThat(cd.reviewedBy()).containsExactly(Account.id(1000));
+
+    cd.reloadChange();
+  }
+
+  @Test
+  public void metaRevisionCachesResolvedIdFromRefStates() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    Change.Id changeNum = Change.id(1);
+    ChangeData cd =
+        ChangeData.createForTest(
+            project, changeNum, 1, ObjectId.zeroId(), new ChangeNumberNoopAlgorithm(), null, null);
+    ObjectId metaSha1 = ObjectId.fromString("1111111111111111111111111111111111111111");
+    cd.setRefStates(
+        com.google.common.collect.ImmutableSetMultimap.of(
+            project,
+            com.google.gerrit.index.RefState.create(
+                com.google.gerrit.entities.RefNames.changeMetaRef(changeNum), metaSha1)));
+
+    assertThat(cd.metaRevision()).hasValue(metaSha1);
+    assertThat(cd.metaRevisionOrThrow()).isEqualTo(metaSha1);
+  }
+
+  @Test
+  public void setMessagesAndReviewedByDirectlyHydrates() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    Change.Id changeNum = Change.id(1);
+    ChangeData cd =
+        ChangeData.createForTest(
+            project, changeNum, 1, ObjectId.zeroId(), new ChangeNumberNoopAlgorithm(), null, null);
+    cd.setChange(TestChanges.newChange(project, Account.id(1000)));
+
+    com.google.gerrit.entities.ChangeMessage msg =
+        com.google.gerrit.entities.ChangeMessage.create(
+            com.google.gerrit.entities.ChangeMessage.key(changeNum, "uuid-1"),
+            Account.id(1001),
+            TimeUtil.now(),
+            PatchSet.id(changeNum, 1),
+            "LGTM",
+            Account.id(1001),
+            null);
+    cd.setMessages(ImmutableList.of(msg));
+
+    assertThat(cd.messages()).containsExactly(msg);
+    assertThat(cd.reviewedBy()).containsExactly(Account.id(1001));
+  }
+
+  @Test
+  public void ensureReviewedByLoadedForOpenChangesHydratesWithoutPatchSets() throws Exception {
+    Project.NameKey project = Project.nameKey("project");
+    Change.Id changeNum = Change.id(1);
+    ChangeData cd =
+        ChangeData.createForTest(
+            project, changeNum, 1, ObjectId.zeroId(), new ChangeNumberNoopAlgorithm(), null, null);
+    Change change = TestChanges.newChange(project, Account.id(1000));
+    cd.setChange(change);
+
+    com.google.gerrit.entities.ChangeMessage msg =
+        com.google.gerrit.entities.ChangeMessage.create(
+            com.google.gerrit.entities.ChangeMessage.key(changeNum, "uuid-1"),
+            Account.id(1002),
+            TimeUtil.now(),
+            PatchSet.id(changeNum, 1),
+            "Looks good",
+            Account.id(1002),
+            null);
+    cd.setMessages(ImmutableList.of(msg));
+
+    ChangeData.ensureReviewedByLoadedForOpenChanges(ImmutableList.of(cd));
+
+    assertThat(cd.reviewedBy()).containsExactly(Account.id(1002));
+  }
+
   private static PatchSet newPatchSet(Change.Id changeId, int num) {
     return PatchSet.builder()
         .id(PatchSet.id(changeId, num))
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index f0873c1..4ece494 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -144,6 +144,30 @@
 
   @Test
   @UseClockStep
+  public void queryDoesNotPaginateWhenLimitMetByVisibleChanges() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    try (TestRepository<Repository> testRepo = createAndOpenProject(project)) {
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+    }
+
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+    idx.resetQueryCount();
+    List<ChangeInfo> queryResult = newQuery("status:new").withLimit(2).get();
+    assertThat(queryResult).hasSize(2);
+    assertThat(queryResult.get(queryResult.size() - 1)._moreChanges).isTrue();
+
+    // Since the limit is 2, the initial index query asks for limit + 1 = 3 changes.
+    // Because all 3 changes returned are visible, the limit and the probe row are satisfied.
+    // A secondary pagination query must not be executed.
+    assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
+  }
+
+  @Test
+  @UseClockStep
   public void noLimitQueryPaginates() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
 
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index d8339e7..7f47530 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -22,6 +22,8 @@
 import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -70,6 +72,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Optional;
 import org.junit.After;
 import org.junit.Before;
@@ -113,6 +116,9 @@
 
   @Inject private GroupIndexCollection groupIndexes;
 
+  @Inject protected Provider<InternalGroupQuery> internalGroupQueryProvider;
+  @Inject protected Provider<GroupQueryProcessor> queryProcessorProvider;
+
   protected LifecycleManager lifecycle;
   protected Injector injector;
   protected AccountInfo currentUserInfo;
@@ -410,6 +416,139 @@
     assertQuery(query);
   }
 
+  @Test
+  public void groupCacheBatchEvictionByUuid() throws Exception {
+    GroupInfo group = createGroup(name("cacheGroup"));
+    AccountGroup.UUID uuid = AccountGroup.uuid(group.id);
+
+    assertThat(groupCache.get(uuid)).isPresent();
+
+    groupsUpdateProvider
+        .get()
+        .updateGroupInNoteDb(uuid, GroupDelta.builder().setDescription("Modified").build());
+
+    groupCache.evict(ImmutableList.of(uuid));
+
+    assertThat(groupCache.get(uuid).map(InternalGroup::getDescription)).hasValue("Modified");
+  }
+
+  @Test
+  public void byUuidInternalQuery() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    GroupInfo group2 = createGroup(name("group2"));
+    AccountGroup.UUID uuid1 = AccountGroup.uuid(group1.id);
+    AccountGroup.UUID uuid2 = AccountGroup.uuid(group2.id);
+
+    Optional<InternalGroup> found = internalGroupQueryProvider.get().byUUID(uuid1);
+    assertThat(found).isPresent();
+    assertThat(found.get().getGroupUUID()).isEqualTo(uuid1);
+
+    ImmutableList<InternalGroup> foundBatch =
+        internalGroupQueryProvider.get().byUUIDs(ImmutableList.of(uuid1, uuid2));
+    assertThat(foundBatch.stream().map(InternalGroup::getGroupUUID).collect(toList()))
+        .containsExactly(uuid1, uuid2);
+
+    assertThat(internalGroupQueryProvider.get().byUUIDs(ImmutableList.of())).isEmpty();
+  }
+
+  @Test
+  public void byUuidInternalQueryWithMaxTermsOne() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    GroupInfo group2 = createGroup(name("group2"));
+    AccountGroup.UUID uuid1 = AccountGroup.uuid(group1.id);
+    AccountGroup.UUID uuid2 = AccountGroup.uuid(group2.id);
+
+    IndexConfig indexConfig = IndexConfig.builder().maxTerms(1).build();
+    InternalGroupQuery query =
+        new InternalGroupQuery(queryProcessorProvider.get(), indexes, indexConfig);
+    ImmutableList<InternalGroup> foundBatch = query.byUUIDs(ImmutableList.of(uuid1, uuid2));
+    assertThat(foundBatch.stream().map(InternalGroup::getGroupUUID).collect(toList()))
+        .containsExactly(uuid1, uuid2);
+  }
+
+  @Test
+  public void byNameInternalQuery() throws Exception {
+    GroupInfo group = createGroup(name("group1"));
+    AccountGroup.NameKey name = AccountGroup.nameKey(group.name);
+
+    Optional<InternalGroup> found = internalGroupQueryProvider.get().byName(name);
+    assertThat(found).isPresent();
+    assertThat(found.get().getNameKey()).isEqualTo(name);
+  }
+
+  @Test
+  public void byMemberInternalQuery() throws Exception {
+    assume().that(getSchemaVersion() >= 4).isTrue();
+
+    AccountInfo user1 = createAccount("user1", "User1", "user1@example.com");
+    AccountInfo user2 = createAccount("user2", "User2", "user2@example.com");
+    Account.Id userId1 = Account.id(user1._accountId);
+    Account.Id userId2 = Account.id(user2._accountId);
+
+    GroupInfo group1 = createGroup(name("group1"), user1);
+    GroupInfo group2 = createGroup(name("group2"), user2);
+    GroupInfo group3 = createGroup(name("group3"), user1);
+
+    ImmutableList<InternalGroup> groupsUser1 = internalGroupQueryProvider.get().byMember(userId1);
+    assertThat(groupsUser1.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
+        .containsExactly(group1.id, group3.id);
+
+    ImmutableList<InternalGroup> groupsBatch =
+        internalGroupQueryProvider.get().byMembers(ImmutableList.of(userId1, userId2));
+    assertThat(groupsBatch.stream().map(g -> g.getGroupUUID().get()).collect(toList()))
+        .containsExactly(group1.id, group2.id, group3.id);
+
+    assertThat(internalGroupQueryProvider.get().byMembers(ImmutableList.of())).isEmpty();
+  }
+
+  @Test
+  public void bySubgroupsInternalQuery() throws Exception {
+    assume().that(getSchemaVersion() >= 4).isTrue();
+
+    assertThat(internalGroupQueryProvider.get().bySubgroups(ImmutableSet.of())).isEmpty();
+
+    GroupInfo superParentGroup = createGroup(name("superParentGroup"));
+    GroupInfo parentGroup1 = createGroup(name("parentGroup1"));
+    GroupInfo parentGroup2 = createGroup(name("parentGroup2"));
+    GroupInfo subGroup = createGroup(name("subGroup"));
+
+    gApi.groups().id(superParentGroup.id).addGroups(parentGroup1.id, parentGroup2.id);
+    gApi.groups().id(parentGroup1.id).addGroups(subGroup.id);
+    gApi.groups().id(parentGroup2.id).addGroups(subGroup.id);
+
+    AccountGroup.UUID subUuid = AccountGroup.uuid(subGroup.id);
+    AccountGroup.UUID parent1Uuid = AccountGroup.uuid(parentGroup1.id);
+    AccountGroup.UUID parent2Uuid = AccountGroup.uuid(parentGroup2.id);
+
+    assertThat(internalGroupQueryProvider.get().bySubgroups(ImmutableSet.of(subUuid)))
+        .containsExactly(subUuid, ImmutableSet.of(parent1Uuid, parent2Uuid));
+  }
+
+  @Test
+  public void groupCacheCrossPopulation() throws Exception {
+    GroupInfo group = createGroup(name("cacheGroup"));
+    AccountGroup.UUID uuid = AccountGroup.uuid(group.id);
+    AccountGroup.NameKey nameKey = AccountGroup.nameKey(group.name);
+    AccountGroup.Id groupId = AccountGroup.id(group.groupId);
+
+    // Evict all to start clean
+    groupCache.evict(uuid);
+    groupCache.evict(nameKey);
+    groupCache.evict(groupId);
+
+    // Batch loading by UUID populates in-memory cache for Name and ID
+    Map<AccountGroup.UUID, InternalGroup> loaded = groupCache.get(ImmutableList.of(uuid));
+    assertThat(loaded).containsKey(uuid);
+
+    Optional<InternalGroup> byName = groupCache.get(nameKey);
+    assertThat(byName).isPresent();
+    assertThat(byName.get().getGroupUUID()).isEqualTo(uuid);
+
+    Optional<InternalGroup> byId = groupCache.get(groupId);
+    assertThat(byId).isPresent();
+    assertThat(byId.get().getGroupUUID()).isEqualTo(uuid);
+  }
+
   private Account.Id createAccountOutsideRequestContext(
       String username, String fullName, String email, boolean active) throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentJsonTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentJsonTest.java
new file mode 100644
index 0000000..3cdcf06
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentJsonTest.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2026 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.restapi.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
+import com.google.inject.util.Providers;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CommentJsonTest {
+
+  private static final Project.NameKey PROJECT = Project.nameKey("test-project");
+  private static final Change.Id CHANGE_ID = Change.id(12345);
+  private static final ObjectId COMMIT_ID =
+      ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+  private AccountLoader.Factory accountLoaderFactory;
+  private AccountLoader accountLoader;
+  private CommentContextCache commentContextCache;
+  private final AtomicInteger cacheGetAllCount = new AtomicInteger(0);
+
+  @Before
+  public void setUp() {
+    accountLoaderFactory = mock(AccountLoader.Factory.class);
+    accountLoader = mock(AccountLoader.class);
+    when(accountLoaderFactory.create(true)).thenReturn(accountLoader);
+    when(accountLoader.get(any()))
+        .thenAnswer(inv -> new AccountInfo(((Account.Id) inv.getArgument(0)).get()));
+
+    commentContextCache = mock(CommentContextCache.class);
+    when(commentContextCache.getAll(any()))
+        .thenAnswer(
+            inv -> {
+              cacheGetAllCount.incrementAndGet();
+              Iterable<CommentContextKey> keys = inv.getArgument(0);
+              ImmutableMap.Builder<CommentContextKey, CommentContext> builder =
+                  ImmutableMap.builder();
+              for (CommentContextKey key : keys) {
+                builder.put(
+                    key,
+                    CommentContext.create(
+                        ImmutableMap.of(1, "line 1 context", 2, "line 2 context"), "text/x-java"));
+              }
+              return builder.build();
+            });
+  }
+
+  private CommentJson newCommentJson() {
+    return new CommentJson(Providers.of(accountLoaderFactory), Providers.of(commentContextCache))
+        .setProjectKey(PROJECT)
+        .setChangeId(CHANGE_ID);
+  }
+
+  private HumanComment newComment(
+      String uuid, String filename, int patchSetId, int line, String message) {
+    Comment.Key key = new Comment.Key(uuid, filename, patchSetId);
+    HumanComment comment =
+        new HumanComment(
+            key,
+            Account.id(1001),
+            Instant.ofEpochMilli(1000000L),
+            (short) 1,
+            message,
+            "serverId",
+            /* unresolved= */ false);
+    comment.setCommitId(COMMIT_ID);
+    comment.lineNbr = line;
+    return comment;
+  }
+
+  @Test
+  public void formatSingleComment() throws Exception {
+    CommentJson commentJson = newCommentJson().setFillAccounts(true).setFillPatchSet(true);
+    HumanComment comment = newComment("c1", "file1.txt", 1, 10, "test message");
+
+    CommentInfo info = commentJson.newHumanCommentFormatter().format(comment);
+
+    assertThat(info.id).isEqualTo("c1");
+    assertThat(info.path).isEqualTo("file1.txt");
+    assertThat(info.patchSet).isEqualTo(1);
+    assertThat(info.line).isEqualTo(10);
+    assertThat(info.message).isEqualTo("test message");
+    assertThat(info.author).isNotNull();
+    assertThat(info.author._accountId).isEqualTo(1001);
+  }
+
+  @Test
+  public void formatMapGroupingAndSorting() throws Exception {
+    CommentJson commentJson = newCommentJson().setFillAccounts(false).setFillPatchSet(true);
+    HumanComment c1 = newComment("c1", "fileA.txt", 1, 20, "msg2");
+    HumanComment c2 = newComment("c2", "fileA.txt", 1, 10, "msg1");
+    HumanComment c3 = newComment("c3", "fileB.txt", 1, 5, "msg3");
+
+    Map<String, List<CommentInfo>> result =
+        commentJson.newHumanCommentFormatter().format(ImmutableList.of(c1, c2, c3));
+
+    assertThat(result.keySet()).containsExactly("fileA.txt", "fileB.txt").inOrder();
+    assertThat(result.get("fileA.txt")).hasSize(2);
+    assertThat(result.get("fileA.txt").get(0).id).isEqualTo("c2");
+    assertThat(result.get("fileA.txt").get(0).line).isEqualTo(10);
+    assertThat(result.get("fileA.txt").get(0).path).isNull(); // Path nulled out for map
+    assertThat(result.get("fileA.txt").get(1).id).isEqualTo("c1");
+    assertThat(result.get("fileA.txt").get(1).line).isEqualTo(20);
+    assertThat(result.get("fileA.txt").get(1).path).isNull();
+
+    assertThat(result.get("fileB.txt")).hasSize(1);
+    assertThat(result.get("fileB.txt").get(0).id).isEqualTo("c3");
+    assertThat(result.get("fileB.txt").get(0).path).isNull();
+  }
+
+  @Test
+  public void formatWithCommentContext() throws Exception {
+    CommentJson commentJson =
+        newCommentJson()
+            .setFillAccounts(false)
+            .setFillPatchSet(true)
+            .setFillCommentContext(true)
+            .setContextPadding(3);
+    HumanComment c1 = newComment("c1", "fileA.txt", 1, 10, "msg1");
+    HumanComment c2 = newComment("c2", "fileB.txt", 1, 20, "msg2");
+
+    Map<String, List<CommentInfo>> result =
+        commentJson.newHumanCommentFormatter().format(ImmutableList.of(c1, c2));
+
+    assertThat(cacheGetAllCount.get()).isEqualTo(1);
+    CommentInfo info1 = result.get("fileA.txt").get(0);
+    assertThat(info1.contextLines).hasSize(2);
+    assertThat(info1.contextLines.get(0).lineNumber).isEqualTo(1);
+    assertThat(info1.contextLines.get(0).contextLine).isEqualTo("line 1 context");
+    assertThat(info1.sourceContentType).isEqualTo("text/x-java");
+    assertThat(info1.path).isNull();
+
+    CommentInfo info2 = result.get("fileB.txt").get(0);
+    assertThat(info2.contextLines).hasSize(2);
+    assertThat(info2.contextLines.get(0).lineNumber).isEqualTo(1);
+    assertThat(info2.contextLines.get(0).contextLine).isEqualTo("line 1 context");
+    assertThat(info2.sourceContentType).isEqualTo("text/x-java");
+    assertThat(info2.path).isNull();
+  }
+
+  @Test
+  public void formatAsListWithCommentContext() throws Exception {
+    CommentJson commentJson =
+        newCommentJson()
+            .setFillAccounts(false)
+            .setFillPatchSet(true)
+            .setFillCommentContext(true)
+            .setContextPadding(2);
+    HumanComment c1 = newComment("c1", "fileB.txt", 1, 20, "msg2");
+    HumanComment c2 = newComment("c2", "fileA.txt", 1, 10, "msg1");
+
+    ImmutableList<CommentInfo> result =
+        commentJson.newHumanCommentFormatter().formatAsList(ImmutableList.of(c1, c2));
+
+    assertThat(result).hasSize(2);
+    assertThat(result.get(0).id).isEqualTo("c2");
+    assertThat(result.get(0).path).isEqualTo("fileA.txt"); // Path preserved in list
+    assertThat(result.get(0).contextLines).hasSize(2);
+    assertThat(result.get(1).id).isEqualTo("c1");
+    assertThat(result.get(1).path).isEqualTo("fileB.txt");
+    assertThat(result.get(1).contextLines).hasSize(2);
+  }
+
+  @Test
+  public void formatWithFixSuggestions() throws Exception {
+    CommentJson commentJson = newCommentJson().setFillAccounts(false).setFillPatchSet(true);
+    HumanComment c1 = newComment("c1", "fileA.txt", 1, 10, "msg1");
+
+    Comment.Range range = new Comment.Range(10, 2, 10, 8);
+
+    FixReplacement replacement = new FixReplacement("fileA.txt", range, "replacement text");
+    FixSuggestion suggestion =
+        new FixSuggestion("fix-1", "Fix description", ImmutableList.of(replacement));
+    c1.fixSuggestions = ImmutableList.of(suggestion);
+
+    CommentInfo info = commentJson.newHumanCommentFormatter().format(c1);
+
+    assertThat(info.fixSuggestions).hasSize(1);
+    assertThat(info.fixSuggestions.get(0).fixId).isEqualTo("fix-1");
+    assertThat(info.fixSuggestions.get(0).description).isEqualTo("Fix description");
+    assertThat(info.fixSuggestions.get(0).replacements).hasSize(1);
+    assertThat(info.fixSuggestions.get(0).replacements.get(0).path).isEqualTo("fileA.txt");
+    assertThat(info.fixSuggestions.get(0).replacements.get(0).replacement)
+        .isEqualTo("replacement text");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/project/GetSubmitRequirementTemplateTest.java b/javatests/com/google/gerrit/server/restapi/project/GetSubmitRequirementTemplateTest.java
new file mode 100644
index 0000000..329c806
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/project/GetSubmitRequirementTemplateTest.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementTemplateResource;
+import org.junit.Test;
+
+public class GetSubmitRequirementTemplateTest {
+  @Test
+  public void formatsTemplateFromResource() {
+    Project.NameKey sourceProject = Project.nameKey("All-Projects");
+    SubmitRequirement template =
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+
+    SubmitRequirementTemplateResource resource =
+        new SubmitRequirementTemplateResource(mock(ProjectResource.class), sourceProject, template);
+
+    Response<SubmitRequirementInfo> response = new GetSubmitRequirementTemplate().apply(resource);
+
+    assertThat(response.statusCode()).isEqualTo(200);
+    assertThat(response.value().name).isEqualTo("Code-Review");
+    assertThat(response.value().projectName).isEqualTo(sourceProject.get());
+    assertThat(response.value().submittabilityExpression).isEqualTo("label:Code-Review=MAX");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/project/SubmitRequirementTemplatesCollectionTest.java b/javatests/com/google/gerrit/server/restapi/project/SubmitRequirementTemplatesCollectionTest.java
new file mode 100644
index 0000000..eeaec4b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/project/SubmitRequirementTemplatesCollectionTest.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+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.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementTemplateResource;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SubmitRequirementTemplatesCollectionTest {
+  private static final Project.NameKey PARENT = Project.nameKey("All-Projects");
+  private static final Project.NameKey CHILD = Project.nameKey("child");
+
+  private CurrentUser currentUser;
+  private PermissionBackend permissionBackend;
+  private PermissionBackend.ForProject parentPermissions;
+  private GitRepositoryManager repoManager;
+  private ProjectConfig.Factory projectConfigFactory;
+  private ProjectConfig parentConfig;
+  private ProjectConfig childConfig;
+  private ProjectResource projectResource;
+  private SubmitRequirementTemplatesCollection collection;
+
+  @Before
+  public void setUp() throws IOException {
+    currentUser = mock(CurrentUser.class);
+    when(currentUser.isIdentifiedUser()).thenReturn(true);
+
+    permissionBackend = mock(PermissionBackend.class);
+    PermissionBackend.WithUser withUser = mock(PermissionBackend.WithUser.class);
+    parentPermissions = mock(PermissionBackend.ForProject.class);
+    PermissionBackend.ForProject childPermissions = mock(PermissionBackend.ForProject.class);
+    when(permissionBackend.currentUser()).thenReturn(withUser);
+    when(withUser.project(PARENT)).thenReturn(parentPermissions);
+    when(withUser.project(CHILD)).thenReturn(childPermissions);
+
+    ProjectState parentState = mock(ProjectState.class);
+    ProjectState childState = mock(ProjectState.class);
+    when(parentState.getNameKey()).thenReturn(PARENT);
+    when(childState.getNameKey()).thenReturn(CHILD);
+    when(childState.treeInOrder()).thenReturn(ImmutableList.of(parentState, childState));
+
+    projectResource = mock(ProjectResource.class);
+    when(projectResource.getProjectState()).thenReturn(childState);
+
+    repoManager = mock(GitRepositoryManager.class);
+    Repository parentRepo = mock(Repository.class);
+    Repository childRepo = mock(Repository.class);
+    when(repoManager.openRepository(PARENT)).thenReturn(parentRepo);
+    when(repoManager.openRepository(CHILD)).thenReturn(childRepo);
+
+    projectConfigFactory = mock(ProjectConfig.Factory.class);
+    parentConfig = mock(ProjectConfig.class);
+    childConfig = mock(ProjectConfig.class);
+    when(projectConfigFactory.create(PARENT)).thenReturn(parentConfig);
+    when(projectConfigFactory.create(CHILD)).thenReturn(childConfig);
+
+    collection =
+        new SubmitRequirementTemplatesCollection(
+            new SubmitRequirementTemplateLoader(
+                () -> currentUser, permissionBackend, repoManager, projectConfigFactory),
+            DynamicMap.emptyMap(),
+            () -> mock(ListSubmitRequirementTemplates.class));
+  }
+
+  @Test
+  public void parseReturnsEffectiveChildTemplateCaseInsensitive() throws Exception {
+    SubmitRequirement parentTemplate =
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+    SubmitRequirement childTemplate =
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+
+    when(parentConfig.getSubmitRequirementTemplateSections())
+        .thenReturn(ImmutableMap.of(parentTemplate.name(), parentTemplate));
+    when(childConfig.getSubmitRequirementTemplateSections())
+        .thenReturn(ImmutableMap.of(childTemplate.name(), childTemplate));
+
+    SubmitRequirementTemplateResource resource =
+        collection.parse(projectResource, IdString.fromDecoded("CODE-REVIEW"));
+
+    assertThat(resource.getSourceProject()).isEqualTo(CHILD);
+    assertThat(
+            resource.getSubmitRequirementTemplate().submittabilityExpression().expressionString())
+        .isEqualTo("label:Code-Review=+2");
+  }
+
+  @Test
+  public void parseThrowsResourceNotFoundForMissingTemplate() throws Exception {
+    when(parentConfig.getSubmitRequirementTemplateSections()).thenReturn(ImmutableMap.of());
+    when(childConfig.getSubmitRequirementTemplateSections()).thenReturn(ImmutableMap.of());
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> collection.parse(projectResource, IdString.fromDecoded("missing")));
+  }
+
+  @Test
+  public void parseSkipsUnreadableParentAndResolvesTemplateFromChild() throws Exception {
+    SubmitRequirement childTemplate =
+        SubmitRequirement.builder()
+            .setName("Template")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+    when(parentConfig.getSubmitRequirementTemplateSections()).thenReturn(ImmutableMap.of());
+    when(childConfig.getSubmitRequirementTemplateSections())
+        .thenReturn(ImmutableMap.of(childTemplate.name(), childTemplate));
+
+    SubmitRequirementTemplateResource resource =
+        collection.parse(projectResource, IdString.fromDecoded("template"));
+
+    assertThat(resource.getSourceProject()).isEqualTo(CHILD);
+    assertThat(resource.getSubmitRequirementTemplate().name()).isEqualTo("Template");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index f58091e..9969f0f 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -14,9 +14,15 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.schema.AllProjectsInput.DEFAULT_BOOLEAN_PROJECT_CONFIGS;
 import static com.google.gerrit.server.schema.AllProjectsInput.getDefaultCodeReviewLabel;
+import static com.google.gerrit.server.schema.AllProjectsInput.getDefaultCodeReviewLabelWithNoBlockFunction;
+import static com.google.gerrit.server.schema.AllProjectsInput.getDefaultCodeReviewSubmitRequirements;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithCustomDefaultReadersAcls;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithCustomDefaultUsersAcls;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultSubmitRequirements;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
@@ -198,4 +204,72 @@
     Config config = readAllProjectsConfig(repoManager, allProjectsName);
     assertTwoConfigsEquivalent(config, expectedConfig);
   }
+
+  @Test
+  public void createAllProjectsWithCustomDefaultReadersGroup() throws Exception {
+    String customDefaultReaders = "Custom Readers";
+    GroupReference adminsGroup = createGroupReference("Administrators");
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
+    GroupReference customGroup = createGroupReference(customDefaultReaders);
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder()
+            .administratorsGroup(adminsGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
+            .defaultReadersGroup(customGroup)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getAllProjectsWithCustomDefaultReadersAcls(customDefaultReaders));
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+
+  @Test
+  public void createAllProjectsWithCustomDefaultUsersGroup() throws Exception {
+    String customDefaultUsers = "Custom Users";
+    GroupReference adminsGroup = createGroupReference("Administrators");
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
+    GroupReference customGroup = createGroupReference(customDefaultUsers);
+    AllProjectsInput allProjectsInput =
+        AllProjectsInput.builder()
+            .administratorsGroup(adminsGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup)
+            .defaultUsersGroup(customGroup)
+            .build();
+    allProjectsCreator.create(allProjectsInput);
+
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getAllProjectsWithCustomDefaultUsersAcls(customDefaultUsers));
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
+
+  @Test
+  public void createAllProjectsWithUnsetDefaultGroups_fallsBackToDefaults() throws Exception {
+    GroupReference adminsGroup = createGroupReference("Administrators");
+    GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+    GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
+    AllProjectsInput.Builder allProjectsInput =
+        AllProjectsInput.builderWithNoDefault()
+            .codeReviewLabel(getDefaultCodeReviewLabelWithNoBlockFunction())
+            .codeReviewSubmitRequirement(getDefaultCodeReviewSubmitRequirements())
+            .firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
+            .initDefaultAcls(true)
+            .initDefaultSubmitRequirements(true)
+            .administratorsGroup(adminsGroup)
+            .serviceUsersGroup(serviceUsersGroup)
+            .blockedUsersGroup(blockedUsersGroup);
+    DEFAULT_BOOLEAN_PROJECT_CONFIGS.forEach(allProjectsInput::addBooleanProjectConfig);
+    allProjectsCreator.create(allProjectsInput.build());
+
+    Config expectedConfig = new Config();
+    expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
+    Config config = readAllProjectsConfig(repoManager, allProjectsName);
+    assertTwoConfigsEquivalent(config, expectedConfig);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java
new file mode 100644
index 0000000..bac1b31
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2026 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 com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.config.SitePaths;
+import java.nio.file.Files;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class H2JGitLockAccountPatchReviewStoreIT {
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+  private H2JGitLockAccountPatchReviewStore store;
+  private static final Account.Id ACCOUNT = Account.id(1);
+  private static final PatchSet.Id PS = PatchSet.id(Change.id(1), 1);
+  private static final String FILE = "foo/bar.txt";
+
+  @Before
+  public void setUp() throws Exception {
+    SitePaths sitePaths = new SitePaths(temporaryFolder.getRoot().toPath());
+    Files.createDirectories(sitePaths.db_dir);
+    Config cfg = new Config();
+    store = new H2JGitLockAccountPatchReviewStore(cfg, sitePaths);
+    store.start();
+  }
+
+  @Test
+  public void markAndFindReviewed() {
+    assertThat(store.findReviewed(PS, ACCOUNT)).isEmpty();
+
+    var unused = store.markReviewed(PS, ACCOUNT, FILE);
+
+    assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+    assertThat(store.findReviewed(PS, ACCOUNT).get().files()).containsExactly(FILE);
+  }
+
+  @Test
+  public void clearReviewedFile() {
+    var unused = store.markReviewed(PS, ACCOUNT, FILE);
+    assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+
+    store.clearReviewed(PS, ACCOUNT, FILE);
+
+    assertThat(store.findReviewed(PS, ACCOUNT)).isEmpty();
+  }
+
+  @Test
+  public void clearReviewedPatchSet() {
+    var unused = store.markReviewed(PS, ACCOUNT, FILE);
+    assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+    assertThat(store.findReviewed(PS, ACCOUNT).get().files()).containsExactly(FILE);
+
+    store.clearReviewed(PS);
+
+    assertThat(store.findReviewed(PS, ACCOUNT)).isEmpty();
+  }
+
+  @Test
+  public void concurrentMarksAreAllDurable() throws Exception {
+    int nThreads = 8;
+    int filesPerThread = 5;
+    CyclicBarrier startGate = new CyclicBarrier(nThreads);
+
+    try (ExecutorService executor = Executors.newFixedThreadPool(nThreads)) {
+      ImmutableList<Future<?>> futures =
+          IntStream.range(0, nThreads)
+              .mapToObj(
+                  threadIdx ->
+                      executor.submit(
+                          () -> {
+                            startGate.await();
+                            for (int f = 0; f < filesPerThread; f++) {
+                              var unused = store.markReviewed(PS, ACCOUNT, fileName(threadIdx, f));
+                            }
+                            return null;
+                          }))
+              .collect(ImmutableList.toImmutableList());
+
+      for (Future<?> future : futures) {
+        future.get();
+      }
+    }
+
+    ImmutableSet<String> expected =
+        IntStream.range(0, nThreads)
+            .boxed()
+            .flatMap(
+                threadIdx ->
+                    IntStream.range(0, filesPerThread).mapToObj(f -> fileName(threadIdx, f)))
+            .collect(ImmutableSet.toImmutableSet());
+
+    assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+    assertThat(store.findReviewed(PS, ACCOUNT).get().files()).isEqualTo(expected);
+  }
+
+  private static String fileName(int threadIdx, int fileIdx) {
+    return "file-" + threadIdx + "-" + fileIdx + ".txt";
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java
new file mode 100644
index 0000000..c15d1c8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2026 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 com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.schema.H2JGitLockAccountPatchReviewStore.lockTargetFromUrl;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.io.File;
+import org.junit.Test;
+
+public class H2JGitLockAccountPatchReviewStoreTest {
+  @Test
+  public void lockTargetFromUrl_plainPath() {
+    assertThat(lockTargetFromUrl("jdbc:h2:file:/path/to/db")).isEqualTo(new File("/path/to/db"));
+  }
+
+  @Test
+  public void lockTargetFromUrl_stripsOptions() {
+    assertThat(lockTargetFromUrl("jdbc:h2:file:/path/to/db;FILE_LOCK=NO;DB_CLOSE_DELAY=0"))
+        .isEqualTo(new File("/path/to/db"));
+  }
+
+  @Test
+  public void lockTargetFromUrl_unescapesSemicolonInPath() {
+    assertThat(lockTargetFromUrl("jdbc:h2:file:/path/with\\;semi/db;FILE_LOCK=NO"))
+        .isEqualTo(new File("/path/with;semi/db"));
+  }
+
+  @Test
+  public void lockTargetFromUrl_throwsOnInvalidUrls() {
+    assertThrows(
+        IllegalArgumentException.class, () -> lockTargetFromUrl("jdbc:h2:mem:/path/to/db"));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/JdbcAccountPatchReviewStoreTest.java b/javatests/com/google/gerrit/server/schema/JdbcAccountPatchReviewStoreTest.java
new file mode 100644
index 0000000..d8c876b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/JdbcAccountPatchReviewStoreTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2026 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 com.google.common.truth.Truth.assertThat;
+
+import java.nio.file.Path;
+import org.junit.Test;
+
+public class JdbcAccountPatchReviewStoreTest {
+  @Test
+  public void checkCreateH2Url() {
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("test").toAbsolutePath());
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("test.db").toAbsolutePath());
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("test.db.mv.db").toAbsolutePath());
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db.trace.db")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("test.db.mv.db.trace.db").toAbsolutePath());
+  }
+
+  @Test
+  public void checkCreateH2UrlWithPath() {
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test").toAbsolutePath());
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test.db")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test.db").toAbsolutePath());
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test.db.mv.db")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test.db.mv.db").toAbsolutePath());
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test.db.mv.db.trace.db")))
+        .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test.db.mv.db.trace.db").toAbsolutePath());
+  }
+
+  @Test
+  public void checkCreateH2UrlWithSemicolon() {
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test;test")))
+        .isEqualTo(
+            "jdbc:h2:file:" + Path.of("test;test").toAbsolutePath().toString().replace(";", "\\;"));
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db;test")))
+        .isEqualTo(
+            "jdbc:h2:file:"
+                + Path.of("test.db;test").toAbsolutePath().toString().replace(";", "\\;"));
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db;test")))
+        .isEqualTo(
+            "jdbc:h2:file:"
+                + Path.of("test.db.mv.db;test").toAbsolutePath().toString().replace(";", "\\;"));
+    assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db.trace.db;test")))
+        .isEqualTo(
+            "jdbc:h2:file:"
+                + Path.of("test.db.mv.db.trace.db;test")
+                    .toAbsolutePath()
+                    .toString()
+                    .replace(";", "\\;"));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateIndexIntentTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateIndexIntentTest.java
new file mode 100644
index 0000000..2cbff2b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateIndexIntentTest.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2026 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.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.PendingIndexUpdate;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.InMemoryTestEnvironment;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+/** Tests for the pending-index lifecycle in {@link BatchUpdate} and {@link BatchUpdates}. */
+public class BatchUpdateIndexIntentTest {
+  @Rule
+  public InMemoryTestEnvironment testEnvironment =
+      new InMemoryTestEnvironment(
+          () -> {
+            Config cfg = new Config();
+            cfg.setString("index", null, "type", "fake");
+            cfg.setBoolean("index", null, "staleChangeRecovery", true);
+            return cfg;
+          });
+
+  @Inject private BatchUpdate.Factory batchUpdateFactory;
+  @Inject private ChangeInserter.Factory changeInserterFactory;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private Provider<CurrentUser> user;
+  @Inject private Sequences sequences;
+  @Inject private SitePaths sitePaths;
+  @Inject private PendingIndexUpdate pendingIndexUpdate;
+
+  private Project.NameKey project;
+  private TestRepository<Repository> repo;
+  private RefUpdateContext testRefUpdateContext;
+
+  @Before
+  public void setUp() throws Exception {
+    project = Project.nameKey("test");
+    repo = new TestRepository<>(repoManager.createRepository(project));
+    testRefUpdateContext = openTestRefUpdateContext();
+  }
+
+  @After
+  public void tearDown() {
+    testRefUpdateContext.close();
+  }
+
+  @Test
+  public void pendingIndexIntentFilePresentDuringUpdate() throws Exception {
+    Change.Id id = createChange();
+    AtomicBoolean intentFound = new AtomicBoolean(false);
+
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public void afterUpdateRefs() throws Exception {
+            intentFound.set(hasPendingIntentFile(id));
+          }
+        };
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.addOp(id, addMessageOp("Pending intent test"));
+      bu.execute(listener);
+    }
+
+    assertThat(intentFound.get()).isTrue();
+  }
+
+  @Test
+  public void pendingIndexIntentFilesRemovedAfterSuccessfulUpdate() throws Exception {
+    Change.Id id = createChange();
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.addOp(id, addMessageOp("Cleanup test"));
+      bu.execute();
+    }
+
+    assertThat(hasPendingIntentFile(id)).isFalse();
+  }
+
+  private boolean hasPendingIntentFile(Change.Id id) throws IOException {
+    Path intentDir = sitePaths.data_dir.resolve("pending-index");
+    String expectedFilename = pendingIndexUpdate.filename(project, id);
+    try (var stream = Files.walk(intentDir)) {
+      return stream
+          .filter(Files::isRegularFile)
+          .anyMatch(p -> p.getFileName().toString().equals(expectedFilename));
+    }
+  }
+
+  private Change.Id createChange() throws Exception {
+    Change.Id id = Change.id(sequences.nextChangeId());
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.insertChange(
+          changeInserterFactory.create(
+              id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
+      bu.execute();
+    }
+    return id;
+  }
+
+  private static BatchUpdateOp addMessageOp(String message) {
+    return new BatchUpdateOp() {
+      @Override
+      public boolean updateChange(ChangeContext ctx) {
+        ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage(message);
+        return true;
+      }
+    };
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 0894762..c6a3abc 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -30,6 +30,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -42,6 +43,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -49,11 +51,15 @@
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.db.GroupDelta;
+import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -112,6 +118,7 @@
   @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
   @Inject private Sequences sequences;
+  @Inject @GerritServerConfig private Config serverConfig;
   @Inject private AddReviewersOp.Factory addReviewersOpFactory;
   @Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
   @Inject private AccountManager accountManager;
@@ -121,6 +128,10 @@
   @Inject private AbandonOp.Factory abandonOpFactory;
   @Inject @GerritPersonIdent private PersonIdent serverIdent;
   @Inject private RetryHelper retryHelper;
+  @Inject private com.google.gerrit.server.account.GroupCache groupCache;
+
+  @Inject @com.google.gerrit.server.ServerInitiated
+  private Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
@@ -959,4 +970,66 @@
       postUpdateUser = ctx.getUser();
     }
   }
+
+  @Test
+  public void indexAsync_disabledByDefault() throws Exception {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isFalse();
+    }
+  }
+
+  @Test
+  public void indexAsync_whenEnabled_returnsTrueForWebBrowser() throws Exception {
+    serverConfig.setBoolean("index", null, "indexChangesAsync", true);
+
+    IdentifiedUser u = user.get().asIdentifiedUser();
+    u.setAccessPath(AccessPath.WEB_BROWSER);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, u, TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isTrue();
+    }
+  }
+
+  @Test
+  public void indexAsync_whenEnabled_returnsTrueForNonServiceUserGitPush() throws Exception {
+    serverConfig.setBoolean("index", null, "indexChangesAsync", true);
+
+    IdentifiedUser u = user.get().asIdentifiedUser();
+    u.setAccessPath(AccessPath.GIT);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, u, TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isTrue();
+    }
+  }
+
+  @Test
+  public void indexAsync_whenEnabled_returnsFalseForServiceUserGitPush() throws Exception {
+    serverConfig.setBoolean("index", null, "indexChangesAsync", true);
+
+    Account.Id serviceUserAccountId =
+        accountManager.authenticate(authRequestFactory.createForUser("robot")).getAccountId();
+    IdentifiedUser serviceUser = userFactory.create(serviceUserAccountId);
+    serviceUser.setAccessPath(AccessPath.GIT);
+
+    // Add serviceUser to "Service Users" group
+    AccountGroup.UUID serviceUsersUuid =
+        groupCache
+            .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+            .orElseThrow()
+            .getGroupUUID();
+    GroupDelta delta =
+        GroupDelta.builder()
+            .setMemberModification(
+                members ->
+                    ImmutableSet.<Account.Id>builder()
+                        .addAll(members)
+                        .add(serviceUserAccountId)
+                        .build())
+            .build();
+    groupsUpdateProvider.get().updateGroup(serviceUsersUuid, delta);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, serviceUser, TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isFalse();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/testing/BUILD b/javatests/com/google/gerrit/testing/BUILD
index 136938a..ac3998eb 100644
--- a/javatests/com/google/gerrit/testing/BUILD
+++ b/javatests/com/google/gerrit/testing/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/guice",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/testing/GuiceErrorFormattingTest.java b/javatests/com/google/gerrit/testing/GuiceErrorFormattingTest.java
new file mode 100644
index 0000000..72e9da0
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/GuiceErrorFormattingTest.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2026 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.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.inject.ConfigurationException;
+import com.google.inject.Guice;
+import org.junit.Test;
+
+/**
+ * Regression test for the Guice bundled-ASM incompatibility with Java 25 class files (<a
+ * href="https://github.com/google/guice/issues/1926">guice#1926</a>).
+ *
+ * <p>When Guice formats the source location for a configuration error it reads the offending class
+ * with ASM. The ASM shaded into the default Guice jar cannot read Java 25 (class major version 69)
+ * bytecode: the read throws, Guice logs a warning, and the source silently degrades to {@code
+ * (Unknown Source)}. Building against the {@code classes} classifier plus an external OW2 ASM makes
+ * the read succeed, so the error names the real {@code File.java:line}.
+ *
+ * <p>The check below therefore asserts on the resolved source (present only when ASM works), not on
+ * the swallowed log line — otherwise it would pass with or without the fix.
+ */
+public class GuiceErrorFormattingTest {
+  @Test
+  public void configurationErrorResolvesSourceLocationOnJava25() {
+    ConfigurationException thrown =
+        assertThrows(
+            ConfigurationException.class,
+            () -> Guice.createInjector().getInstance(MissingBinding.class));
+
+    // Sanity: this is the missing-constructor error we set out to provoke.
+    assertThat(thrown).hasMessageThat().contains("No injectable constructor for type");
+
+    // The guard: source formatting must resolve the class's real file:line via ASM. With the
+    // bundled Guice ASM on Java 25 the read fails and the source degrades to "(Unknown Source)".
+    assertThat(thrown).hasMessageThat().contains("GuiceErrorFormattingTest.java:");
+    assertThat(thrown).hasMessageThat().doesNotContain("Unknown Source");
+  }
+
+  private static class MissingBinding {
+    @SuppressWarnings("UnusedMethod")
+    MissingBinding(String value) {
+      throw new AssertionError(value);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/util/concurrent/ConcurrentBloomFilterTest.java b/javatests/com/google/gerrit/util/concurrent/ConcurrentBloomFilterTest.java
index 24b659a..eef5e11 100644
--- a/javatests/com/google/gerrit/util/concurrent/ConcurrentBloomFilterTest.java
+++ b/javatests/com/google/gerrit/util/concurrent/ConcurrentBloomFilterTest.java
@@ -312,7 +312,7 @@
 
   private static boolean await(CountDownLatch latch) {
     try {
-      return latch.await(100, TimeUnit.MILLISECONDS);
+      return latch.await(10, TimeUnit.SECONDS);
     } catch (InterruptedException e) {
       return false;
     }
@@ -320,7 +320,7 @@
 
   private static boolean get(Future<?> future) {
     try {
-      future.get(100, TimeUnit.MILLISECONDS);
+      future.get(10, TimeUnit.SECONDS);
       return true;
     } catch (Exception e) {
       return false;
diff --git a/javatests/com/google/gerrit/util/crypto/AesGcmCipherTest.java b/javatests/com/google/gerrit/util/crypto/AesGcmCipherTest.java
new file mode 100644
index 0000000..fe2c05c
--- /dev/null
+++ b/javatests/com/google/gerrit/util/crypto/AesGcmCipherTest.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2026 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.util.crypto;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+
+public class AesGcmCipherTest {
+  private static final byte[] INFO = "aes-gcm-cipher-test".getBytes(UTF_8);
+
+  private final AesGcmCipher cipher =
+      new AesGcmCipher("0123456789abcdef0123456789abcdef".getBytes(US_ASCII), INFO);
+
+  private static byte[] aad() {
+    return "aad".getBytes(UTF_8);
+  }
+
+  @Test
+  public void sealThenOpen_roundTrips() {
+    String sealed = cipher.seal(aad(), "hello");
+    assertThat(cipher.isSealed(sealed)).isTrue();
+    assertThat(cipher.open(aad(), sealed)).isEqualTo("hello");
+  }
+
+  @Test
+  public void seal_usesFreshIv_soCiphertextDiffers() {
+    assertThat(cipher.seal(aad(), "hello")).isNotEqualTo(cipher.seal(aad(), "hello"));
+  }
+
+  @Test
+  public void open_wrongAad_throws() {
+    String sealed = cipher.seal(aad(), "hello");
+    assertThrows(
+        IllegalStateException.class, () -> cipher.open("different".getBytes(UTF_8), sealed));
+  }
+
+  @Test
+  public void open_tampered_throws() {
+    String sealed = cipher.seal(aad(), "hello");
+    int i = sealed.length() / 2;
+    String tampered =
+        sealed.substring(0, i) + (sealed.charAt(i) == 'A' ? 'B' : 'A') + sealed.substring(i + 1);
+    assertThrows(IllegalStateException.class, () -> cipher.open(aad(), tampered));
+  }
+
+  @Test
+  public void open_corruptPayload_throws() {
+    assertThrows(IllegalStateException.class, () -> cipher.open(aad(), "gcm:v1:not-base64"));
+  }
+
+  @Test
+  public void open_unsealedPayload_throws() {
+    assertThrows(IllegalStateException.class, () -> cipher.open(aad(), "plain"));
+  }
+
+  @Test
+  public void isSealed_recognizesPrefix() {
+    assertThat(cipher.isSealed(null)).isFalse();
+    assertThat(cipher.isSealed("plain")).isFalse();
+    assertThat(cipher.isSealed(cipher.seal(aad(), "hello"))).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/util/crypto/BUILD b/javatests/com/google/gerrit/util/crypto/BUILD
new file mode 100644
index 0000000..7ea0626
--- /dev/null
+++ b/javatests/com/google/gerrit/util/crypto/BUILD
@@ -0,0 +1,12 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "crypto_tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/util/crypto",
+        "//lib:junit",
+        "//lib/bouncycastle:bcprov",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/util/crypto/SecureRandomUtilTest.java b/javatests/com/google/gerrit/util/crypto/SecureRandomUtilTest.java
new file mode 100644
index 0000000..8bce4af
--- /dev/null
+++ b/javatests/com/google/gerrit/util/crypto/SecureRandomUtilTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2026 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.util.crypto;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.regex.Pattern;
+import org.junit.Test;
+
+public class SecureRandomUtilTest {
+  private static final Pattern BASE64URL = Pattern.compile("[A-Za-z0-9_-]+");
+
+  @Test
+  public void newBytesReturnsRequestedLength() {
+    assertThat(SecureRandomUtil.newBytes(1)).hasLength(1);
+    assertThat(SecureRandomUtil.newBytes(16)).hasLength(16);
+    assertThat(SecureRandomUtil.newBytes(32)).hasLength(32);
+  }
+
+  @Test
+  public void newBytesOfZeroIsEmpty() {
+    assertThat(SecureRandomUtil.newBytes(0)).hasLength(0);
+  }
+
+  @Test
+  public void newBytesReturnsDifferentValues() {
+    assertThat(SecureRandomUtil.newBytes(32)).isNotEqualTo(SecureRandomUtil.newBytes(32));
+  }
+
+  @Test
+  public void newRandomStringIsUrlSafeAndUnpadded() {
+    String s = SecureRandomUtil.newRandomString(32);
+    assertThat(s).matches(BASE64URL);
+    assertThat(s).doesNotContain("=");
+  }
+
+  @Test
+  public void newRandomStringLengthExpandsByFourThirds() {
+    // base64url is unpadded: ceil(n * 4 / 3) chars. 48 bytes -> 64 chars, the
+    // size the signed-push seed relies on.
+    assertThat(SecureRandomUtil.newRandomString(48)).hasLength(64);
+    assertThat(SecureRandomUtil.newRandomString(48)).matches(BASE64URL);
+  }
+}
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index c6b44be..8688bdd 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -28,7 +28,6 @@
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import java.io.BufferedReader;
-import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.security.Principal;
 import java.time.Instant;
@@ -189,13 +188,9 @@
     ListMultimap<String, String> params = LinkedListMultimap.create();
     for (String entry : Splitter.on('&').split(qs)) {
       List<String> kv = Splitter.on('=').limit(2).splitToList(entry);
-      try {
-        params.put(
-            URLDecoder.decode(kv.get(0), UTF_8.name()),
-            kv.size() == 2 ? URLDecoder.decode(kv.get(1), UTF_8.name()) : "");
-      } catch (UnsupportedEncodingException e) {
-        throw new IllegalArgumentException(e);
-      }
+      params.put(
+          URLDecoder.decode(kv.get(0), UTF_8),
+          kv.size() == 2 ? URLDecoder.decode(kv.get(1), UTF_8) : "");
     }
     parameters = params;
   }
diff --git a/lib/BUILD b/lib/BUILD
index 0a9c9b0..5648007 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -1,4 +1,5 @@
 load("@rules_java//java:defs.bzl", "java_library")
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
 
 exports_files(glob([
     "LICENSE-*",
@@ -210,10 +211,17 @@
 )
 
 java_library(
+    name = "jspecify",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@external_deps//:org_jspecify_jspecify"],
+)
+
+java_library(
     name = "blame-cache",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@external_deps//:com_google_gitiles_blame_cache"],
+    exports = ["@gitiles//java/com/google/gitiles/blame/cache"],
 )
 
 java_library(
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 302b5cd..4eab274 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -112,7 +112,13 @@
         ":auto-oneof-plugin",
     ],
     visibility = ["//visibility:public"],
-    exports = ["@external_deps//:com_google_auto_value_auto_value_annotations"],
+    exports = [
+        "@external_deps//:com_google_auto_value_auto_value_annotations",
+        # AutoValue/AutoAnnotation-generated code references
+        # org.jspecify.annotations.Nullable; export it so every consumer of the
+        # processor satisfies strict deps without a per-target dependency.
+        "@external_deps//:org_jspecify_jspecify",
+    ],
 )
 
 java_library(
diff --git a/lib/emojis/BUILD b/lib/emojis/BUILD
index 2264434..d1f488a 100644
--- a/lib/emojis/BUILD
+++ b/lib/emojis/BUILD
@@ -1,6 +1,6 @@
 # build emojis.min.js from node modules
 
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("@aspect_rules_rollup//rollup:defs.bzl", "rollup")
 
 package(
     default_visibility = ["//visibility:public"],
@@ -9,7 +9,7 @@
 
 exports_files(["emojis.js"])
 
-rollup_bundle(
+rollup(
     name = "emojis.min",
     srcs = [
         ":emojis.js",
@@ -20,13 +20,13 @@
     config_file = "rollup.config.js",
     entry_point = "index.js",
     format = "iife",
-    rollup_bin = "//tools/node_tools:rollup-bin",
+    node_modules = "//tools/node_tools:node_modules",
     silent = True,
     sourcemap = "hidden",
     deps = [
-        "@tools_npm//@rollup/plugin-commonjs",
-        "@tools_npm//@rollup/plugin-node-resolve",
-        "@tools_npm//@rollup/plugin-terser",
-        "@tools_npm//rollup",
+        "//tools/node_tools:node_modules/@rollup/plugin-commonjs",
+        "//tools/node_tools:node_modules/@rollup/plugin-node-resolve",
+        "//tools/node_tools:node_modules/@rollup/plugin-terser",
+        "//tools/node_tools:node_modules/rollup",
     ],
 )
diff --git a/lib/emojis/rollup.config.js b/lib/emojis/rollup.config.js
index 4e98b87..5d81ed8 100644
--- a/lib/emojis/rollup.config.js
+++ b/lib/emojis/rollup.config.js
@@ -28,19 +28,19 @@
 // so require(plugin_name) can't find a plugin.
 // To fix it, requirePlugin tries:
 // 1. resolve module id using default behavior, i.e. it starts from __dirname
-// 2. if module not found - it tries to resolve module starting from rollupBin
-//    location.
+// 2. if module not found - it tries to resolve module starting from
+//    tools/node_tools/node_modules
 // This workaround also gives us additional power - we can place .config.js
 // file anywhere in a source tree and add all plugins in the same package.json
 // file as rollup node module.
 function requirePlugin(id) {
-  const rollupBinDir = path.dirname(process.argv[1]);
-  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  const nodeToolsModulesDir = path.join(__dirname, '../../tools/node_tools/node_modules');
+  const pluginPath = require.resolve(id, {paths: [__dirname, nodeToolsModulesDir] });
   return require(pluginPath);
 }
 
 const cjs = requirePlugin('@rollup/plugin-commonjs');
-const {nodeResolve} = requirePlugin('@rollup/plugin-node-resolve');
+const nodeResolve = requirePlugin('@rollup/plugin-node-resolve');
 const terser = requirePlugin('@rollup/plugin-terser');
 
 export default {
diff --git a/lib/gitiles/BUILD b/lib/gitiles/BUILD
index 3457828..ad7ad06 100644
--- a/lib/gitiles/BUILD
+++ b/lib/gitiles/BUILD
@@ -10,8 +10,6 @@
         ":gfm-tables",
         ":gitiles-servlet",
         ":prettify",
-        "//lib/commons:lang3",
-        "//lib/commons:text",
     ],
 )
 
@@ -47,7 +45,7 @@
     name = "gitiles-servlet",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@external_deps//:com_google_gitiles_gitiles_servlet"],
+    exports = ["@gitiles//java/com/google/gitiles:servlet"],
 )
 
 java_library(
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 8e4272b..12151cd 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -15,7 +15,10 @@
     name = "guice-library",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@external_deps//:com_google_inject_guice"],
+    exports = [
+        "//lib/ow2:ow2-asm",
+        "@external_deps//:com_google_inject_guice_classes",
+    ],
     runtime_deps = ["aopalliance"],
 )
 
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index aa15509..1e018d8 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -1,6 +1,6 @@
 # build highlight.min.js from node modules
 
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("@aspect_rules_rollup//rollup:defs.bzl", "rollup")
 
 package(
     default_visibility = ["//visibility:public"],
@@ -13,16 +13,16 @@
 # highlightjs-gn.
 exports_files(["gn.js"])
 
-rollup_bundle(
+rollup(
     name = "highlight.min",
     srcs = [
         ":gn.js",
-        "@ui_npm//highlight.js",
-        "@ui_npm//highlightjs-closure-templates",
-        "@ui_npm//highlightjs-epp",
-        "@ui_npm//highlightjs-structured-text",
-        "@ui_npm//highlightjs-ttcn3",
-        "@ui_npm//highlightjs-vue",
+        "//polygerrit-ui/app:node_modules/highlight.js",
+        "//polygerrit-ui/app:node_modules/highlightjs-closure-templates",
+        "//polygerrit-ui/app:node_modules/highlightjs-epp",
+        "//polygerrit-ui/app:node_modules/highlightjs-structured-text",
+        "//polygerrit-ui/app:node_modules/highlightjs-ttcn3",
+        "//polygerrit-ui/app:node_modules/highlightjs-vue",
     ],
     args = [
         "--bundleConfigAsCjs=true",
@@ -30,13 +30,13 @@
     config_file = "rollup.config.js",
     entry_point = "index.js",
     format = "iife",
-    rollup_bin = "//tools/node_tools:rollup-bin",
+    node_modules = "//tools/node_tools:node_modules",
     silent = True,
     sourcemap = "hidden",
     deps = [
-        "@tools_npm//@rollup/plugin-commonjs",
-        "@tools_npm//@rollup/plugin-node-resolve",
-        "@tools_npm//@rollup/plugin-terser",
-        "@tools_npm//rollup",
+        "//tools/node_tools:node_modules/@rollup/plugin-commonjs",
+        "//tools/node_tools:node_modules/@rollup/plugin-node-resolve",
+        "//tools/node_tools:node_modules/@rollup/plugin-terser",
+        "//tools/node_tools:node_modules/rollup",
     ],
 )
diff --git a/lib/highlightjs/index.js b/lib/highlightjs/index.js
index d0152e6..fdc40b4 100644
--- a/lib/highlightjs/index.js
+++ b/lib/highlightjs/index.js
@@ -15,12 +15,12 @@
  * limitations under the License.
  */
 
-import hljs from 'highlight.js';
-import soy from 'highlightjs-closure-templates';
-import epp from 'highlightjs-epp';
-import iecst from 'highlightjs-structured-text';
-import ttcn3 from 'highlightjs-ttcn3';
-import vue from 'highlightjs-vue';
+import hljs from '../../polygerrit-ui/app/node_modules/highlight.js';
+import soy from '../../polygerrit-ui/app/node_modules/highlightjs-closure-templates';
+import epp from '../../polygerrit-ui/app/node_modules/highlightjs-epp';
+import iecst from '../../polygerrit-ui/app/node_modules/highlightjs-structured-text';
+import ttcn3 from '../../polygerrit-ui/app/node_modules/highlightjs-ttcn3';
+import vue from '../../polygerrit-ui/app/node_modules/highlightjs-vue';
 import gn from './gn';
 
 hljs.registerLanguage('soy', soy);
@@ -30,4 +30,18 @@
 hljs.registerLanguage('vue', vue);
 hljs.registerLanguage('gn', gn);
 
+// Patch the Objective-C language definition to support C++14 digit separators.
+// TODO(upstream): Remove this workaround once highlight.js is upgraded to a
+// version that includes https://github.com/highlightjs/highlight.js/pull/4322
+const objc = hljs.getLanguage('objectivec');
+const cpp = hljs.getLanguage('cpp');
+if (objc && cpp) {
+  const cppNumberMode = cpp.contains.find(m => m.className === 'number' || m.scope === 'number');
+  const objcContains = objc.contains;
+  const objcNumIdx = objcContains.findIndex(m => m.scope === 'number' || m.className === 'number');
+  if (cppNumberMode && objcNumIdx !== -1) {
+    objcContains[objcNumIdx] = cppNumberMode;
+  }
+}
+
 export default hljs;
diff --git a/lib/highlightjs/rollup.config.js b/lib/highlightjs/rollup.config.js
index 85ab910..a865093 100644
--- a/lib/highlightjs/rollup.config.js
+++ b/lib/highlightjs/rollup.config.js
@@ -28,14 +28,14 @@
 // so require(plugin_name) can't find a plugin.
 // To fix it, requirePlugin tries:
 // 1. resolve module id using default behavior, i.e. it starts from __dirname
-// 2. if module not found - it tries to resolve module starting from rollupBin
-//    location.
+// 2. if module not found - it tries to resolve module starting from
+//    tools/node_tools/node_modules
 // This workaround also gives us additional power - we can place .config.js
 // file anywhere in a source tree and add all plugins in the same package.json
 // file as rollup node module.
 function requirePlugin(id) {
-  const rollupBinDir = path.dirname(process.argv[1]);
-  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  const nodeToolsModulesDir = path.join(__dirname, '../../tools/node_tools/node_modules');
+  const pluginPath = require.resolve(id, {paths: [__dirname, nodeToolsModulesDir] });
   return require(pluginPath);
 }
 
diff --git a/lib/js/BUILD b/lib/js/BUILD
index d00a91e..4163227 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -1,4 +1,4 @@
-load("//tools/bzl:js.bzl", "js_component")
+load("@com_googlesource_gerrit_bazlets//js:defs.bzl", "js_component")
 
 package(default_visibility = ["//visibility:public"])
 
diff --git a/modules/gitiles b/modules/gitiles
new file mode 160000
index 0000000..e5135e3
--- /dev/null
+++ b/modules/gitiles
@@ -0,0 +1 @@
+Subproject commit e5135e39eac0f5f3b91c7616f9131be32286a136
diff --git a/modules/java-prettify b/modules/java-prettify
index 32fa081..1c0ef60 160000
--- a/modules/java-prettify
+++ b/modules/java-prettify
@@ -1 +1 @@
-Subproject commit 32fa081a797a97beaf77a4f2efca26c39168e72f
+Subproject commit 1c0ef60424995a24452ce5ccd54f60c7eb9a5051
diff --git a/modules/jgit b/modules/jgit
index 182a140..87eaf66 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 182a140566592276ac94146b26cd43242b03965c
+Subproject commit 87eaf6628b8ef58591382f15fa7e442639e1be91
diff --git a/package.json b/package.json
index f1e24c5..75a1a9f 100644
--- a/package.json
+++ b/package.json
@@ -3,13 +3,11 @@
   "version": "3.11.0",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/concatjs": "^5.8.1",
-    "@bazel/rollup": "^5.8.1",
-    "@bazel/terser": "^5.8.1",
-    "@bazel/typescript": "^5.8.1",
     "@typescript-eslint/parser": "^8.32.0"
   },
   "devDependencies": {
+    "@eslint/eslintrc": "^3.3.1",
+    "@eslint/js": "^9.26.0",
     "@koa/cors": "^5.0.0",
     "@types/page": "^1.11.9",
     "@typescript-eslint/eslint-plugin": "^8.32.0",
@@ -68,11 +66,11 @@
     "test:single:coverage": "yarn --cwd=polygerrit-ui test:single:coverage",
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
-    "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "eslintfix:modified": "git diff --name-only --diff-filter=d | grep -E 'polygerrit-ui/app/.*\\.(js|ts)$' | sed 's|^polygerrit-ui/app/||' | xargs -r npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix",
+    "eslintfix": "eslint -c polygerrit-ui/app/eslint-bazel.config.js polygerrit-ui/app --fix --cache",
+    "eslintfix:modified": "git diff --name-only --diff-filter=d | grep -E 'polygerrit-ui/app/.*\\.(js|ts)$' | xargs -r eslint -c polygerrit-ui/app/eslint-bazel.config.js --fix",
     "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
     "litlintforCI": "lit-analyzer --strict --rules.no-unknown-property off --rules.no-unknown-tag-name off --rules.no-incompatible-type-binding off --rules.no-incompatible-property-type off --rules.no-invalid-tag-name off --rules.no-property-visibility-mismatch off --rules.no-unknown-attribute off **/elements/**/*.ts",
-    "lint": "eslint -c polygerrit-ui/app/eslint-bazel.config.js polygerrit-ui/app",
+    "lint": "eslint -c polygerrit-ui/app/eslint-bazel.config.js polygerrit-ui/app --cache",
     "gjf": "./tools/gjf.sh run"
   },
   "repository": {
diff --git a/plugins/BUILD b/plugins/BUILD
index c48ddf6..7af6fd2 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -1,5 +1,8 @@
+load("@aspect_bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin")
+load("@aspect_rules_js//js:defs.bzl", "js_library")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
+load("@plugins_npm//:defs.bzl", "npm_link_all_packages")
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
-load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 load(
     "//tools/bzl:plugins.bzl",
@@ -7,14 +10,19 @@
     "CUSTOM_PLUGINS",
 )
 
+npm_link_all_packages(name = "node_modules")
+
 package(default_visibility = ["//visibility:public"])
 
-exports_files([
-    "eslint.config.js",
-    ".prettierrc.js",
-    "rollup.config.js",
-    "tsconfig-plugins-base.json",
-])
+js_library(
+    name = "plugins-config-lib",
+    srcs = [
+        ".prettierrc.js",
+        "eslint-plugin.config.js",
+        "rollup.config.js",
+        "tsconfig-plugins-base.json",
+    ],
+)
 
 genrule2(
     name = "core",
@@ -53,6 +61,8 @@
     "//java/com/google/gerrit/metrics",
     "//java/com/google/gerrit/metrics/dropwizard",
     "//java/com/google/gerrit/entities",
+    "//java/com/google/gerrit/entities/converter:converters",
+    "//java/com/google/gerrit/entities/converter:proto_converter",
     "//java/com/google/gerrit/server/api",
     "//java/com/google/gerrit/server/audit",
     "//java/com/google/gerrit/server/cache/mem",
@@ -81,12 +91,12 @@
     "//lib/guice:guice",
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
-    "//lib/guice:javax_inject",
     "//lib/httpcomponents:httpclient",
     "//lib/httpcomponents:httpcore",
     "//lib:jgit-servlet",
     "//lib:jgit",
     "//lib:jsr305",
+    "//lib:jspecify",
     "//lib/log:api",
     "//lib/log:log4j",
     "//lib/mina:sshd",
@@ -171,3 +181,15 @@
     pkgs = ["com.google.gerrit"],
     title = "Gerrit Review Plugin API Documentation",
 )
+
+copy_to_bin(
+    name = "rollup.config",
+    srcs = ["rollup.config.js"],
+    visibility = ["//plugins:__subpackages__"],
+)
+
+copy_to_bin(
+    name = "tsconfig-plugins-base",
+    srcs = ["tsconfig-plugins-base.json"],
+    visibility = ["//plugins:__subpackages__"],
+)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index e93d258..d6a9c60 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit e93d258f0b9178732f51025b6a0cdbee597a78a4
+Subproject commit d6a9c60c7ca27866d8f0b01d1a48bece35dc9bc8
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index c38e0a9..275c528 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit c38e0a9d36767092b20558b28eff7f546c6d754c
+Subproject commit 275c528d58a12d8c90cd9755240be06844fdea07
diff --git a/plugins/delete-project b/plugins/delete-project
index 7090927..3603583 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 7090927136857b5abaa22604e538813989f2559d
+Subproject commit 3603583484c45e578aeff66885723dfd171932fa
diff --git a/plugins/download-commands b/plugins/download-commands
index 15f2608..0825d70 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 15f26084cc2d1b2055e5fa5f9b0d5583883896a9
+Subproject commit 0825d702194fa8f86947ce48a49da3c6deb2b37f
diff --git a/plugins/eslint.config.js b/plugins/eslint-plugin.config.js
similarity index 99%
rename from plugins/eslint.config.js
rename to plugins/eslint-plugin.config.js
index d072a2a..5bd8f7d 100644
--- a/plugins/eslint.config.js
+++ b/plugins/eslint-plugin.config.js
@@ -281,6 +281,7 @@
             name: '@polymer/decorators/lib/decorators',
             message: 'Use @polymer/decorators instead',
           }],
+          '@typescript-eslint/no-floating-promises': 'off',
           '@typescript-eslint/no-explicit-any': 'error',
           // See https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/9
           '@typescript-eslint/ban-ts-comment': 'off',
diff --git a/plugins/external_plugin_deps.bzl b/plugins/external_plugin_deps.bzl
deleted file mode 100644
index c498979..0000000
--- a/plugins/external_plugin_deps.bzl
+++ /dev/null
@@ -1,5 +0,0 @@
-# Deprecation notice: This file is deprecated. Please migrate dependencies to
-# MODULE.bazel.
-
-def external_plugin_deps():
-    pass
diff --git a/plugins/gitiles b/plugins/gitiles
index c423243..af35ead 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit c42324332aa1357bd9dfdf2de14593a36ffbe3e0
+Subproject commit af35ead2d86007665c9ae93a95f5abbd52e59fe7
diff --git a/plugins/hooks b/plugins/hooks
index 7d4f143..41b7a62 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 7d4f143a988c8f5595000684c1f0dd4501546e48
+Subproject commit 41b7a626220c730fd623f751139a77bce80f04a4
diff --git a/plugins/package.json b/plugins/package.json
index e7fdc0f..189a9d1 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,18 +3,18 @@
   "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
   "browser": true,
   "dependencies": {
-    "@codemirror/autocomplete": "^6.20.1",
-    "@codemirror/commands": "^6.10.3",
+    "@codemirror/autocomplete": "^6.20.3",
+    "@codemirror/commands": "^6.11.0",
     "@codemirror/lang-cpp": "^6.0.3",
     "@codemirror/lang-css": "^6.3.1",
     "@codemirror/lang-go": "^6.0.1",
-    "@codemirror/lang-html": "^6.4.11",
+    "@codemirror/lang-html": "^6.4.12",
     "@codemirror/lang-java": "^6.0.2",
     "@codemirror/lang-javascript": "^6.2.5",
-    "@codemirror/lang-jinja": "^6.0.0",
+    "@codemirror/lang-jinja": "^6.0.1",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/lang-less": "^6.0.2",
-    "@codemirror/lang-markdown": "^6.5.0",
+    "@codemirror/lang-markdown": "^6.5.2",
     "@codemirror/lang-php": "^6.0.2",
     "@codemirror/lang-python": "^6.2.1",
     "@codemirror/lang-rust": "^6.0.2",
@@ -22,20 +22,19 @@
     "@codemirror/lang-sql": "^6.10.0",
     "@codemirror/lang-vue": "^0.1.3",
     "@codemirror/lang-xml": "^6.1.0",
-    "@codemirror/lang-yaml": "^6.1.2",
-    "@codemirror/language": "^6.12.2",
+    "@codemirror/lang-yaml": "^6.1.3",
+    "@codemirror/language": "^6.12.4",
     "@codemirror/language-data": "^6.5.2",
-    "@codemirror/legacy-modes": "^6.5.2",
-    "@codemirror/lint": "^6.9.5",
-    "@codemirror/search": "^6.6.0",
-    "@codemirror/state": "^6.6.0",
-    "@codemirror/view": "^6.40.0",
+    "@codemirror/legacy-modes": "^6.5.3",
+    "@codemirror/lint": "^6.9.7",
+    "@codemirror/search": "^6.7.1",
+    "@codemirror/state": "^6.7.1",
+    "@codemirror/view": "^6.43.9",
     "@lezer/highlight": "^1.2.3",
-    "@gerritcodereview/typescript-api": "3.13.0",
+    "@gerritcodereview/typescript-api": "3.14.0",
     "@material/web": "^2.4.1",
     "@open-wc/testing": "^4.0.0",
-    "@polymer/decorators": "^3.0.0",
-    "@polymer/polymer": "3.5.1",
+    "@types/sinon": "^17.0.4",
     "@web/dev-server-esbuild": "^1.0.4",
     "@web/test-runner": "^0.20.2",
     "lit": "^3.3.1",
@@ -43,13 +42,6 @@
     "rxjs": "^6.6.7",
     "sinon": "^20.0.0"
   },
-  "dependencies // comments": {
-    "@polymer/polymer": [
-      "There is a an issue with release 3.5.2. Tests are failing with:",
-      "NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':",
-      "the name 'dom-module' has already been used with this registry at ..."
-    ]
-  },
   "license": "Apache-2.0",
   "private": true
 }
diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml
new file mode 100644
index 0000000..8713e49
--- /dev/null
+++ b/plugins/pnpm-lock.yaml
@@ -0,0 +1,4450 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@codemirror/autocomplete':
+        specifier: ^6.20.3
+        version: 6.20.3
+      '@codemirror/commands':
+        specifier: ^6.11.0
+        version: 6.11.0
+      '@codemirror/lang-cpp':
+        specifier: ^6.0.3
+        version: 6.0.3
+      '@codemirror/lang-css':
+        specifier: ^6.3.1
+        version: 6.3.1
+      '@codemirror/lang-go':
+        specifier: ^6.0.1
+        version: 6.0.1
+      '@codemirror/lang-html':
+        specifier: ^6.4.12
+        version: 6.4.12
+      '@codemirror/lang-java':
+        specifier: ^6.0.2
+        version: 6.0.2
+      '@codemirror/lang-javascript':
+        specifier: ^6.2.5
+        version: 6.2.5
+      '@codemirror/lang-jinja':
+        specifier: ^6.0.1
+        version: 6.0.1
+      '@codemirror/lang-json':
+        specifier: ^6.0.2
+        version: 6.0.2
+      '@codemirror/lang-less':
+        specifier: ^6.0.2
+        version: 6.0.2
+      '@codemirror/lang-markdown':
+        specifier: ^6.5.2
+        version: 6.5.2
+      '@codemirror/lang-php':
+        specifier: ^6.0.2
+        version: 6.0.2
+      '@codemirror/lang-python':
+        specifier: ^6.2.1
+        version: 6.2.1
+      '@codemirror/lang-rust':
+        specifier: ^6.0.2
+        version: 6.0.2
+      '@codemirror/lang-sass':
+        specifier: ^6.0.2
+        version: 6.0.2
+      '@codemirror/lang-sql':
+        specifier: ^6.10.0
+        version: 6.10.0
+      '@codemirror/lang-vue':
+        specifier: ^0.1.3
+        version: 0.1.3
+      '@codemirror/lang-xml':
+        specifier: ^6.1.0
+        version: 6.1.0
+      '@codemirror/lang-yaml':
+        specifier: ^6.1.3
+        version: 6.1.3
+      '@codemirror/language':
+        specifier: ^6.12.4
+        version: 6.12.4
+      '@codemirror/language-data':
+        specifier: ^6.5.2
+        version: 6.5.2
+      '@codemirror/legacy-modes':
+        specifier: ^6.5.3
+        version: 6.5.3
+      '@codemirror/lint':
+        specifier: ^6.9.7
+        version: 6.9.7
+      '@codemirror/search':
+        specifier: ^6.7.1
+        version: 6.7.1
+      '@codemirror/state':
+        specifier: ^6.7.1
+        version: 6.7.1
+      '@codemirror/view':
+        specifier: ^6.43.9
+        version: 6.43.9
+      '@gerritcodereview/typescript-api':
+        specifier: 3.14.0
+        version: 3.14.0
+      '@lezer/highlight':
+        specifier: ^1.2.3
+        version: 1.2.3
+      '@material/web':
+        specifier: ^2.4.1
+        version: 2.4.1
+      '@open-wc/testing':
+        specifier: ^4.0.0
+        version: 4.0.0
+      '@types/sinon':
+        specifier: ^17.0.4
+        version: 17.0.4
+      '@web/dev-server-esbuild':
+        specifier: ^1.0.4
+        version: 1.0.4
+      '@web/test-runner':
+        specifier: ^0.20.2
+        version: 0.20.2
+      lit:
+        specifier: ^3.3.1
+        version: 3.3.1
+      resemblejs:
+        specifier: ^5.0.0
+        version: 5.0.0
+      rxjs:
+        specifier: ^6.6.7
+        version: 6.6.7
+      sinon:
+        specifier: ^20.0.0
+        version: 20.0.0
+
+packages:
+
+  '@babel/code-frame@7.27.1':
+    resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.27.1':
+    resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
+    engines: {node: '>=6.9.0'}
+
+  '@codemirror/autocomplete@6.20.3':
+    resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
+
+  '@codemirror/commands@6.11.0':
+    resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==}
+
+  '@codemirror/lang-angular@0.1.4':
+    resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
+
+  '@codemirror/lang-cpp@6.0.3':
+    resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
+
+  '@codemirror/lang-css@6.3.1':
+    resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
+
+  '@codemirror/lang-go@6.0.1':
+    resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
+
+  '@codemirror/lang-html@6.4.12':
+    resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==}
+
+  '@codemirror/lang-java@6.0.2':
+    resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
+
+  '@codemirror/lang-javascript@6.2.5':
+    resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
+
+  '@codemirror/lang-jinja@6.0.1':
+    resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==}
+
+  '@codemirror/lang-json@6.0.2':
+    resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
+
+  '@codemirror/lang-less@6.0.2':
+    resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
+
+  '@codemirror/lang-liquid@6.3.2':
+    resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
+
+  '@codemirror/lang-markdown@6.5.2':
+    resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==}
+
+  '@codemirror/lang-php@6.0.2':
+    resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
+
+  '@codemirror/lang-python@6.2.1':
+    resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
+
+  '@codemirror/lang-rust@6.0.2':
+    resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
+
+  '@codemirror/lang-sass@6.0.2':
+    resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
+
+  '@codemirror/lang-sql@6.10.0':
+    resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
+
+  '@codemirror/lang-vue@0.1.3':
+    resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
+
+  '@codemirror/lang-wast@6.0.2':
+    resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
+
+  '@codemirror/lang-xml@6.1.0':
+    resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
+
+  '@codemirror/lang-yaml@6.1.3':
+    resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
+
+  '@codemirror/language-data@6.5.2':
+    resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
+
+  '@codemirror/language@6.12.4':
+    resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
+
+  '@codemirror/legacy-modes@6.5.3':
+    resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
+
+  '@codemirror/lint@6.9.7':
+    resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
+
+  '@codemirror/search@6.7.1':
+    resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==}
+
+  '@codemirror/state@6.7.1':
+    resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
+
+  '@codemirror/view@6.43.9':
+    resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==}
+
+  '@esbuild/aix-ppc64@0.25.9':
+    resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.25.9':
+    resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.25.9':
+    resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.25.9':
+    resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.25.9':
+    resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.25.9':
+    resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.25.9':
+    resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.25.9':
+    resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.25.9':
+    resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.25.9':
+    resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.25.9':
+    resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.25.9':
+    resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.25.9':
+    resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.25.9':
+    resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.25.9':
+    resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.25.9':
+    resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.25.9':
+    resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.25.9':
+    resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.9':
+    resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.25.9':
+    resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.9':
+    resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.25.9':
+    resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.25.9':
+    resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.25.9':
+    resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.25.9':
+    resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.25.9':
+    resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@esm-bundle/chai@4.3.4-fix.0':
+    resolution: {integrity: sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==}
+
+  '@gerritcodereview/typescript-api@3.14.0':
+    resolution: {integrity: sha512-GhYzh6h/bHUz2cU1pzHwlfWDeGSkGAlBulfvrUyG4N3tuA/jqUp5LSBDeeJHzXgmOSdNE7Qyravjh3wDd0REyg==}
+
+  '@hapi/bourne@3.0.0':
+    resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/sourcemap-codec@1.5.5':
+    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+  '@jridgewell/trace-mapping@0.3.30':
+    resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+
+  '@lezer/common@1.5.2':
+    resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
+
+  '@lezer/cpp@1.1.6':
+    resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
+
+  '@lezer/css@1.3.6':
+    resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==}
+
+  '@lezer/go@1.0.1':
+    resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
+
+  '@lezer/highlight@1.2.3':
+    resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
+
+  '@lezer/html@1.3.13':
+    resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
+
+  '@lezer/java@1.1.3':
+    resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
+
+  '@lezer/javascript@1.5.4':
+    resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
+
+  '@lezer/json@1.0.3':
+    resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
+
+  '@lezer/lr@1.4.10':
+    resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
+
+  '@lezer/markdown@1.7.2':
+    resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==}
+
+  '@lezer/php@1.0.5':
+    resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
+
+  '@lezer/python@1.1.19':
+    resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
+
+  '@lezer/rust@1.0.2':
+    resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
+
+  '@lezer/sass@1.1.0':
+    resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
+
+  '@lezer/xml@1.0.6':
+    resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
+
+  '@lezer/yaml@1.0.4':
+    resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
+
+  '@lit-labs/ssr-dom-shim@1.4.0':
+    resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==}
+
+  '@lit/reactive-element@2.1.1':
+    resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==}
+
+  '@mapbox/node-pre-gyp@1.0.11':
+    resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
+    hasBin: true
+
+  '@marijn/find-cluster-break@1.0.4':
+    resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
+
+  '@material/web@2.4.1':
+    resolution: {integrity: sha512-0sk9t25acJ72Qv3r0n9r0lgDbPaAKnpm0p+QmEAAwYyZomHxuVbgrrAdtNXaRm7jFyGh+WsTr8bhtvCnpPRFjw==}
+
+  '@mdn/browser-compat-data@4.2.1':
+    resolution: {integrity: sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==}
+
+  '@nodelib/fs.scandir@2.1.5':
+    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.stat@2.0.5':
+    resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.walk@1.2.8':
+    resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+    engines: {node: '>= 8'}
+
+  '@open-wc/dedupe-mixin@2.0.1':
+    resolution: {integrity: sha512-+R4VxvceUxHAUJXJQipkkoV9fy10vNo+OnUnGKZnVmcwxMl460KLzytnUM4S35SI073R0yZQp9ra0MbPUwVcEA==}
+
+  '@open-wc/scoped-elements@3.0.6':
+    resolution: {integrity: sha512-w1ayJaUUmBw8tALtqQ6cBueld+op+bufujzbrOdH0uCTXnSQkONYZzOH+9jyQ8auVgKLqcxZ8oU6SzfqQhQkPg==}
+
+  '@open-wc/semantic-dom-diff@0.20.1':
+    resolution: {integrity: sha512-mPF/RPT2TU7Dw41LEDdaeP6eyTOWBD4z0+AHP4/d0SbgcfJZVRymlIB6DQmtz0fd2CImIS9kszaMmwMt92HBPA==}
+
+  '@open-wc/testing-helpers@3.0.1':
+    resolution: {integrity: sha512-hyNysSatbgT2FNxHJsS3rGKcLEo6+HwDFu1UQL6jcSQUabp/tj3PyX7UnXL3H5YGv0lJArdYLSnvjLnjn3O2fw==}
+
+  '@open-wc/testing@4.0.0':
+    resolution: {integrity: sha512-KI70O0CJEpBWs3jrTju4BFCy7V/d4tFfYWkg8pMzncsDhD7TYNHLw5cy+s1FHXIgVFetnMDhPpwlKIPvtTQW7w==}
+
+  '@puppeteer/browsers@2.10.7':
+    resolution: {integrity: sha512-wHWLkQWBjHtajZeqCB74nsa/X70KheyOhySYBRmVQDJiNj0zjZR/naPCvdWjMhcG1LmjaMV/9WtTo5mpe8qWLw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  '@rollup/plugin-node-resolve@15.3.1':
+    resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^2.78.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/pluginutils@5.2.0':
+    resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/rollup-android-arm-eabi@4.49.0':
+    resolution: {integrity: sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.49.0':
+    resolution: {integrity: sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.49.0':
+    resolution: {integrity: sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.49.0':
+    resolution: {integrity: sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.49.0':
+    resolution: {integrity: sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.49.0':
+    resolution: {integrity: sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.49.0':
+    resolution: {integrity: sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.49.0':
+    resolution: {integrity: sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.49.0':
+    resolution: {integrity: sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.49.0':
+    resolution: {integrity: sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loongarch64-gnu@4.49.0':
+    resolution: {integrity: sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.49.0':
+    resolution: {integrity: sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.49.0':
+    resolution: {integrity: sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.49.0':
+    resolution: {integrity: sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.49.0':
+    resolution: {integrity: sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.49.0':
+    resolution: {integrity: sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.49.0':
+    resolution: {integrity: sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-win32-arm64-msvc@4.49.0':
+    resolution: {integrity: sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.49.0':
+    resolution: {integrity: sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.49.0':
+    resolution: {integrity: sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==}
+    cpu: [x64]
+    os: [win32]
+
+  '@sinonjs/commons@3.0.1':
+    resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
+
+  '@sinonjs/fake-timers@13.0.5':
+    resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==}
+
+  '@sinonjs/samsam@8.0.3':
+    resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==}
+
+  '@tootallnate/quickjs-emscripten@0.23.0':
+    resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+
+  '@types/accepts@1.3.7':
+    resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
+
+  '@types/babel__code-frame@7.0.6':
+    resolution: {integrity: sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==}
+
+  '@types/body-parser@1.19.6':
+    resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+  '@types/chai-dom@1.11.3':
+    resolution: {integrity: sha512-EUEZI7uID4ewzxnU7DJXtyvykhQuwe+etJ1wwOiJyQRTH/ifMWKX+ghiXkxCUvNJ6IQDodf0JXhuP6zZcy2qXQ==}
+
+  '@types/chai@4.3.20':
+    resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==}
+
+  '@types/chai@5.2.2':
+    resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
+
+  '@types/co-body@6.1.3':
+    resolution: {integrity: sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==}
+
+  '@types/command-line-args@5.2.3':
+    resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
+
+  '@types/connect@3.4.38':
+    resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+  '@types/content-disposition@0.5.9':
+    resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==}
+
+  '@types/convert-source-map@2.0.3':
+    resolution: {integrity: sha512-ag0BfJLZf6CQz8VIuRIEYQ5Ggwk/82uvTQf27RcpyDNbY0Vw49LIPqAxk5tqYfrCs9xDaIMvl4aj7ZopnYL8bA==}
+
+  '@types/cookies@0.9.1':
+    resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==}
+
+  '@types/debounce@1.2.4':
+    resolution: {integrity: sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==}
+
+  '@types/deep-eql@4.0.2':
+    resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+  '@types/estree@1.0.8':
+    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+  '@types/express-serve-static-core@5.0.7':
+    resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==}
+
+  '@types/express@5.0.3':
+    resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
+
+  '@types/http-assert@1.5.6':
+    resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
+
+  '@types/http-errors@2.0.5':
+    resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
+  '@types/istanbul-lib-coverage@2.0.6':
+    resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
+
+  '@types/istanbul-lib-report@3.0.3':
+    resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==}
+
+  '@types/istanbul-reports@3.0.4':
+    resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
+
+  '@types/keygrip@1.0.6':
+    resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==}
+
+  '@types/koa-compose@3.2.8':
+    resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==}
+
+  '@types/koa@2.15.0':
+    resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==}
+
+  '@types/koa@3.0.0':
+    resolution: {integrity: sha512-MOcVYdVYmkSutVHZZPh8j3+dAjLyR5Tl59CN0eKgpkE1h/LBSmPAsQQuWs+bKu7WtGNn+hKfJH9Gzml+PulmDg==}
+
+  '@types/mime@1.3.5':
+    resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+
+  '@types/node@24.3.0':
+    resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
+
+  '@types/parse5@6.0.3':
+    resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
+
+  '@types/qs@6.14.0':
+    resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
+
+  '@types/range-parser@1.2.7':
+    resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
+  '@types/resolve@1.20.2':
+    resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+
+  '@types/send@0.17.5':
+    resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
+
+  '@types/serve-static@1.15.8':
+    resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
+
+  '@types/sinon-chai@3.2.12':
+    resolution: {integrity: sha512-9y0Gflk3b0+NhQZ/oxGtaAJDvRywCa5sIyaVnounqLvmf93yBF4EgIRspePtkMs3Tr844nCclYMlcCNmLCvjuQ==}
+
+  '@types/sinon@17.0.4':
+    resolution: {integrity: sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==}
+
+  '@types/sinonjs__fake-timers@8.1.5':
+    resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==}
+
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+  '@types/ws@7.4.7':
+    resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==}
+
+  '@types/yauzl@2.10.3':
+    resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
+  '@web/browser-logs@0.4.1':
+    resolution: {integrity: sha512-ypmMG+72ERm+LvP+loj9A64MTXvWMXHUOu773cPO4L1SV/VWg6xA9Pv7vkvkXQX+ItJtCJt+KQ+U6ui2HhSFUw==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/config-loader@0.3.3':
+    resolution: {integrity: sha512-ilzeQzrPpPLWZhzFCV+4doxKDGm7oKVfdKpW9wiUNVgive34NSzCw+WzXTvjE4Jgr5CkyTDIObEmMrqQEjhT0g==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-core@0.7.5':
+    resolution: {integrity: sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-esbuild@1.0.4':
+    resolution: {integrity: sha512-ia1LxBwwRiQBYhJ7/RtLenHyPjzle3SvTw3jOZaeGv8UGXVPOkQV8fR05caOtW/DPPZaZovNAybzRKVnNiYIZg==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-rollup@0.6.4':
+    resolution: {integrity: sha512-sJZfTGCCrdku5xYnQQG51odGI092hKY9YFM0X3Z0tRY3iXKXcYRaLZrErw5KfCxr6g0JRuhe4BBhqXTA5Q2I3Q==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server@0.4.6':
+    resolution: {integrity: sha512-jj/1bcElAy5EZet8m2CcUdzxT+CRvUjIXGh8Lt7vxtthkN9PzY9wlhWx/9WOs5iwlnG1oj0VGo6f/zvbPO0s9w==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  '@web/parse5-utils@2.1.0':
+    resolution: {integrity: sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-chrome@0.18.1':
+    resolution: {integrity: sha512-eO6ctCaqSguGM6G3cFobGHnrEs9wlv9Juj/Akyr4XLjeEMTheNULdvOXw9Bygi+QC/ir/0snMmt+/YKnfy8rYA==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-commands@0.9.0':
+    resolution: {integrity: sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-core@0.13.4':
+    resolution: {integrity: sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-coverage-v8@0.8.0':
+    resolution: {integrity: sha512-PskiucYpjUtgNfR2zF2AWqWwjXL7H3WW/SnCAYmzUrtob7X9o/+BjdyZ4wKbOxWWSbJO4lEdGIDLu+8X2Xw+lA==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-mocha@0.9.0':
+    resolution: {integrity: sha512-ZL9F6FXd0DBQvo/h/+mSfzFTSRVxzV9st/AHhpgABtUtV/AIpVE9to6+xdkpu6827kwjezdpuadPfg+PlrBWqQ==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner@0.20.2':
+    resolution: {integrity: sha512-zfEGYEDnS0EI8qgoWFjmtkIXhqP15W40NW3dCaKtbxj5eU0a7E53f3GV/tZGD0GlZKF8d4Fyw+AFrwOJU9Z4GA==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  abbrev@1.1.1:
+    resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+
+  accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+
+  agent-base@6.0.2:
+    resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+    engines: {node: '>= 6.0.0'}
+
+  agent-base@7.1.4:
+    resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+    engines: {node: '>= 14'}
+
+  ansi-escapes@4.3.2:
+    resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
+    engines: {node: '>=8'}
+
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  aproba@2.1.0:
+    resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
+
+  are-we-there-yet@2.0.0:
+    resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
+    engines: {node: '>=10'}
+    deprecated: This package is no longer supported.
+
+  array-back@3.1.0:
+    resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
+    engines: {node: '>=6'}
+
+  array-back@6.2.2:
+    resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==}
+    engines: {node: '>=12.17'}
+
+  array-union@2.1.0:
+    resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
+    engines: {node: '>=8'}
+
+  ast-types@0.13.4:
+    resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
+    engines: {node: '>=4'}
+
+  astral-regex@2.0.0:
+    resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
+    engines: {node: '>=8'}
+
+  async@3.2.6:
+    resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+
+  axe-core@4.10.3:
+    resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==}
+    engines: {node: '>=4'}
+
+  b4a@1.6.7:
+    resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
+
+  balanced-match@1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+  bare-events@2.6.1:
+    resolution: {integrity: sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==}
+
+  bare-fs@4.2.1:
+    resolution: {integrity: sha512-mELROzV0IhqilFgsl1gyp48pnZsaV9xhQapHLDsvn4d4ZTfbFhcghQezl7FTEDNBcGqLUnNI3lUlm6ecrLWdFA==}
+    engines: {bare: '>=1.16.0'}
+    peerDependencies:
+      bare-buffer: '*'
+    peerDependenciesMeta:
+      bare-buffer:
+        optional: true
+
+  bare-os@3.6.2:
+    resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==}
+    engines: {bare: '>=1.14.0'}
+
+  bare-path@3.0.0:
+    resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
+
+  bare-stream@2.7.0:
+    resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==}
+    peerDependencies:
+      bare-buffer: '*'
+      bare-events: '*'
+    peerDependenciesMeta:
+      bare-buffer:
+        optional: true
+      bare-events:
+        optional: true
+
+  basic-ftp@5.0.5:
+    resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
+    engines: {node: '>=10.0.0'}
+    deprecated: Security vulnerability fixed in 5.2.1, please upgrade
+
+  brace-expansion@1.1.12:
+    resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+  braces@3.0.3:
+    resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+    engines: {node: '>=8'}
+
+  buffer-crc32@0.2.13:
+    resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
+  bytes@3.1.2:
+    resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+    engines: {node: '>= 0.8'}
+
+  cache-content-type@1.0.1:
+    resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==}
+    engines: {node: '>= 6.0.0'}
+
+  call-bind-apply-helpers@1.0.2:
+    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+    engines: {node: '>= 0.4'}
+
+  call-bound@1.0.4:
+    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+    engines: {node: '>= 0.4'}
+
+  camelcase@6.3.0:
+    resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
+    engines: {node: '>=10'}
+
+  canvas@2.11.2:
+    resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==}
+    engines: {node: '>=6'}
+
+  chai-a11y-axe@1.5.0:
+    resolution: {integrity: sha512-V/Vg/zJDr9aIkaHJ2KQu7lGTQQm5ZOH4u1k5iTMvIXuSVlSuUo0jcSpSqf9wUn9zl6oQXa4e4E0cqH18KOgKlQ==}
+
+  chalk-template@0.4.0:
+    resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
+    engines: {node: '>=12'}
+
+  chalk@4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+
+  chokidar@4.0.3:
+    resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+    engines: {node: '>= 14.16.0'}
+
+  chownr@2.0.0:
+    resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
+    engines: {node: '>=10'}
+
+  chrome-launcher@0.15.2:
+    resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==}
+    engines: {node: '>=12.13.0'}
+    hasBin: true
+
+  chromium-bidi@8.0.0:
+    resolution: {integrity: sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==}
+    peerDependencies:
+      devtools-protocol: '*'
+
+  cli-cursor@3.1.0:
+    resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
+    engines: {node: '>=8'}
+
+  cliui@8.0.1:
+    resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+    engines: {node: '>=12'}
+
+  clone@2.1.2:
+    resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
+    engines: {node: '>=0.8'}
+
+  co-body@6.2.0:
+    resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==}
+    engines: {node: '>=8.0.0'}
+
+  co@4.6.0:
+    resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
+    engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  color-support@1.1.3:
+    resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
+    hasBin: true
+
+  command-line-args@5.2.1:
+    resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
+    engines: {node: '>=4.0.0'}
+
+  command-line-usage@7.0.3:
+    resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
+    engines: {node: '>=12.20.0'}
+
+  concat-map@0.0.1:
+    resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+  console-control-strings@1.1.0:
+    resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
+
+  content-disposition@0.5.4:
+    resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
+    engines: {node: '>= 0.6'}
+
+  content-type@1.0.5:
+    resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+    engines: {node: '>= 0.6'}
+
+  convert-source-map@2.0.0:
+    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+  cookies@0.9.1:
+    resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
+    engines: {node: '>= 0.8'}
+
+  crelt@1.0.7:
+    resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
+
+  cross-spawn@7.0.6:
+    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+    engines: {node: '>= 8'}
+
+  data-uri-to-buffer@6.0.2:
+    resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
+    engines: {node: '>= 14'}
+
+  debounce@1.2.1:
+    resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
+
+  debug@2.6.9:
+    resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  debug@3.2.7:
+    resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  debug@4.4.1:
+    resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  decompress-response@4.2.1:
+    resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==}
+    engines: {node: '>=8'}
+
+  deep-equal@1.0.1:
+    resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
+
+  deepmerge@4.3.1:
+    resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+    engines: {node: '>=0.10.0'}
+
+  default-gateway@6.0.3:
+    resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==}
+    engines: {node: '>= 10'}
+
+  define-lazy-prop@2.0.0:
+    resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+    engines: {node: '>=8'}
+
+  degenerator@5.0.1:
+    resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
+    engines: {node: '>= 14'}
+
+  delegates@1.0.0:
+    resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
+
+  depd@1.1.2:
+    resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
+    engines: {node: '>= 0.6'}
+
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
+  dependency-graph@0.11.0:
+    resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
+    engines: {node: '>= 0.6.0'}
+
+  destroy@1.2.0:
+    resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
+  detect-libc@2.1.2:
+    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+    engines: {node: '>=8'}
+
+  devtools-protocol@0.0.1475386:
+    resolution: {integrity: sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==}
+
+  diff@5.2.0:
+    resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
+    engines: {node: '>=0.3.1'}
+
+  diff@7.0.0:
+    resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
+    engines: {node: '>=0.3.1'}
+
+  dir-glob@3.0.1:
+    resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
+    engines: {node: '>=8'}
+
+  dunder-proto@1.0.1:
+    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+    engines: {node: '>= 0.4'}
+
+  ee-first@1.1.1:
+    resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+  encodeurl@1.0.2:
+    resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
+    engines: {node: '>= 0.8'}
+
+  end-of-stream@1.4.5:
+    resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+  errorstacks@2.4.1:
+    resolution: {integrity: sha512-jE4i0SMYevwu/xxAuzhly/KTwtj0xDhbzB6m1xPImxTkw8wcCbgarOQPfCVMi5JKVyW7in29pNJCCJrry3Ynnw==}
+
+  es-define-property@1.0.1:
+    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+    engines: {node: '>= 0.4'}
+
+  es-errors@1.3.0:
+    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+    engines: {node: '>= 0.4'}
+
+  es-module-lexer@1.7.0:
+    resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+  es-object-atoms@1.1.1:
+    resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+    engines: {node: '>= 0.4'}
+
+  esbuild@0.25.9:
+    resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escalade@3.2.0:
+    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+    engines: {node: '>=6'}
+
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+  escape-string-regexp@4.0.0:
+    resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+    engines: {node: '>=10'}
+
+  escodegen@2.1.0:
+    resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+    engines: {node: '>=6.0'}
+    hasBin: true
+
+  esprima@4.0.1:
+    resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+    engines: {node: '>=4'}
+    hasBin: true
+
+  estraverse@5.3.0:
+    resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+    engines: {node: '>=4.0'}
+
+  estree-walker@2.0.2:
+    resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+  esutils@2.0.3:
+    resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+    engines: {node: '>=0.10.0'}
+
+  etag@1.8.1:
+    resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+    engines: {node: '>= 0.6'}
+
+  execa@5.1.1:
+    resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+    engines: {node: '>=10'}
+
+  extract-zip@2.0.1:
+    resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+    engines: {node: '>= 10.17.0'}
+    hasBin: true
+
+  fast-fifo@1.3.2:
+    resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
+  fast-glob@3.3.3:
+    resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+    engines: {node: '>=8.6.0'}
+
+  fastq@1.19.1:
+    resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
+
+  fd-slicer@1.1.0:
+    resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
+  fill-range@7.1.1:
+    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+    engines: {node: '>=8'}
+
+  find-replace@3.0.0:
+    resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
+    engines: {node: '>=4.0.0'}
+
+  fresh@0.5.2:
+    resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
+    engines: {node: '>= 0.6'}
+
+  fs-minipass@2.1.0:
+    resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
+    engines: {node: '>= 8'}
+
+  fs.realpath@1.0.0:
+    resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  gauge@3.0.2:
+    resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
+    engines: {node: '>=10'}
+    deprecated: This package is no longer supported.
+
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
+  get-intrinsic@1.3.0:
+    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+    engines: {node: '>= 0.4'}
+
+  get-proto@1.0.1:
+    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+    engines: {node: '>= 0.4'}
+
+  get-stream@5.2.0:
+    resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+    engines: {node: '>=8'}
+
+  get-stream@6.0.1:
+    resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+    engines: {node: '>=10'}
+
+  get-uri@6.0.5:
+    resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
+    engines: {node: '>= 14'}
+
+  glob-parent@5.1.2:
+    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+    engines: {node: '>= 6'}
+
+  glob@7.2.3:
+    resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+
+  globby@11.1.0:
+    resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
+    engines: {node: '>=10'}
+
+  gopd@1.2.0:
+    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+    engines: {node: '>= 0.4'}
+
+  has-flag@4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+
+  has-symbols@1.1.0:
+    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+    engines: {node: '>= 0.4'}
+
+  has-tostringtag@1.0.2:
+    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+    engines: {node: '>= 0.4'}
+
+  has-unicode@2.0.1:
+    resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
+
+  hasown@2.0.2:
+    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+    engines: {node: '>= 0.4'}
+
+  html-escaper@2.0.2:
+    resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
+  http-assert@1.5.0:
+    resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==}
+    engines: {node: '>= 0.8'}
+
+  http-errors@1.6.3:
+    resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==}
+    engines: {node: '>= 0.6'}
+
+  http-errors@1.8.1:
+    resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==}
+    engines: {node: '>= 0.6'}
+
+  http-errors@2.0.0:
+    resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+    engines: {node: '>= 0.8'}
+
+  http-proxy-agent@7.0.2:
+    resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+    engines: {node: '>= 14'}
+
+  https-proxy-agent@5.0.1:
+    resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+    engines: {node: '>= 6'}
+
+  https-proxy-agent@7.0.6:
+    resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+    engines: {node: '>= 14'}
+
+  human-signals@2.1.0:
+    resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+    engines: {node: '>=10.17.0'}
+
+  iconv-lite@0.4.24:
+    resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
+    engines: {node: '>=0.10.0'}
+
+  ignore@5.3.2:
+    resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+    engines: {node: '>= 4'}
+
+  inflation@2.1.0:
+    resolution: {integrity: sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==}
+    engines: {node: '>= 0.8.0'}
+
+  inflight@1.0.6:
+    resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+    deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
+
+  inherits@2.0.3:
+    resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==}
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+  internal-ip@6.2.0:
+    resolution: {integrity: sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==}
+    engines: {node: '>=10'}
+
+  ip-address@10.0.1:
+    resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
+    engines: {node: '>= 12'}
+
+  ip-regex@4.3.0:
+    resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
+    engines: {node: '>=8'}
+
+  ipaddr.js@1.9.1:
+    resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+    engines: {node: '>= 0.10'}
+
+  is-core-module@2.16.1:
+    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
+    engines: {node: '>= 0.4'}
+
+  is-docker@2.2.1:
+    resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+    engines: {node: '>=8'}
+    hasBin: true
+
+  is-extglob@2.1.1:
+    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+    engines: {node: '>=0.10.0'}
+
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
+  is-generator-function@1.1.0:
+    resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==}
+    engines: {node: '>= 0.4'}
+
+  is-glob@4.0.3:
+    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+    engines: {node: '>=0.10.0'}
+
+  is-ip@3.1.0:
+    resolution: {integrity: sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==}
+    engines: {node: '>=8'}
+
+  is-module@1.0.0:
+    resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+
+  is-number@7.0.0:
+    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+    engines: {node: '>=0.12.0'}
+
+  is-regex@1.2.1:
+    resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+    engines: {node: '>= 0.4'}
+
+  is-stream@2.0.1:
+    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+    engines: {node: '>=8'}
+
+  is-wsl@2.2.0:
+    resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+    engines: {node: '>=8'}
+
+  isbinaryfile@5.0.5:
+    resolution: {integrity: sha512-vh9MWXjhhblwrHlt/yutrubDuBD01kKFscyVndE2/VEeEU5aAizrzuWAsEaCjo997k+IluhN6C4jgxfS69SCIw==}
+    engines: {node: '>= 18.0.0'}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  istanbul-lib-coverage@3.2.2:
+    resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+    engines: {node: '>=8'}
+
+  istanbul-lib-report@3.0.1:
+    resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+    engines: {node: '>=10'}
+
+  istanbul-reports@3.2.0:
+    resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+    engines: {node: '>=8'}
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  keygrip@1.1.0:
+    resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
+    engines: {node: '>= 0.6'}
+
+  koa-compose@4.1.0:
+    resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==}
+
+  koa-convert@2.0.0:
+    resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==}
+    engines: {node: '>= 10'}
+
+  koa-etag@4.0.0:
+    resolution: {integrity: sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==}
+
+  koa-send@5.0.1:
+    resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==}
+    engines: {node: '>= 8'}
+
+  koa-static@5.0.0:
+    resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==}
+    engines: {node: '>= 7.6.0'}
+
+  koa@2.16.2:
+    resolution: {integrity: sha512-+CCssgnrWKx9aI3OeZwroa/ckG4JICxvIFnSiOUyl2Uv+UTI+xIw0FfFrWS7cQFpoePpr9o8csss7KzsTzNL8Q==}
+    engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4}
+
+  lighthouse-logger@1.4.2:
+    resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
+
+  lit-element@4.2.1:
+    resolution: {integrity: sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==}
+
+  lit-html@3.3.1:
+    resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==}
+
+  lit@3.3.1:
+    resolution: {integrity: sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==}
+
+  lodash.camelcase@4.3.0:
+    resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
+
+  log-update@4.0.0:
+    resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==}
+    engines: {node: '>=10'}
+
+  lru-cache@7.18.3:
+    resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
+    engines: {node: '>=12'}
+
+  lru-cache@8.0.5:
+    resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
+    engines: {node: '>=16.14'}
+
+  make-dir@3.1.0:
+    resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
+    engines: {node: '>=8'}
+
+  make-dir@4.0.0:
+    resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+    engines: {node: '>=10'}
+
+  marky@1.3.0:
+    resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  media-typer@0.3.0:
+    resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+    engines: {node: '>= 0.6'}
+
+  merge-stream@2.0.0:
+    resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+
+  merge2@1.4.1:
+    resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+    engines: {node: '>= 8'}
+
+  micromatch@4.0.8:
+    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+    engines: {node: '>=8.6'}
+
+  mime-db@1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+
+  mimic-fn@2.1.0:
+    resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+    engines: {node: '>=6'}
+
+  mimic-response@2.1.0:
+    resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
+    engines: {node: '>=8'}
+
+  minimatch@3.1.5:
+    resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+  minipass@3.3.6:
+    resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
+    engines: {node: '>=8'}
+
+  minipass@5.0.0:
+    resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
+    engines: {node: '>=8'}
+
+  minizlib@2.1.2:
+    resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
+    engines: {node: '>= 8'}
+
+  mitt@3.0.1:
+    resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
+  mkdirp@1.0.4:
+    resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  ms@2.0.0:
+    resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  nan@2.26.2:
+    resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==}
+
+  nanocolors@0.2.13:
+    resolution: {integrity: sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==}
+
+  nanoid@3.3.11:
+    resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  negotiator@0.6.3:
+    resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+    engines: {node: '>= 0.6'}
+
+  netmask@2.0.2:
+    resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
+    engines: {node: '>= 0.4.0'}
+
+  node-fetch@2.7.0:
+    resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
+    engines: {node: 4.x || >=6.0.0}
+    peerDependencies:
+      encoding: ^0.1.0
+    peerDependenciesMeta:
+      encoding:
+        optional: true
+
+  nopt@5.0.0:
+    resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  npm-run-path@4.0.1:
+    resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+    engines: {node: '>=8'}
+
+  npmlog@5.0.1:
+    resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
+    deprecated: This package is no longer supported.
+
+  object-assign@4.1.1:
+    resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+    engines: {node: '>=0.10.0'}
+
+  object-inspect@1.13.4:
+    resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+    engines: {node: '>= 0.4'}
+
+  on-finished@2.4.1:
+    resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+    engines: {node: '>= 0.8'}
+
+  once@1.4.0:
+    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+  onetime@5.1.2:
+    resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+    engines: {node: '>=6'}
+
+  only@0.0.2:
+    resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==}
+
+  open@8.4.2:
+    resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
+    engines: {node: '>=12'}
+
+  p-event@4.2.0:
+    resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
+    engines: {node: '>=8'}
+
+  p-finally@1.0.0:
+    resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
+    engines: {node: '>=4'}
+
+  p-timeout@3.2.0:
+    resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
+    engines: {node: '>=8'}
+
+  pac-proxy-agent@7.2.0:
+    resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
+    engines: {node: '>= 14'}
+
+  pac-resolver@7.0.1:
+    resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
+    engines: {node: '>= 14'}
+
+  parse5@6.0.1:
+    resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
+
+  parseurl@1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+
+  path-is-absolute@1.0.1:
+    resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+    engines: {node: '>=0.10.0'}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  path-parse@1.0.7:
+    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+  path-type@4.0.0:
+    resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
+    engines: {node: '>=8'}
+
+  pend@1.2.0:
+    resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@2.3.1:
+    resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+    engines: {node: '>=8.6'}
+
+  picomatch@4.0.3:
+    resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+    engines: {node: '>=12'}
+
+  portfinder@1.0.37:
+    resolution: {integrity: sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==}
+    engines: {node: '>= 10.12'}
+
+  progress@2.0.3:
+    resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+    engines: {node: '>=0.4.0'}
+
+  proxy-agent@6.5.0:
+    resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
+    engines: {node: '>= 14'}
+
+  proxy-from-env@1.1.0:
+    resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
+  pump@3.0.3:
+    resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
+  punycode@2.3.1:
+    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+    engines: {node: '>=6'}
+
+  puppeteer-core@24.17.0:
+    resolution: {integrity: sha512-RYOBKFiF+3RdwIZTEacqNpD567gaFcBAOKTT7742FdB1icXudrPI7BlZbYTYWK2wgGQUXt9Zi1Yn+D5PmCs4CA==}
+    engines: {node: '>=18'}
+
+  qs@6.14.0:
+    resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
+    engines: {node: '>=0.6'}
+
+  queue-microtask@1.2.3:
+    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+  raw-body@2.5.2:
+    resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
+    engines: {node: '>= 0.8'}
+
+  readable-stream@3.6.2:
+    resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+    engines: {node: '>= 6'}
+
+  readdirp@4.1.2:
+    resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+    engines: {node: '>= 14.18.0'}
+
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  resemblejs@5.0.0:
+    resolution: {integrity: sha512-+B0eP9k9VDP/YhBbH+ZdYmHiotdtuc6blVI+h8wwkY2cOow+uiIpSmgkBBBtrEAL0D31/gR/AJPwDeX5TcwmIA==}
+
+  resolve-path@1.4.0:
+    resolution: {integrity: sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==}
+    engines: {node: '>= 0.8'}
+
+  resolve@1.22.10:
+    resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
+    engines: {node: '>= 0.4'}
+    hasBin: true
+
+  restore-cursor@3.1.0:
+    resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
+    engines: {node: '>=8'}
+
+  reusify@1.1.0:
+    resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+  rimraf@3.0.2:
+    resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
+    deprecated: Rimraf versions prior to v4 are no longer supported
+    hasBin: true
+
+  rollup@4.49.0:
+    resolution: {integrity: sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  run-parallel@1.2.0:
+    resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+  rxjs@6.6.7:
+    resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
+    engines: {npm: '>=2.0.0'}
+
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+  safe-regex-test@1.1.0:
+    resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+    engines: {node: '>= 0.4'}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  semver@6.3.1:
+    resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+    hasBin: true
+
+  semver@7.7.4:
+    resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  set-blocking@2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
+  setprototypeof@1.1.0:
+    resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
+
+  setprototypeof@1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  side-channel-list@1.0.0:
+    resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-map@1.0.1:
+    resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-weakmap@1.0.2:
+    resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+    engines: {node: '>= 0.4'}
+
+  side-channel@1.1.0:
+    resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+    engines: {node: '>= 0.4'}
+
+  signal-exit@3.0.7:
+    resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
+  simple-concat@1.0.1:
+    resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+  simple-get@3.1.1:
+    resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
+
+  sinon@20.0.0:
+    resolution: {integrity: sha512-+FXOAbdnj94AQIxH0w1v8gzNxkawVvNqE3jUzRLptR71Oykeu2RrQXXl/VQjKay+Qnh73fDt/oDfMo6xMeDQbQ==}
+
+  slash@3.0.0:
+    resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
+    engines: {node: '>=8'}
+
+  slice-ansi@4.0.0:
+    resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
+    engines: {node: '>=10'}
+
+  smart-buffer@4.2.0:
+    resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+    engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+
+  socks-proxy-agent@8.0.5:
+    resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
+    engines: {node: '>= 14'}
+
+  socks@2.8.7:
+    resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
+    engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+
+  source-map@0.6.1:
+    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+    engines: {node: '>=0.10.0'}
+
+  source-map@0.7.6:
+    resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
+    engines: {node: '>= 12'}
+
+  statuses@1.5.0:
+    resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+    engines: {node: '>= 0.6'}
+
+  statuses@2.0.1:
+    resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
+    engines: {node: '>= 0.8'}
+
+  streamx@2.22.1:
+    resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==}
+
+  string-width@4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+
+  string_decoder@1.3.0:
+    resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+  strip-ansi@6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+
+  strip-final-newline@2.0.0:
+    resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+    engines: {node: '>=6'}
+
+  style-mod@4.1.3:
+    resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+
+  supports-color@7.2.0:
+    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+    engines: {node: '>=8'}
+
+  supports-preserve-symlinks-flag@1.0.0:
+    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+    engines: {node: '>= 0.4'}
+
+  table-layout@4.1.1:
+    resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
+    engines: {node: '>=12.17'}
+
+  tar-fs@3.1.0:
+    resolution: {integrity: sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==}
+
+  tar-stream@3.1.7:
+    resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
+
+  tar@6.2.1:
+    resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
+    engines: {node: '>=10'}
+    deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+
+  text-decoder@1.2.3:
+    resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
+
+  to-regex-range@5.0.1:
+    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+    engines: {node: '>=8.0'}
+
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
+  tr46@0.0.3:
+    resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+
+  tr46@5.1.1:
+    resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+    engines: {node: '>=18'}
+
+  tslib@1.14.1:
+    resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+  tsscmp@1.0.6:
+    resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
+    engines: {node: '>=0.6.x'}
+
+  type-detect@4.0.8:
+    resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
+    engines: {node: '>=4'}
+
+  type-detect@4.1.0:
+    resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
+    engines: {node: '>=4'}
+
+  type-fest@0.21.3:
+    resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
+    engines: {node: '>=10'}
+
+  type-is@1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+
+  typed-query-selector@2.12.0:
+    resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==}
+
+  typical@4.0.0:
+    resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
+    engines: {node: '>=8'}
+
+  typical@7.3.0:
+    resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
+    engines: {node: '>=12.17'}
+
+  ua-parser-js@1.0.41:
+    resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
+    hasBin: true
+
+  undici-types@7.10.0:
+    resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
+
+  unpipe@1.0.0:
+    resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+    engines: {node: '>= 0.8'}
+
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+  v8-to-istanbul@9.3.0:
+    resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
+    engines: {node: '>=10.12.0'}
+
+  vary@1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+
+  w3c-keyname@2.2.8:
+    resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
+  webidl-conversions@3.0.1:
+    resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+
+  webidl-conversions@7.0.0:
+    resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+    engines: {node: '>=12'}
+
+  whatwg-url@14.2.0:
+    resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+    engines: {node: '>=18'}
+
+  whatwg-url@5.0.0:
+    resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+
+  which@2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  wide-align@1.1.5:
+    resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
+
+  wordwrapjs@5.1.0:
+    resolution: {integrity: sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==}
+    engines: {node: '>=12.17'}
+
+  wrap-ansi@6.2.0:
+    resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+    engines: {node: '>=8'}
+
+  wrap-ansi@7.0.0:
+    resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+    engines: {node: '>=10'}
+
+  wrappy@1.0.2:
+    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+  ws@7.5.10:
+    resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
+    engines: {node: '>=8.3.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: ^5.0.2
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  ws@8.18.3:
+    resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  y18n@5.0.8:
+    resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+    engines: {node: '>=10'}
+
+  yallist@4.0.0:
+    resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
+  yargs-parser@21.1.1:
+    resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+    engines: {node: '>=12'}
+
+  yargs@17.7.2:
+    resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+    engines: {node: '>=12'}
+
+  yauzl@2.10.0:
+    resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
+  ylru@1.4.0:
+    resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==}
+    engines: {node: '>= 4.0.0'}
+
+  zod@3.25.76:
+    resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+snapshots:
+
+  '@babel/code-frame@7.27.1':
+    dependencies:
+      '@babel/helper-validator-identifier': 7.27.1
+      js-tokens: 4.0.0
+      picocolors: 1.1.1
+
+  '@babel/helper-validator-identifier@7.27.1': {}
+
+  '@codemirror/autocomplete@6.20.3':
+    dependencies:
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+
+  '@codemirror/commands@6.11.0':
+    dependencies:
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+
+  '@codemirror/lang-angular@0.1.4':
+    dependencies:
+      '@codemirror/lang-html': 6.4.12
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/language': 6.12.4
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/lang-cpp@6.0.3':
+    dependencies:
+      '@codemirror/language': 6.12.4
+      '@lezer/cpp': 1.1.6
+
+  '@codemirror/lang-css@6.3.1':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@lezer/common': 1.5.2
+      '@lezer/css': 1.3.6
+
+  '@codemirror/lang-go@6.0.1':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@lezer/common': 1.5.2
+      '@lezer/go': 1.0.1
+
+  '@codemirror/lang-html@6.4.12':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+      '@lezer/css': 1.3.6
+      '@lezer/html': 1.3.13
+
+  '@codemirror/lang-java@6.0.2':
+    dependencies:
+      '@codemirror/language': 6.12.4
+      '@lezer/java': 1.1.3
+
+  '@codemirror/lang-javascript@6.2.5':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/lint': 6.9.7
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+      '@lezer/javascript': 1.5.4
+
+  '@codemirror/lang-jinja@6.0.1':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-html': 6.4.12
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/lang-json@6.0.2':
+    dependencies:
+      '@codemirror/language': 6.12.4
+      '@lezer/json': 1.0.3
+
+  '@codemirror/lang-less@6.0.2':
+    dependencies:
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/language': 6.12.4
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/lang-liquid@6.3.2':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-html': 6.4.12
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/lang-markdown@6.5.2':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/lang-html': 6.4.12
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+      '@lezer/markdown': 1.7.2
+
+  '@codemirror/lang-php@6.0.2':
+    dependencies:
+      '@codemirror/lang-html': 6.4.12
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@lezer/common': 1.5.2
+      '@lezer/php': 1.0.5
+
+  '@codemirror/lang-python@6.2.1':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@lezer/common': 1.5.2
+      '@lezer/python': 1.1.19
+
+  '@codemirror/lang-rust@6.0.2':
+    dependencies:
+      '@codemirror/language': 6.12.4
+      '@lezer/rust': 1.0.2
+
+  '@codemirror/lang-sass@6.0.2':
+    dependencies:
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@lezer/common': 1.5.2
+      '@lezer/sass': 1.1.0
+
+  '@codemirror/lang-sql@6.10.0':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/lang-vue@0.1.3':
+    dependencies:
+      '@codemirror/lang-html': 6.4.12
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/language': 6.12.4
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/lang-wast@6.0.2':
+    dependencies:
+      '@codemirror/language': 6.12.4
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@codemirror/lang-xml@6.1.0':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+      '@lezer/xml': 1.0.6
+
+  '@codemirror/lang-yaml@6.1.3':
+    dependencies:
+      '@codemirror/autocomplete': 6.20.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/state': 6.7.1
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+      '@lezer/yaml': 1.0.4
+
+  '@codemirror/language-data@6.5.2':
+    dependencies:
+      '@codemirror/lang-angular': 0.1.4
+      '@codemirror/lang-cpp': 6.0.3
+      '@codemirror/lang-css': 6.3.1
+      '@codemirror/lang-go': 6.0.1
+      '@codemirror/lang-html': 6.4.12
+      '@codemirror/lang-java': 6.0.2
+      '@codemirror/lang-javascript': 6.2.5
+      '@codemirror/lang-jinja': 6.0.1
+      '@codemirror/lang-json': 6.0.2
+      '@codemirror/lang-less': 6.0.2
+      '@codemirror/lang-liquid': 6.3.2
+      '@codemirror/lang-markdown': 6.5.2
+      '@codemirror/lang-php': 6.0.2
+      '@codemirror/lang-python': 6.2.1
+      '@codemirror/lang-rust': 6.0.2
+      '@codemirror/lang-sass': 6.0.2
+      '@codemirror/lang-sql': 6.10.0
+      '@codemirror/lang-vue': 0.1.3
+      '@codemirror/lang-wast': 6.0.2
+      '@codemirror/lang-xml': 6.1.0
+      '@codemirror/lang-yaml': 6.1.3
+      '@codemirror/language': 6.12.4
+      '@codemirror/legacy-modes': 6.5.3
+
+  '@codemirror/language@6.12.4':
+    dependencies:
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+      style-mod: 4.1.3
+
+  '@codemirror/legacy-modes@6.5.3':
+    dependencies:
+      '@codemirror/language': 6.12.4
+
+  '@codemirror/lint@6.9.7':
+    dependencies:
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      crelt: 1.0.7
+
+  '@codemirror/search@6.7.1':
+    dependencies:
+      '@codemirror/state': 6.7.1
+      '@codemirror/view': 6.43.9
+      crelt: 1.0.7
+
+  '@codemirror/state@6.7.1':
+    dependencies:
+      '@marijn/find-cluster-break': 1.0.4
+
+  '@codemirror/view@6.43.9':
+    dependencies:
+      '@codemirror/state': 6.7.1
+      crelt: 1.0.7
+      style-mod: 4.1.3
+      w3c-keyname: 2.2.8
+
+  '@esbuild/aix-ppc64@0.25.9':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/android-arm@0.25.9':
+    optional: true
+
+  '@esbuild/android-x64@0.25.9':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.9':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.9':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.9':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.9':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.9':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.9':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.9':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.9':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.9':
+    optional: true
+
+  '@esm-bundle/chai@4.3.4-fix.0':
+    dependencies:
+      '@types/chai': 4.3.20
+
+  '@gerritcodereview/typescript-api@3.14.0': {}
+
+  '@hapi/bourne@3.0.0': {}
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/sourcemap-codec@1.5.5': {}
+
+  '@jridgewell/trace-mapping@0.3.30':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.5.5
+
+  '@lezer/common@1.5.2': {}
+
+  '@lezer/cpp@1.1.6':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/css@1.3.6':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/go@1.0.1':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/highlight@1.2.3':
+    dependencies:
+      '@lezer/common': 1.5.2
+
+  '@lezer/html@1.3.13':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/java@1.1.3':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/javascript@1.5.4':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/json@1.0.3':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/lr@1.4.10':
+    dependencies:
+      '@lezer/common': 1.5.2
+
+  '@lezer/markdown@1.7.2':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+
+  '@lezer/php@1.0.5':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/python@1.1.19':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/rust@1.0.2':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/sass@1.1.0':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/xml@1.0.6':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lezer/yaml@1.0.4':
+    dependencies:
+      '@lezer/common': 1.5.2
+      '@lezer/highlight': 1.2.3
+      '@lezer/lr': 1.4.10
+
+  '@lit-labs/ssr-dom-shim@1.4.0': {}
+
+  '@lit/reactive-element@2.1.1':
+    dependencies:
+      '@lit-labs/ssr-dom-shim': 1.4.0
+
+  '@mapbox/node-pre-gyp@1.0.11':
+    dependencies:
+      detect-libc: 2.1.2
+      https-proxy-agent: 5.0.1
+      make-dir: 3.1.0
+      node-fetch: 2.7.0
+      nopt: 5.0.0
+      npmlog: 5.0.1
+      rimraf: 3.0.2
+      semver: 7.7.4
+      tar: 6.2.1
+    transitivePeerDependencies:
+      - encoding
+      - supports-color
+    optional: true
+
+  '@marijn/find-cluster-break@1.0.4': {}
+
+  '@material/web@2.4.1':
+    dependencies:
+      lit: 3.3.1
+      tslib: 2.8.1
+
+  '@mdn/browser-compat-data@4.2.1': {}
+
+  '@nodelib/fs.scandir@2.1.5':
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      run-parallel: 1.2.0
+
+  '@nodelib/fs.stat@2.0.5': {}
+
+  '@nodelib/fs.walk@1.2.8':
+    dependencies:
+      '@nodelib/fs.scandir': 2.1.5
+      fastq: 1.19.1
+
+  '@open-wc/dedupe-mixin@2.0.1': {}
+
+  '@open-wc/scoped-elements@3.0.6':
+    dependencies:
+      '@open-wc/dedupe-mixin': 2.0.1
+      lit: 3.3.1
+
+  '@open-wc/semantic-dom-diff@0.20.1':
+    dependencies:
+      '@types/chai': 4.3.20
+      '@web/test-runner-commands': 0.9.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@open-wc/testing-helpers@3.0.1':
+    dependencies:
+      '@open-wc/scoped-elements': 3.0.6
+      lit: 3.3.1
+      lit-html: 3.3.1
+
+  '@open-wc/testing@4.0.0':
+    dependencies:
+      '@esm-bundle/chai': 4.3.4-fix.0
+      '@open-wc/semantic-dom-diff': 0.20.1
+      '@open-wc/testing-helpers': 3.0.1
+      '@types/chai-dom': 1.11.3
+      '@types/sinon-chai': 3.2.12
+      chai-a11y-axe: 1.5.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@puppeteer/browsers@2.10.7':
+    dependencies:
+      debug: 4.4.1
+      extract-zip: 2.0.1
+      progress: 2.0.3
+      proxy-agent: 6.5.0
+      semver: 7.7.4
+      tar-fs: 3.1.0
+      yargs: 17.7.2
+    transitivePeerDependencies:
+      - bare-buffer
+      - supports-color
+
+  '@rollup/plugin-node-resolve@15.3.1(rollup@4.49.0)':
+    dependencies:
+      '@rollup/pluginutils': 5.2.0(rollup@4.49.0)
+      '@types/resolve': 1.20.2
+      deepmerge: 4.3.1
+      is-module: 1.0.0
+      resolve: 1.22.10
+    optionalDependencies:
+      rollup: 4.49.0
+
+  '@rollup/pluginutils@5.2.0(rollup@4.49.0)':
+    dependencies:
+      '@types/estree': 1.0.8
+      estree-walker: 2.0.2
+      picomatch: 4.0.3
+    optionalDependencies:
+      rollup: 4.49.0
+
+  '@rollup/rollup-android-arm-eabi@4.49.0':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-loongarch64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.49.0':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.49.0':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.49.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.49.0':
+    optional: true
+
+  '@sinonjs/commons@3.0.1':
+    dependencies:
+      type-detect: 4.0.8
+
+  '@sinonjs/fake-timers@13.0.5':
+    dependencies:
+      '@sinonjs/commons': 3.0.1
+
+  '@sinonjs/samsam@8.0.3':
+    dependencies:
+      '@sinonjs/commons': 3.0.1
+      type-detect: 4.1.0
+
+  '@tootallnate/quickjs-emscripten@0.23.0': {}
+
+  '@types/accepts@1.3.7':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/babel__code-frame@7.0.6': {}
+
+  '@types/body-parser@1.19.6':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/node': 24.3.0
+
+  '@types/chai-dom@1.11.3':
+    dependencies:
+      '@types/chai': 5.2.2
+
+  '@types/chai@4.3.20': {}
+
+  '@types/chai@5.2.2':
+    dependencies:
+      '@types/deep-eql': 4.0.2
+
+  '@types/co-body@6.1.3':
+    dependencies:
+      '@types/node': 24.3.0
+      '@types/qs': 6.14.0
+
+  '@types/command-line-args@5.2.3': {}
+
+  '@types/connect@3.4.38':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/content-disposition@0.5.9': {}
+
+  '@types/convert-source-map@2.0.3': {}
+
+  '@types/cookies@0.9.1':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/express': 5.0.3
+      '@types/keygrip': 1.0.6
+      '@types/node': 24.3.0
+
+  '@types/debounce@1.2.4': {}
+
+  '@types/deep-eql@4.0.2': {}
+
+  '@types/estree@1.0.8': {}
+
+  '@types/express-serve-static-core@5.0.7':
+    dependencies:
+      '@types/node': 24.3.0
+      '@types/qs': 6.14.0
+      '@types/range-parser': 1.2.7
+      '@types/send': 0.17.5
+
+  '@types/express@5.0.3':
+    dependencies:
+      '@types/body-parser': 1.19.6
+      '@types/express-serve-static-core': 5.0.7
+      '@types/serve-static': 1.15.8
+
+  '@types/http-assert@1.5.6': {}
+
+  '@types/http-errors@2.0.5': {}
+
+  '@types/istanbul-lib-coverage@2.0.6': {}
+
+  '@types/istanbul-lib-report@3.0.3':
+    dependencies:
+      '@types/istanbul-lib-coverage': 2.0.6
+
+  '@types/istanbul-reports@3.0.4':
+    dependencies:
+      '@types/istanbul-lib-report': 3.0.3
+
+  '@types/keygrip@1.0.6': {}
+
+  '@types/koa-compose@3.2.8':
+    dependencies:
+      '@types/koa': 3.0.0
+
+  '@types/koa@2.15.0':
+    dependencies:
+      '@types/accepts': 1.3.7
+      '@types/content-disposition': 0.5.9
+      '@types/cookies': 0.9.1
+      '@types/http-assert': 1.5.6
+      '@types/http-errors': 2.0.5
+      '@types/keygrip': 1.0.6
+      '@types/koa-compose': 3.2.8
+      '@types/node': 24.3.0
+
+  '@types/koa@3.0.0':
+    dependencies:
+      '@types/accepts': 1.3.7
+      '@types/content-disposition': 0.5.9
+      '@types/cookies': 0.9.1
+      '@types/http-assert': 1.5.6
+      '@types/http-errors': 2.0.5
+      '@types/keygrip': 1.0.6
+      '@types/koa-compose': 3.2.8
+      '@types/node': 24.3.0
+
+  '@types/mime@1.3.5': {}
+
+  '@types/node@24.3.0':
+    dependencies:
+      undici-types: 7.10.0
+
+  '@types/parse5@6.0.3': {}
+
+  '@types/qs@6.14.0': {}
+
+  '@types/range-parser@1.2.7': {}
+
+  '@types/resolve@1.20.2': {}
+
+  '@types/send@0.17.5':
+    dependencies:
+      '@types/mime': 1.3.5
+      '@types/node': 24.3.0
+
+  '@types/serve-static@1.15.8':
+    dependencies:
+      '@types/http-errors': 2.0.5
+      '@types/node': 24.3.0
+      '@types/send': 0.17.5
+
+  '@types/sinon-chai@3.2.12':
+    dependencies:
+      '@types/chai': 5.2.2
+      '@types/sinon': 17.0.4
+
+  '@types/sinon@17.0.4':
+    dependencies:
+      '@types/sinonjs__fake-timers': 8.1.5
+
+  '@types/sinonjs__fake-timers@8.1.5': {}
+
+  '@types/trusted-types@2.0.7': {}
+
+  '@types/ws@7.4.7':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/yauzl@2.10.3':
+    dependencies:
+      '@types/node': 24.3.0
+    optional: true
+
+  '@web/browser-logs@0.4.1':
+    dependencies:
+      errorstacks: 2.4.1
+
+  '@web/config-loader@0.3.3': {}
+
+  '@web/dev-server-core@0.7.5':
+    dependencies:
+      '@types/koa': 2.15.0
+      '@types/ws': 7.4.7
+      '@web/parse5-utils': 2.1.0
+      chokidar: 4.0.3
+      clone: 2.1.2
+      es-module-lexer: 1.7.0
+      get-stream: 6.0.1
+      is-stream: 2.0.1
+      isbinaryfile: 5.0.5
+      koa: 2.16.2
+      koa-etag: 4.0.0
+      koa-send: 5.0.1
+      koa-static: 5.0.0
+      lru-cache: 8.0.5
+      mime-types: 2.1.35
+      parse5: 6.0.1
+      picomatch: 2.3.1
+      ws: 7.5.10
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server-esbuild@1.0.4':
+    dependencies:
+      '@mdn/browser-compat-data': 4.2.1
+      '@web/dev-server-core': 0.7.5
+      esbuild: 0.25.9
+      parse5: 6.0.1
+      ua-parser-js: 1.0.41
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server-rollup@0.6.4':
+    dependencies:
+      '@rollup/plugin-node-resolve': 15.3.1(rollup@4.49.0)
+      '@web/dev-server-core': 0.7.5
+      nanocolors: 0.2.13
+      parse5: 6.0.1
+      rollup: 4.49.0
+      whatwg-url: 14.2.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server@0.4.6':
+    dependencies:
+      '@babel/code-frame': 7.27.1
+      '@types/command-line-args': 5.2.3
+      '@web/config-loader': 0.3.3
+      '@web/dev-server-core': 0.7.5
+      '@web/dev-server-rollup': 0.6.4
+      camelcase: 6.3.0
+      command-line-args: 5.2.1
+      command-line-usage: 7.0.3
+      debounce: 1.2.1
+      deepmerge: 4.3.1
+      internal-ip: 6.2.0
+      nanocolors: 0.2.13
+      open: 8.4.2
+      portfinder: 1.0.37
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/parse5-utils@2.1.0':
+    dependencies:
+      '@types/parse5': 6.0.3
+      parse5: 6.0.1
+
+  '@web/test-runner-chrome@0.18.1':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+      '@web/test-runner-coverage-v8': 0.8.0
+      chrome-launcher: 0.15.2
+      puppeteer-core: 24.17.0
+    transitivePeerDependencies:
+      - bare-buffer
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-commands@0.9.0':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+      mkdirp: 1.0.4
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-core@0.13.4':
+    dependencies:
+      '@babel/code-frame': 7.27.1
+      '@types/babel__code-frame': 7.0.6
+      '@types/co-body': 6.1.3
+      '@types/convert-source-map': 2.0.3
+      '@types/debounce': 1.2.4
+      '@types/istanbul-lib-coverage': 2.0.6
+      '@types/istanbul-reports': 3.0.4
+      '@web/browser-logs': 0.4.1
+      '@web/dev-server-core': 0.7.5
+      chokidar: 4.0.3
+      cli-cursor: 3.1.0
+      co-body: 6.2.0
+      convert-source-map: 2.0.0
+      debounce: 1.2.1
+      dependency-graph: 0.11.0
+      globby: 11.1.0
+      internal-ip: 6.2.0
+      istanbul-lib-coverage: 3.2.2
+      istanbul-lib-report: 3.0.1
+      istanbul-reports: 3.2.0
+      log-update: 4.0.0
+      nanocolors: 0.2.13
+      nanoid: 3.3.11
+      open: 8.4.2
+      picomatch: 2.3.1
+      source-map: 0.7.6
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-coverage-v8@0.8.0':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+      istanbul-lib-coverage: 3.2.2
+      lru-cache: 8.0.5
+      picomatch: 2.3.1
+      v8-to-istanbul: 9.3.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-mocha@0.9.0':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner@0.20.2':
+    dependencies:
+      '@web/browser-logs': 0.4.1
+      '@web/config-loader': 0.3.3
+      '@web/dev-server': 0.4.6
+      '@web/test-runner-chrome': 0.18.1
+      '@web/test-runner-commands': 0.9.0
+      '@web/test-runner-core': 0.13.4
+      '@web/test-runner-mocha': 0.9.0
+      camelcase: 6.3.0
+      command-line-args: 5.2.1
+      command-line-usage: 7.0.3
+      convert-source-map: 2.0.0
+      diff: 5.2.0
+      globby: 11.1.0
+      nanocolors: 0.2.13
+      portfinder: 1.0.37
+      source-map: 0.7.6
+    transitivePeerDependencies:
+      - bare-buffer
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  abbrev@1.1.1:
+    optional: true
+
+  accepts@1.3.8:
+    dependencies:
+      mime-types: 2.1.35
+      negotiator: 0.6.3
+
+  agent-base@6.0.2:
+    dependencies:
+      debug: 4.4.1
+    transitivePeerDependencies:
+      - supports-color
+    optional: true
+
+  agent-base@7.1.4: {}
+
+  ansi-escapes@4.3.2:
+    dependencies:
+      type-fest: 0.21.3
+
+  ansi-regex@5.0.1: {}
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  aproba@2.1.0:
+    optional: true
+
+  are-we-there-yet@2.0.0:
+    dependencies:
+      delegates: 1.0.0
+      readable-stream: 3.6.2
+    optional: true
+
+  array-back@3.1.0: {}
+
+  array-back@6.2.2: {}
+
+  array-union@2.1.0: {}
+
+  ast-types@0.13.4:
+    dependencies:
+      tslib: 2.8.1
+
+  astral-regex@2.0.0: {}
+
+  async@3.2.6: {}
+
+  axe-core@4.10.3: {}
+
+  b4a@1.6.7: {}
+
+  balanced-match@1.0.2:
+    optional: true
+
+  bare-events@2.6.1:
+    optional: true
+
+  bare-fs@4.2.1:
+    dependencies:
+      bare-events: 2.6.1
+      bare-path: 3.0.0
+      bare-stream: 2.7.0(bare-events@2.6.1)
+    optional: true
+
+  bare-os@3.6.2:
+    optional: true
+
+  bare-path@3.0.0:
+    dependencies:
+      bare-os: 3.6.2
+    optional: true
+
+  bare-stream@2.7.0(bare-events@2.6.1):
+    dependencies:
+      streamx: 2.22.1
+    optionalDependencies:
+      bare-events: 2.6.1
+    optional: true
+
+  basic-ftp@5.0.5: {}
+
+  brace-expansion@1.1.12:
+    dependencies:
+      balanced-match: 1.0.2
+      concat-map: 0.0.1
+    optional: true
+
+  braces@3.0.3:
+    dependencies:
+      fill-range: 7.1.1
+
+  buffer-crc32@0.2.13: {}
+
+  bytes@3.1.2: {}
+
+  cache-content-type@1.0.1:
+    dependencies:
+      mime-types: 2.1.35
+      ylru: 1.4.0
+
+  call-bind-apply-helpers@1.0.2:
+    dependencies:
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+
+  call-bound@1.0.4:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      get-intrinsic: 1.3.0
+
+  camelcase@6.3.0: {}
+
+  canvas@2.11.2:
+    dependencies:
+      '@mapbox/node-pre-gyp': 1.0.11
+      nan: 2.26.2
+      simple-get: 3.1.1
+    transitivePeerDependencies:
+      - encoding
+      - supports-color
+    optional: true
+
+  chai-a11y-axe@1.5.0:
+    dependencies:
+      axe-core: 4.10.3
+
+  chalk-template@0.4.0:
+    dependencies:
+      chalk: 4.1.2
+
+  chalk@4.1.2:
+    dependencies:
+      ansi-styles: 4.3.0
+      supports-color: 7.2.0
+
+  chokidar@4.0.3:
+    dependencies:
+      readdirp: 4.1.2
+
+  chownr@2.0.0:
+    optional: true
+
+  chrome-launcher@0.15.2:
+    dependencies:
+      '@types/node': 24.3.0
+      escape-string-regexp: 4.0.0
+      is-wsl: 2.2.0
+      lighthouse-logger: 1.4.2
+    transitivePeerDependencies:
+      - supports-color
+
+  chromium-bidi@8.0.0(devtools-protocol@0.0.1475386):
+    dependencies:
+      devtools-protocol: 0.0.1475386
+      mitt: 3.0.1
+      zod: 3.25.76
+
+  cli-cursor@3.1.0:
+    dependencies:
+      restore-cursor: 3.1.0
+
+  cliui@8.0.1:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 7.0.0
+
+  clone@2.1.2: {}
+
+  co-body@6.2.0:
+    dependencies:
+      '@hapi/bourne': 3.0.0
+      inflation: 2.1.0
+      qs: 6.14.0
+      raw-body: 2.5.2
+      type-is: 1.6.18
+
+  co@4.6.0: {}
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  color-support@1.1.3:
+    optional: true
+
+  command-line-args@5.2.1:
+    dependencies:
+      array-back: 3.1.0
+      find-replace: 3.0.0
+      lodash.camelcase: 4.3.0
+      typical: 4.0.0
+
+  command-line-usage@7.0.3:
+    dependencies:
+      array-back: 6.2.2
+      chalk-template: 0.4.0
+      table-layout: 4.1.1
+      typical: 7.3.0
+
+  concat-map@0.0.1:
+    optional: true
+
+  console-control-strings@1.1.0:
+    optional: true
+
+  content-disposition@0.5.4:
+    dependencies:
+      safe-buffer: 5.2.1
+
+  content-type@1.0.5: {}
+
+  convert-source-map@2.0.0: {}
+
+  cookies@0.9.1:
+    dependencies:
+      depd: 2.0.0
+      keygrip: 1.1.0
+
+  crelt@1.0.7: {}
+
+  cross-spawn@7.0.6:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+
+  data-uri-to-buffer@6.0.2: {}
+
+  debounce@1.2.1: {}
+
+  debug@2.6.9:
+    dependencies:
+      ms: 2.0.0
+
+  debug@3.2.7:
+    dependencies:
+      ms: 2.1.3
+
+  debug@4.4.1:
+    dependencies:
+      ms: 2.1.3
+
+  decompress-response@4.2.1:
+    dependencies:
+      mimic-response: 2.1.0
+    optional: true
+
+  deep-equal@1.0.1: {}
+
+  deepmerge@4.3.1: {}
+
+  default-gateway@6.0.3:
+    dependencies:
+      execa: 5.1.1
+
+  define-lazy-prop@2.0.0: {}
+
+  degenerator@5.0.1:
+    dependencies:
+      ast-types: 0.13.4
+      escodegen: 2.1.0
+      esprima: 4.0.1
+
+  delegates@1.0.0: {}
+
+  depd@1.1.2: {}
+
+  depd@2.0.0: {}
+
+  dependency-graph@0.11.0: {}
+
+  destroy@1.2.0: {}
+
+  detect-libc@2.1.2:
+    optional: true
+
+  devtools-protocol@0.0.1475386: {}
+
+  diff@5.2.0: {}
+
+  diff@7.0.0: {}
+
+  dir-glob@3.0.1:
+    dependencies:
+      path-type: 4.0.0
+
+  dunder-proto@1.0.1:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  ee-first@1.1.1: {}
+
+  emoji-regex@8.0.0: {}
+
+  encodeurl@1.0.2: {}
+
+  end-of-stream@1.4.5:
+    dependencies:
+      once: 1.4.0
+
+  errorstacks@2.4.1: {}
+
+  es-define-property@1.0.1: {}
+
+  es-errors@1.3.0: {}
+
+  es-module-lexer@1.7.0: {}
+
+  es-object-atoms@1.1.1:
+    dependencies:
+      es-errors: 1.3.0
+
+  esbuild@0.25.9:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.9
+      '@esbuild/android-arm': 0.25.9
+      '@esbuild/android-arm64': 0.25.9
+      '@esbuild/android-x64': 0.25.9
+      '@esbuild/darwin-arm64': 0.25.9
+      '@esbuild/darwin-x64': 0.25.9
+      '@esbuild/freebsd-arm64': 0.25.9
+      '@esbuild/freebsd-x64': 0.25.9
+      '@esbuild/linux-arm': 0.25.9
+      '@esbuild/linux-arm64': 0.25.9
+      '@esbuild/linux-ia32': 0.25.9
+      '@esbuild/linux-loong64': 0.25.9
+      '@esbuild/linux-mips64el': 0.25.9
+      '@esbuild/linux-ppc64': 0.25.9
+      '@esbuild/linux-riscv64': 0.25.9
+      '@esbuild/linux-s390x': 0.25.9
+      '@esbuild/linux-x64': 0.25.9
+      '@esbuild/netbsd-arm64': 0.25.9
+      '@esbuild/netbsd-x64': 0.25.9
+      '@esbuild/openbsd-arm64': 0.25.9
+      '@esbuild/openbsd-x64': 0.25.9
+      '@esbuild/openharmony-arm64': 0.25.9
+      '@esbuild/sunos-x64': 0.25.9
+      '@esbuild/win32-arm64': 0.25.9
+      '@esbuild/win32-ia32': 0.25.9
+      '@esbuild/win32-x64': 0.25.9
+
+  escalade@3.2.0: {}
+
+  escape-html@1.0.3: {}
+
+  escape-string-regexp@4.0.0: {}
+
+  escodegen@2.1.0:
+    dependencies:
+      esprima: 4.0.1
+      estraverse: 5.3.0
+      esutils: 2.0.3
+    optionalDependencies:
+      source-map: 0.6.1
+
+  esprima@4.0.1: {}
+
+  estraverse@5.3.0: {}
+
+  estree-walker@2.0.2: {}
+
+  esutils@2.0.3: {}
+
+  etag@1.8.1: {}
+
+  execa@5.1.1:
+    dependencies:
+      cross-spawn: 7.0.6
+      get-stream: 6.0.1
+      human-signals: 2.1.0
+      is-stream: 2.0.1
+      merge-stream: 2.0.0
+      npm-run-path: 4.0.1
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+      strip-final-newline: 2.0.0
+
+  extract-zip@2.0.1:
+    dependencies:
+      debug: 4.4.1
+      get-stream: 5.2.0
+      yauzl: 2.10.0
+    optionalDependencies:
+      '@types/yauzl': 2.10.3
+    transitivePeerDependencies:
+      - supports-color
+
+  fast-fifo@1.3.2: {}
+
+  fast-glob@3.3.3:
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      '@nodelib/fs.walk': 1.2.8
+      glob-parent: 5.1.2
+      merge2: 1.4.1
+      micromatch: 4.0.8
+
+  fastq@1.19.1:
+    dependencies:
+      reusify: 1.1.0
+
+  fd-slicer@1.1.0:
+    dependencies:
+      pend: 1.2.0
+
+  fill-range@7.1.1:
+    dependencies:
+      to-regex-range: 5.0.1
+
+  find-replace@3.0.0:
+    dependencies:
+      array-back: 3.1.0
+
+  fresh@0.5.2: {}
+
+  fs-minipass@2.1.0:
+    dependencies:
+      minipass: 3.3.6
+    optional: true
+
+  fs.realpath@1.0.0:
+    optional: true
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  gauge@3.0.2:
+    dependencies:
+      aproba: 2.1.0
+      color-support: 1.1.3
+      console-control-strings: 1.1.0
+      has-unicode: 2.0.1
+      object-assign: 4.1.1
+      signal-exit: 3.0.7
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wide-align: 1.1.5
+    optional: true
+
+  get-caller-file@2.0.5: {}
+
+  get-intrinsic@1.3.0:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.1
+      function-bind: 1.1.2
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.2
+      math-intrinsics: 1.1.0
+
+  get-proto@1.0.1:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-object-atoms: 1.1.1
+
+  get-stream@5.2.0:
+    dependencies:
+      pump: 3.0.3
+
+  get-stream@6.0.1: {}
+
+  get-uri@6.0.5:
+    dependencies:
+      basic-ftp: 5.0.5
+      data-uri-to-buffer: 6.0.2
+      debug: 4.4.1
+    transitivePeerDependencies:
+      - supports-color
+
+  glob-parent@5.1.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob@7.2.3:
+    dependencies:
+      fs.realpath: 1.0.0
+      inflight: 1.0.6
+      inherits: 2.0.4
+      minimatch: 3.1.5
+      once: 1.4.0
+      path-is-absolute: 1.0.1
+    optional: true
+
+  globby@11.1.0:
+    dependencies:
+      array-union: 2.1.0
+      dir-glob: 3.0.1
+      fast-glob: 3.3.3
+      ignore: 5.3.2
+      merge2: 1.4.1
+      slash: 3.0.0
+
+  gopd@1.2.0: {}
+
+  has-flag@4.0.0: {}
+
+  has-symbols@1.1.0: {}
+
+  has-tostringtag@1.0.2:
+    dependencies:
+      has-symbols: 1.1.0
+
+  has-unicode@2.0.1:
+    optional: true
+
+  hasown@2.0.2:
+    dependencies:
+      function-bind: 1.1.2
+
+  html-escaper@2.0.2: {}
+
+  http-assert@1.5.0:
+    dependencies:
+      deep-equal: 1.0.1
+      http-errors: 1.8.1
+
+  http-errors@1.6.3:
+    dependencies:
+      depd: 1.1.2
+      inherits: 2.0.3
+      setprototypeof: 1.1.0
+      statuses: 1.5.0
+
+  http-errors@1.8.1:
+    dependencies:
+      depd: 1.1.2
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 1.5.0
+      toidentifier: 1.0.1
+
+  http-errors@2.0.0:
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.1
+      toidentifier: 1.0.1
+
+  http-proxy-agent@7.0.2:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1
+    transitivePeerDependencies:
+      - supports-color
+
+  https-proxy-agent@5.0.1:
+    dependencies:
+      agent-base: 6.0.2
+      debug: 4.4.1
+    transitivePeerDependencies:
+      - supports-color
+    optional: true
+
+  https-proxy-agent@7.0.6:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1
+    transitivePeerDependencies:
+      - supports-color
+
+  human-signals@2.1.0: {}
+
+  iconv-lite@0.4.24:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  ignore@5.3.2: {}
+
+  inflation@2.1.0: {}
+
+  inflight@1.0.6:
+    dependencies:
+      once: 1.4.0
+      wrappy: 1.0.2
+    optional: true
+
+  inherits@2.0.3: {}
+
+  inherits@2.0.4: {}
+
+  internal-ip@6.2.0:
+    dependencies:
+      default-gateway: 6.0.3
+      ipaddr.js: 1.9.1
+      is-ip: 3.1.0
+      p-event: 4.2.0
+
+  ip-address@10.0.1: {}
+
+  ip-regex@4.3.0: {}
+
+  ipaddr.js@1.9.1: {}
+
+  is-core-module@2.16.1:
+    dependencies:
+      hasown: 2.0.2
+
+  is-docker@2.2.1: {}
+
+  is-extglob@2.1.1: {}
+
+  is-fullwidth-code-point@3.0.0: {}
+
+  is-generator-function@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      get-proto: 1.0.1
+      has-tostringtag: 1.0.2
+      safe-regex-test: 1.1.0
+
+  is-glob@4.0.3:
+    dependencies:
+      is-extglob: 2.1.1
+
+  is-ip@3.1.0:
+    dependencies:
+      ip-regex: 4.3.0
+
+  is-module@1.0.0: {}
+
+  is-number@7.0.0: {}
+
+  is-regex@1.2.1:
+    dependencies:
+      call-bound: 1.0.4
+      gopd: 1.2.0
+      has-tostringtag: 1.0.2
+      hasown: 2.0.2
+
+  is-stream@2.0.1: {}
+
+  is-wsl@2.2.0:
+    dependencies:
+      is-docker: 2.2.1
+
+  isbinaryfile@5.0.5: {}
+
+  isexe@2.0.0: {}
+
+  istanbul-lib-coverage@3.2.2: {}
+
+  istanbul-lib-report@3.0.1:
+    dependencies:
+      istanbul-lib-coverage: 3.2.2
+      make-dir: 4.0.0
+      supports-color: 7.2.0
+
+  istanbul-reports@3.2.0:
+    dependencies:
+      html-escaper: 2.0.2
+      istanbul-lib-report: 3.0.1
+
+  js-tokens@4.0.0: {}
+
+  keygrip@1.1.0:
+    dependencies:
+      tsscmp: 1.0.6
+
+  koa-compose@4.1.0: {}
+
+  koa-convert@2.0.0:
+    dependencies:
+      co: 4.6.0
+      koa-compose: 4.1.0
+
+  koa-etag@4.0.0:
+    dependencies:
+      etag: 1.8.1
+
+  koa-send@5.0.1:
+    dependencies:
+      debug: 4.4.1
+      http-errors: 1.8.1
+      resolve-path: 1.4.0
+    transitivePeerDependencies:
+      - supports-color
+
+  koa-static@5.0.0:
+    dependencies:
+      debug: 3.2.7
+      koa-send: 5.0.1
+    transitivePeerDependencies:
+      - supports-color
+
+  koa@2.16.2:
+    dependencies:
+      accepts: 1.3.8
+      cache-content-type: 1.0.1
+      content-disposition: 0.5.4
+      content-type: 1.0.5
+      cookies: 0.9.1
+      debug: 4.4.1
+      delegates: 1.0.0
+      depd: 2.0.0
+      destroy: 1.2.0
+      encodeurl: 1.0.2
+      escape-html: 1.0.3
+      fresh: 0.5.2
+      http-assert: 1.5.0
+      http-errors: 1.8.1
+      is-generator-function: 1.1.0
+      koa-compose: 4.1.0
+      koa-convert: 2.0.0
+      on-finished: 2.4.1
+      only: 0.0.2
+      parseurl: 1.3.3
+      statuses: 1.5.0
+      type-is: 1.6.18
+      vary: 1.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  lighthouse-logger@1.4.2:
+    dependencies:
+      debug: 2.6.9
+      marky: 1.3.0
+    transitivePeerDependencies:
+      - supports-color
+
+  lit-element@4.2.1:
+    dependencies:
+      '@lit-labs/ssr-dom-shim': 1.4.0
+      '@lit/reactive-element': 2.1.1
+      lit-html: 3.3.1
+
+  lit-html@3.3.1:
+    dependencies:
+      '@types/trusted-types': 2.0.7
+
+  lit@3.3.1:
+    dependencies:
+      '@lit/reactive-element': 2.1.1
+      lit-element: 4.2.1
+      lit-html: 3.3.1
+
+  lodash.camelcase@4.3.0: {}
+
+  log-update@4.0.0:
+    dependencies:
+      ansi-escapes: 4.3.2
+      cli-cursor: 3.1.0
+      slice-ansi: 4.0.0
+      wrap-ansi: 6.2.0
+
+  lru-cache@7.18.3: {}
+
+  lru-cache@8.0.5: {}
+
+  make-dir@3.1.0:
+    dependencies:
+      semver: 6.3.1
+    optional: true
+
+  make-dir@4.0.0:
+    dependencies:
+      semver: 7.7.4
+
+  marky@1.3.0: {}
+
+  math-intrinsics@1.1.0: {}
+
+  media-typer@0.3.0: {}
+
+  merge-stream@2.0.0: {}
+
+  merge2@1.4.1: {}
+
+  micromatch@4.0.8:
+    dependencies:
+      braces: 3.0.3
+      picomatch: 2.3.1
+
+  mime-db@1.52.0: {}
+
+  mime-types@2.1.35:
+    dependencies:
+      mime-db: 1.52.0
+
+  mimic-fn@2.1.0: {}
+
+  mimic-response@2.1.0:
+    optional: true
+
+  minimatch@3.1.5:
+    dependencies:
+      brace-expansion: 1.1.12
+    optional: true
+
+  minipass@3.3.6:
+    dependencies:
+      yallist: 4.0.0
+    optional: true
+
+  minipass@5.0.0:
+    optional: true
+
+  minizlib@2.1.2:
+    dependencies:
+      minipass: 3.3.6
+      yallist: 4.0.0
+    optional: true
+
+  mitt@3.0.1: {}
+
+  mkdirp@1.0.4: {}
+
+  ms@2.0.0: {}
+
+  ms@2.1.3: {}
+
+  nan@2.26.2:
+    optional: true
+
+  nanocolors@0.2.13: {}
+
+  nanoid@3.3.11: {}
+
+  negotiator@0.6.3: {}
+
+  netmask@2.0.2: {}
+
+  node-fetch@2.7.0:
+    dependencies:
+      whatwg-url: 5.0.0
+    optional: true
+
+  nopt@5.0.0:
+    dependencies:
+      abbrev: 1.1.1
+    optional: true
+
+  npm-run-path@4.0.1:
+    dependencies:
+      path-key: 3.1.1
+
+  npmlog@5.0.1:
+    dependencies:
+      are-we-there-yet: 2.0.0
+      console-control-strings: 1.1.0
+      gauge: 3.0.2
+      set-blocking: 2.0.0
+    optional: true
+
+  object-assign@4.1.1:
+    optional: true
+
+  object-inspect@1.13.4: {}
+
+  on-finished@2.4.1:
+    dependencies:
+      ee-first: 1.1.1
+
+  once@1.4.0:
+    dependencies:
+      wrappy: 1.0.2
+
+  onetime@5.1.2:
+    dependencies:
+      mimic-fn: 2.1.0
+
+  only@0.0.2: {}
+
+  open@8.4.2:
+    dependencies:
+      define-lazy-prop: 2.0.0
+      is-docker: 2.2.1
+      is-wsl: 2.2.0
+
+  p-event@4.2.0:
+    dependencies:
+      p-timeout: 3.2.0
+
+  p-finally@1.0.0: {}
+
+  p-timeout@3.2.0:
+    dependencies:
+      p-finally: 1.0.0
+
+  pac-proxy-agent@7.2.0:
+    dependencies:
+      '@tootallnate/quickjs-emscripten': 0.23.0
+      agent-base: 7.1.4
+      debug: 4.4.1
+      get-uri: 6.0.5
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.6
+      pac-resolver: 7.0.1
+      socks-proxy-agent: 8.0.5
+    transitivePeerDependencies:
+      - supports-color
+
+  pac-resolver@7.0.1:
+    dependencies:
+      degenerator: 5.0.1
+      netmask: 2.0.2
+
+  parse5@6.0.1: {}
+
+  parseurl@1.3.3: {}
+
+  path-is-absolute@1.0.1: {}
+
+  path-key@3.1.1: {}
+
+  path-parse@1.0.7: {}
+
+  path-type@4.0.0: {}
+
+  pend@1.2.0: {}
+
+  picocolors@1.1.1: {}
+
+  picomatch@2.3.1: {}
+
+  picomatch@4.0.3: {}
+
+  portfinder@1.0.37:
+    dependencies:
+      async: 3.2.6
+      debug: 4.4.1
+    transitivePeerDependencies:
+      - supports-color
+
+  progress@2.0.3: {}
+
+  proxy-agent@6.5.0:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.6
+      lru-cache: 7.18.3
+      pac-proxy-agent: 7.2.0
+      proxy-from-env: 1.1.0
+      socks-proxy-agent: 8.0.5
+    transitivePeerDependencies:
+      - supports-color
+
+  proxy-from-env@1.1.0: {}
+
+  pump@3.0.3:
+    dependencies:
+      end-of-stream: 1.4.5
+      once: 1.4.0
+
+  punycode@2.3.1: {}
+
+  puppeteer-core@24.17.0:
+    dependencies:
+      '@puppeteer/browsers': 2.10.7
+      chromium-bidi: 8.0.0(devtools-protocol@0.0.1475386)
+      debug: 4.4.1
+      devtools-protocol: 0.0.1475386
+      typed-query-selector: 2.12.0
+      ws: 8.18.3
+    transitivePeerDependencies:
+      - bare-buffer
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  qs@6.14.0:
+    dependencies:
+      side-channel: 1.1.0
+
+  queue-microtask@1.2.3: {}
+
+  raw-body@2.5.2:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.0
+      iconv-lite: 0.4.24
+      unpipe: 1.0.0
+
+  readable-stream@3.6.2:
+    dependencies:
+      inherits: 2.0.4
+      string_decoder: 1.3.0
+      util-deprecate: 1.0.2
+    optional: true
+
+  readdirp@4.1.2: {}
+
+  require-directory@2.1.1: {}
+
+  resemblejs@5.0.0:
+    optionalDependencies:
+      canvas: 2.11.2
+    transitivePeerDependencies:
+      - encoding
+      - supports-color
+
+  resolve-path@1.4.0:
+    dependencies:
+      http-errors: 1.6.3
+      path-is-absolute: 1.0.1
+
+  resolve@1.22.10:
+    dependencies:
+      is-core-module: 2.16.1
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+
+  restore-cursor@3.1.0:
+    dependencies:
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+
+  reusify@1.1.0: {}
+
+  rimraf@3.0.2:
+    dependencies:
+      glob: 7.2.3
+    optional: true
+
+  rollup@4.49.0:
+    dependencies:
+      '@types/estree': 1.0.8
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.49.0
+      '@rollup/rollup-android-arm64': 4.49.0
+      '@rollup/rollup-darwin-arm64': 4.49.0
+      '@rollup/rollup-darwin-x64': 4.49.0
+      '@rollup/rollup-freebsd-arm64': 4.49.0
+      '@rollup/rollup-freebsd-x64': 4.49.0
+      '@rollup/rollup-linux-arm-gnueabihf': 4.49.0
+      '@rollup/rollup-linux-arm-musleabihf': 4.49.0
+      '@rollup/rollup-linux-arm64-gnu': 4.49.0
+      '@rollup/rollup-linux-arm64-musl': 4.49.0
+      '@rollup/rollup-linux-loongarch64-gnu': 4.49.0
+      '@rollup/rollup-linux-ppc64-gnu': 4.49.0
+      '@rollup/rollup-linux-riscv64-gnu': 4.49.0
+      '@rollup/rollup-linux-riscv64-musl': 4.49.0
+      '@rollup/rollup-linux-s390x-gnu': 4.49.0
+      '@rollup/rollup-linux-x64-gnu': 4.49.0
+      '@rollup/rollup-linux-x64-musl': 4.49.0
+      '@rollup/rollup-win32-arm64-msvc': 4.49.0
+      '@rollup/rollup-win32-ia32-msvc': 4.49.0
+      '@rollup/rollup-win32-x64-msvc': 4.49.0
+      fsevents: 2.3.3
+
+  run-parallel@1.2.0:
+    dependencies:
+      queue-microtask: 1.2.3
+
+  rxjs@6.6.7:
+    dependencies:
+      tslib: 1.14.1
+
+  safe-buffer@5.2.1: {}
+
+  safe-regex-test@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-regex: 1.2.1
+
+  safer-buffer@2.1.2: {}
+
+  semver@6.3.1:
+    optional: true
+
+  semver@7.7.4: {}
+
+  set-blocking@2.0.0:
+    optional: true
+
+  setprototypeof@1.1.0: {}
+
+  setprototypeof@1.2.0: {}
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@3.0.0: {}
+
+  side-channel-list@1.0.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-map@1.0.1:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-weakmap@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-map: 1.0.1
+
+  side-channel@1.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-list: 1.0.0
+      side-channel-map: 1.0.1
+      side-channel-weakmap: 1.0.2
+
+  signal-exit@3.0.7: {}
+
+  simple-concat@1.0.1:
+    optional: true
+
+  simple-get@3.1.1:
+    dependencies:
+      decompress-response: 4.2.1
+      once: 1.4.0
+      simple-concat: 1.0.1
+    optional: true
+
+  sinon@20.0.0:
+    dependencies:
+      '@sinonjs/commons': 3.0.1
+      '@sinonjs/fake-timers': 13.0.5
+      '@sinonjs/samsam': 8.0.3
+      diff: 7.0.0
+      supports-color: 7.2.0
+
+  slash@3.0.0: {}
+
+  slice-ansi@4.0.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      astral-regex: 2.0.0
+      is-fullwidth-code-point: 3.0.0
+
+  smart-buffer@4.2.0: {}
+
+  socks-proxy-agent@8.0.5:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1
+      socks: 2.8.7
+    transitivePeerDependencies:
+      - supports-color
+
+  socks@2.8.7:
+    dependencies:
+      ip-address: 10.0.1
+      smart-buffer: 4.2.0
+
+  source-map@0.6.1:
+    optional: true
+
+  source-map@0.7.6: {}
+
+  statuses@1.5.0: {}
+
+  statuses@2.0.1: {}
+
+  streamx@2.22.1:
+    dependencies:
+      fast-fifo: 1.3.2
+      text-decoder: 1.2.3
+    optionalDependencies:
+      bare-events: 2.6.1
+
+  string-width@4.2.3:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+
+  string_decoder@1.3.0:
+    dependencies:
+      safe-buffer: 5.2.1
+    optional: true
+
+  strip-ansi@6.0.1:
+    dependencies:
+      ansi-regex: 5.0.1
+
+  strip-final-newline@2.0.0: {}
+
+  style-mod@4.1.3: {}
+
+  supports-color@7.2.0:
+    dependencies:
+      has-flag: 4.0.0
+
+  supports-preserve-symlinks-flag@1.0.0: {}
+
+  table-layout@4.1.1:
+    dependencies:
+      array-back: 6.2.2
+      wordwrapjs: 5.1.0
+
+  tar-fs@3.1.0:
+    dependencies:
+      pump: 3.0.3
+      tar-stream: 3.1.7
+    optionalDependencies:
+      bare-fs: 4.2.1
+      bare-path: 3.0.0
+    transitivePeerDependencies:
+      - bare-buffer
+
+  tar-stream@3.1.7:
+    dependencies:
+      b4a: 1.6.7
+      fast-fifo: 1.3.2
+      streamx: 2.22.1
+
+  tar@6.2.1:
+    dependencies:
+      chownr: 2.0.0
+      fs-minipass: 2.1.0
+      minipass: 5.0.0
+      minizlib: 2.1.2
+      mkdirp: 1.0.4
+      yallist: 4.0.0
+    optional: true
+
+  text-decoder@1.2.3:
+    dependencies:
+      b4a: 1.6.7
+
+  to-regex-range@5.0.1:
+    dependencies:
+      is-number: 7.0.0
+
+  toidentifier@1.0.1: {}
+
+  tr46@0.0.3:
+    optional: true
+
+  tr46@5.1.1:
+    dependencies:
+      punycode: 2.3.1
+
+  tslib@1.14.1: {}
+
+  tslib@2.8.1: {}
+
+  tsscmp@1.0.6: {}
+
+  type-detect@4.0.8: {}
+
+  type-detect@4.1.0: {}
+
+  type-fest@0.21.3: {}
+
+  type-is@1.6.18:
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.35
+
+  typed-query-selector@2.12.0: {}
+
+  typical@4.0.0: {}
+
+  typical@7.3.0: {}
+
+  ua-parser-js@1.0.41: {}
+
+  undici-types@7.10.0: {}
+
+  unpipe@1.0.0: {}
+
+  util-deprecate@1.0.2:
+    optional: true
+
+  v8-to-istanbul@9.3.0:
+    dependencies:
+      '@jridgewell/trace-mapping': 0.3.30
+      '@types/istanbul-lib-coverage': 2.0.6
+      convert-source-map: 2.0.0
+
+  vary@1.1.2: {}
+
+  w3c-keyname@2.2.8: {}
+
+  webidl-conversions@3.0.1:
+    optional: true
+
+  webidl-conversions@7.0.0: {}
+
+  whatwg-url@14.2.0:
+    dependencies:
+      tr46: 5.1.1
+      webidl-conversions: 7.0.0
+
+  whatwg-url@5.0.0:
+    dependencies:
+      tr46: 0.0.3
+      webidl-conversions: 3.0.1
+    optional: true
+
+  which@2.0.2:
+    dependencies:
+      isexe: 2.0.0
+
+  wide-align@1.1.5:
+    dependencies:
+      string-width: 4.2.3
+    optional: true
+
+  wordwrapjs@5.1.0: {}
+
+  wrap-ansi@6.2.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  wrap-ansi@7.0.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  wrappy@1.0.2: {}
+
+  ws@7.5.10: {}
+
+  ws@8.18.3: {}
+
+  y18n@5.0.8: {}
+
+  yallist@4.0.0:
+    optional: true
+
+  yargs-parser@21.1.1: {}
+
+  yargs@17.7.2:
+    dependencies:
+      cliui: 8.0.1
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 21.1.1
+
+  yauzl@2.10.0:
+    dependencies:
+      buffer-crc32: 0.2.13
+      fd-slicer: 1.1.0
+
+  ylru@1.4.0: {}
+
+  zod@3.25.76: {}
diff --git a/plugins/pnpm-workspace.yaml b/plugins/pnpm-workspace.yaml
new file mode 100644
index 0000000..7d40629
--- /dev/null
+++ b/plugins/pnpm-workspace.yaml
@@ -0,0 +1 @@
+onlyBuiltDependencies: []
diff --git a/plugins/replication b/plugins/replication
index d820c37..23d3866 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit d820c3780693fccd9192dd1839c31df9c72f33af
+Subproject commit 23d386651175ca83d214793749852339684d3c2c
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index d94078c..df42ff9 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit d94078c4734085cff50e3b886d9cd1aa052a7ae4
+Subproject commit df42ff9f17c98e99a534c4cfaa21f66baf19f7cf
diff --git a/plugins/rollup.config.js b/plugins/rollup.config.js
index 4ab9907..ae42721 100644
--- a/plugins/rollup.config.js
+++ b/plugins/rollup.config.js
@@ -27,18 +27,19 @@
 // so require(plugin_name) can't find a plugin.
 // To fix it, requirePlugin tries:
 // 1. resolve module id using default behavior, i.e. it starts from __dirname
-// 2. if module not found - it tries to resolve module starting from rollupBin
-//    location.
+// 2. if module not found - it tries to resolve module starting from
+//    tools/node_tools/node_modules
 // This workaround also gives us additional power - we can place .config.js
 // file anywhere in a source tree and add all plugins in the same package.json
 // file as rollup node module.
 function requirePlugin(id) {
-  const rollupBinDir = path.dirname(process.argv[1]);
-  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  const nodeToolsModulesDir = path.join(__dirname, '../tools/node_tools/node_modules');
+  const pluginPath = require.resolve(id, {paths: [__dirname, nodeToolsModulesDir] });
   return require(pluginPath);
 }
 
 const {nodeResolve} = requirePlugin('@rollup/plugin-node-resolve');
+const terser = requirePlugin('@rollup/plugin-terser');
 
 export default {
   treeshake: false,
@@ -51,7 +52,14 @@
   },
   // Context must be set to window to correctly process global variables
   context: 'window',
-  plugins: [nodeResolve({
-    modulePaths: [path.join(process.cwd(), 'external/plugins_npm/node_modules')]
-  })],
+  plugins: [
+    nodeResolve({
+      modulePaths: [path.join(process.cwd(), 'external/plugins_npm/node_modules')]
+    }),
+    terser({
+      format: {
+        comments: false,
+      },
+    }),
+  ],
 };
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 4bee62c..a2575da 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 4bee62cbbc21979b841843dd5faaf79470a35966
+Subproject commit a2575da832e41387947554d415de9f6de97c841d
diff --git a/plugins/tsconfig-plugins-base.json b/plugins/tsconfig-plugins-base.json
index 605c69a..b41f410 100644
--- a/plugins/tsconfig-plugins-base.json
+++ b/plugins/tsconfig-plugins-base.json
@@ -26,9 +26,9 @@
     "skipLibCheck": true, /* Do not check node_modules */
 
     /* Module Resolution Options */
-    "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+    "moduleResolution": "bundler",
     "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
-    "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+    "preserveSymlinks": false, /* Do not resolve the real path of symlinks. */
 
     /* Advanced Options */
     "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
diff --git a/plugins/webhooks b/plugins/webhooks
index 13f28d1..8e9a401 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 13f28d1927673b48e75b2eacd248f5d303bbb82f
+Subproject commit 8e9a4015f1f3f1d7326d3ce015f9e8424ec8121f
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 4c5b62b..9a504e0 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -16,23 +16,23 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
   integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
 
-"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.20.1", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1":
-  version "6.20.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz#4cfbc8b2e1e25f890ec34a081037e58b4e44143e"
-  integrity sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.20.3", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1":
+  version "6.20.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz#696b740312c6a962e14567b49a3661b5924bc5ae"
+  integrity sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.17.0"
     "@lezer/common" "^1.0.0"
 
-"@codemirror/commands@^6.10.3":
-  version "6.10.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.10.3.tgz#01877060befdec352e8300dec1f185489c300635"
-  integrity sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==
+"@codemirror/commands@^6.11.0":
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.11.0.tgz#2194d6fcad9ed787dcc42667db0e0543fab2e0ef"
+  integrity sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==
   dependencies:
     "@codemirror/language" "^6.0.0"
-    "@codemirror/state" "^6.6.0"
+    "@codemirror/state" "^6.7.0"
     "@codemirror/view" "^6.27.0"
     "@lezer/common" "^1.1.0"
 
@@ -78,10 +78,10 @@
     "@lezer/common" "^1.0.0"
     "@lezer/go" "^1.0.0"
 
-"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.11":
-  version "6.4.11"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz#c46ba46ae642fd567cf05c4129005d2913ac248d"
-  integrity sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.12":
+  version "6.4.12"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.12.tgz#ca5dc0f741c1e819182bce9d03b073552172b1b7"
+  integrity sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/lang-css" "^6.0.0"
@@ -114,13 +114,16 @@
     "@lezer/common" "^1.0.0"
     "@lezer/javascript" "^1.0.0"
 
-"@codemirror/lang-jinja@^6.0.0":
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-jinja/-/lang-jinja-6.0.0.tgz#cc02cd1e45d1fed1226e3c3b44615503f794c904"
-  integrity sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==
+"@codemirror/lang-jinja@^6.0.0", "@codemirror/lang-jinja@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz#01d128a7e0756b2714cd453ad16c1dffb20188f0"
+  integrity sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==
   dependencies:
+    "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/language" "^6.0.0"
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.2.0"
     "@lezer/lr" "^1.4.0"
@@ -158,10 +161,10 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.3.1"
 
-"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.5.0":
-  version "6.5.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz#29df87310a555b007beba8e12893363956a26e8e"
-  integrity sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==
+"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.5.2":
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz#530b91442c035ca5d4ea5135c0499b2c15c39872"
+  integrity sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==
   dependencies:
     "@codemirror/autocomplete" "^6.7.1"
     "@codemirror/lang-html" "^6.0.0"
@@ -258,10 +261,10 @@
     "@lezer/common" "^1.0.0"
     "@lezer/xml" "^1.0.0"
 
-"@codemirror/lang-yaml@^6.0.0", "@codemirror/lang-yaml@^6.1.2":
-  version "6.1.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz#c84280c68fa7af456a355d91183b5e537e9b7038"
-  integrity sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==
+"@codemirror/lang-yaml@^6.0.0", "@codemirror/lang-yaml@^6.1.3":
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz#4d4127e8339984639715d1e3f8edca1ea5bfabfb"
+  integrity sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.0.0"
@@ -300,10 +303,10 @@
     "@codemirror/language" "^6.0.0"
     "@codemirror/legacy-modes" "^6.4.0"
 
-"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.2", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0":
-  version "6.12.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.2.tgz#7db5a46757411cf251e8f450474c05710c27d42c"
-  integrity sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.4", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0":
+  version "6.12.4"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.4.tgz#01e70fd5aa3a8a067ff1dfec75d5b6394cdfa058"
+  integrity sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.23.0"
@@ -312,44 +315,44 @@
     "@lezer/lr" "^1.0.0"
     style-mod "^4.0.0"
 
-"@codemirror/legacy-modes@^6.4.0", "@codemirror/legacy-modes@^6.5.2":
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz#7e2976c79007cd3fa9ed8a1d690892184a7f5ecf"
-  integrity sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==
+"@codemirror/legacy-modes@^6.4.0", "@codemirror/legacy-modes@^6.5.3":
+  version "6.5.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz#5dcac7cdc430d32ad8af1c2c9bce392fad1b5fcb"
+  integrity sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==
   dependencies:
     "@codemirror/language" "^6.0.0"
 
-"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.9.5":
-  version "6.9.5"
-  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.5.tgz#c7da006f3335a33014799a7375c82df558e89f90"
-  integrity sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.9.7":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.7.tgz#841fc733674389d91fe49a1c34027ad3babdf105"
+  integrity sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==
   dependencies:
     "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.35.0"
+    "@codemirror/view" "^6.42.0"
     crelt "^1.0.5"
 
-"@codemirror/search@^6.6.0":
-  version "6.6.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.6.0.tgz#3b83a1e35391e1575a83a3b485e3f95263ddaa0b"
-  integrity sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==
+"@codemirror/search@^6.7.1":
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.7.1.tgz#2523a762871d18ad982edc2d4b2fa1da483b0392"
+  integrity sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.37.0"
     crelt "^1.0.5"
 
-"@codemirror/state@^6.0.0", "@codemirror/state@^6.6.0":
-  version "6.6.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.6.0.tgz#b88dbdc14aea4ace3c6d67bb77fe28bb84e4394e"
-  integrity sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.7.0", "@codemirror/state@^6.7.1":
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.7.1.tgz#9e88a17448c1dbc7b50acbeeec979ed7ccf1d6fc"
+  integrity sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==
   dependencies:
     "@marijn/find-cluster-break" "^1.0.0"
 
-"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.40.0":
-  version "6.40.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.40.0.tgz#97198fd717ebf471ef594a5bd557a9f2d1d4d165"
-  integrity sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.42.0", "@codemirror/view@^6.43.9":
+  version "6.43.9"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.9.tgz#85c44ad1bc5fc930e5642e7313643dab7dc866a4"
+  integrity sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==
   dependencies:
-    "@codemirror/state" "^6.6.0"
+    "@codemirror/state" "^6.7.0"
     crelt "^1.0.6"
     style-mod "^4.1.0"
     w3c-keyname "^2.2.4"
@@ -491,10 +494,10 @@
   dependencies:
     "@types/chai" "^4.2.12"
 
-"@gerritcodereview/typescript-api@3.13.0":
-  version "3.13.0"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.13.0.tgz#c7f5ac20f8b5b575424508eb1deb260a290ca0f1"
-  integrity sha512-1TvqARSNO9SXnnCzJKZVz9SFTr4hx1ZzBR0jyRZM6Xd4lLw3YwCfXozD9E8Pm4pfBWmN9WDkURboEG85p7ICag==
+"@gerritcodereview/typescript-api@3.14.0":
+  version "3.14.0"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.14.0.tgz#0ba1a06b68593e84e5a7525baf91521fdbcb2920"
+  integrity sha512-GhYzh6h/bHUz2cU1pzHwlfWDeGSkGAlBulfvrUyG4N3tuA/jqUp5LSBDeeJHzXgmOSdNE7Qyravjh3wDd0REyg==
 
 "@hapi/bourne@^3.0.0":
   version "3.0.0"
@@ -520,23 +523,23 @@
     "@jridgewell/sourcemap-codec" "^1.4.14"
 
 "@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.2.1", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0":
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.1.tgz#6e8c114ff5d36a41148e146a253734d3bb8807d3"
-  integrity sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.2.tgz#d6840db13779e3f1b42e70c9a97c4086d12fae22"
+  integrity sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==
 
 "@lezer/cpp@^1.0.0":
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.5.tgz#de5b0352b4e0825b5cb62334f6a69f8ddc6ec734"
-  integrity sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.6.tgz#4408c66f0ce4fb47759a3b83dbfdd780a49315aa"
+  integrity sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/css@^1.1.0", "@lezer/css@^1.1.7":
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.1.tgz#583e0119768021c58a731d38e56a91c700b57e14"
-  integrity sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==
+  version "1.3.6"
+  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.6.tgz#2cdae5b532beaa5cf1e7dccb918d8b190b6c6d14"
+  integrity sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
@@ -595,16 +598,16 @@
     "@lezer/lr" "^1.0.0"
 
 "@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1", "@lezer/lr@^1.3.3", "@lezer/lr@^1.4.0":
-  version "1.4.8"
-  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.8.tgz#333de9bc9346057323ff09beb4cda47ccc38a498"
-  integrity sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==
+  version "1.4.10"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.10.tgz#b3acc36e5ad049b74ddb7719594e7e74d9161ff5"
+  integrity sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==
   dependencies:
     "@lezer/common" "^1.0.0"
 
 "@lezer/markdown@^1.0.0":
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.6.3.tgz#04beb444f656c2319ddf23554b1e4b0edf536071"
-  integrity sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==
+  version "1.7.2"
+  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.7.2.tgz#dfe0249813dc8faa60b4659a4ca2b8da6dcaf753"
+  integrity sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==
   dependencies:
     "@lezer/common" "^1.5.0"
     "@lezer/highlight" "^1.0.0"
@@ -619,9 +622,9 @@
     "@lezer/lr" "^1.1.0"
 
 "@lezer/python@^1.1.4":
-  version "1.1.18"
-  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.18.tgz#fa02fbf492741c82dc2dc98a0a042bd0d4d7f1d3"
-  integrity sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==
+  version "1.1.19"
+  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.19.tgz#7843d44ff27c980439a82e87c18b28172e85c478"
+  integrity sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==
   dependencies:
     "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
@@ -691,9 +694,9 @@
     tar "^6.1.11"
 
 "@marijn/find-cluster-break@^1.0.0":
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"
-  integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz#42c2aea61cda307cdb1347444792452d7b5dbfb4"
+  integrity sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==
 
 "@material/web@^2.4.1":
   version "2.4.1"
@@ -771,27 +774,6 @@
     "@types/sinon-chai" "^3.2.3"
     chai-a11y-axe "^1.5.0"
 
-"@polymer/decorators@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
-  integrity sha512-qh+VID9nDV9q3ABvIfWgm7/+udl7v2HKsMLPXFm8tj1fI7qr7yWJMFwS3xWBkMmuNPtmkS8MDP0vqLAQIEOWzg==
-  dependencies:
-    "@polymer/polymer" "^3.0.5"
-
-"@polymer/polymer@3.5.1":
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.1.tgz#4b5234e43b8876441022bcb91313ab3c4a29f0c8"
-  integrity sha512-JlAHuy+1qIC6hL1ojEUfIVD58fzTpJAoCxFwV5yr0mYTXV1H8bz5zy0+rC963Cgr9iNXQ4T9ncSjC2fkF9BQfw==
-  dependencies:
-    "@webcomponents/shadycss" "^1.9.1"
-
-"@polymer/polymer@^3.0.5":
-  version "3.5.2"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.2.tgz#af0e7e13976df53ace6728e841121c36aca351de"
-  integrity sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==
-  dependencies:
-    "@webcomponents/shadycss" "^1.9.1"
-
 "@puppeteer/browsers@2.10.7":
   version "2.10.7"
   resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.10.7.tgz#a28f622b2da0ee131c9a576d25d8c4e31fc24135"
@@ -1191,7 +1173,7 @@
     "@types/chai" "*"
     "@types/sinon" "*"
 
-"@types/sinon@*":
+"@types/sinon@*", "@types/sinon@^17.0.4":
   version "17.0.4"
   resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.4.tgz#fd9a3e8e07eea1a3f4a6f82a972c899e5778f369"
   integrity sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==
@@ -1399,11 +1381,6 @@
     portfinder "^1.0.32"
     source-map "^0.7.3"
 
-"@webcomponents/shadycss@^1.9.1":
-  version "1.11.2"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.2.tgz#7539b0ad29598aa2eafee8b341059e20ac9e1006"
-  integrity sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==
-
 abbrev@1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@@ -1771,9 +1748,9 @@
     keygrip "~1.1.0"
 
 crelt@^1.0.5, crelt@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
-  integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.7.tgz#3b441b2ddfa73161d6a2770aa4cd677f895eaf28"
+  integrity sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==
 
 cross-spawn@^7.0.3:
   version "7.0.6"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..aeae959
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,5381 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+overrides:
+  eslint: ^9.39.1
+  '@typescript-eslint/eslint-plugin': ^8.49.0
+  '@typescript-eslint/parser': ^8.49.0
+  typescript: 5.8.2
+  vscode-css-languageservice: ^6.3.9
+  vscode-html-languageservice: ^5.6.1
+
+importers:
+
+  .:
+    dependencies:
+      '@typescript-eslint/parser':
+        specifier: ^8.49.0
+        version: 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+    devDependencies:
+      '@eslint/eslintrc':
+        specifier: ^3.3.1
+        version: 3.3.3
+      '@eslint/js':
+        specifier: ^9.26.0
+        version: 9.39.1
+      '@koa/cors':
+        specifier: ^5.0.0
+        version: 5.0.0
+      '@types/page':
+        specifier: ^1.11.9
+        version: 1.11.9
+      '@typescript-eslint/eslint-plugin':
+        specifier: ^8.49.0
+        version: 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2))(eslint@9.39.1)(typescript@5.8.2)
+      '@web/dev-server':
+        specifier: ^0.4.6
+        version: 0.4.6
+      '@web/dev-server-esbuild':
+        specifier: ^1.0.4
+        version: 1.0.4
+      eslint:
+        specifier: ^9.39.1
+        version: 9.39.1
+      eslint-config-google:
+        specifier: ^0.14.0
+        version: 0.14.0(eslint@9.39.1)
+      eslint-plugin-es-x:
+        specifier: 8.0.0
+        version: 8.0.0(eslint@9.39.1)
+      eslint-plugin-html:
+        specifier: ^8.1.3
+        version: 8.1.3
+      eslint-plugin-import:
+        specifier: ^2.31.0
+        version: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2))(eslint@9.39.1)
+      eslint-plugin-jsdoc:
+        specifier: ^50.6.11
+        version: 50.8.0(eslint@9.39.1)
+      eslint-plugin-lit:
+        specifier: ^1.15.0
+        version: 1.15.0(eslint@9.39.1)
+      eslint-plugin-n:
+        specifier: ^17.17.0
+        version: 17.23.1(eslint@9.39.1)(typescript@5.8.2)
+      eslint-plugin-prettier:
+        specifier: ^5.4.0
+        version: 5.5.4(eslint-config-prettier@9.1.0(eslint@9.39.1))(eslint@9.39.1)(prettier@2.8.8)
+      eslint-plugin-regex:
+        specifier: ^1.10.0
+        version: 1.10.0(eslint@9.39.1)
+      gts:
+        specifier: ^6.0.2
+        version: 6.0.2(typescript@5.8.2)
+      lit-analyzer:
+        specifier: ^2.0.3
+        version: 2.0.3
+      npm-run-all:
+        specifier: ^4.1.5
+        version: 4.1.5
+      prettier:
+        specifier: ^2.8.8
+        version: 2.8.8
+      rollup:
+        specifier: ^4.49.0
+        version: 4.53.3
+      terser:
+        specifier: ~5.39.0
+        version: 5.39.2
+      ts-lit-plugin:
+        specifier: ^2.0.2
+        version: 2.0.2
+      typescript:
+        specifier: 5.8.2
+        version: 5.8.2
+
+packages:
+
+  '@babel/code-frame@7.27.1':
+    resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.27.1':
+    resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/runtime@7.28.3':
+    resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
+    engines: {node: '>=6.9.0'}
+
+  '@es-joy/jsdoccomment@0.50.2':
+    resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==}
+    engines: {node: '>=18'}
+
+  '@esbuild/aix-ppc64@0.25.9':
+    resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.25.9':
+    resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.25.9':
+    resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.25.9':
+    resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.25.9':
+    resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.25.9':
+    resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.25.9':
+    resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.25.9':
+    resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.25.9':
+    resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.25.9':
+    resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.25.9':
+    resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.25.9':
+    resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.25.9':
+    resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.25.9':
+    resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.25.9':
+    resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.25.9':
+    resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.25.9':
+    resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.25.9':
+    resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.9':
+    resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.25.9':
+    resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.9':
+    resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.25.9':
+    resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.25.9':
+    resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.25.9':
+    resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.25.9':
+    resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.25.9':
+    resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@eslint-community/eslint-utils@4.9.0':
+    resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  '@eslint-community/regexpp@4.12.2':
+    resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+    engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+  '@eslint/config-array@0.21.1':
+    resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/config-helpers@0.4.2':
+    resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/core@0.17.0':
+    resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/eslintrc@3.3.3':
+    resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/js@9.39.1':
+    resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/object-schema@2.1.7':
+    resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@eslint/plugin-kit@0.4.1':
+    resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@humanfs/core@0.19.1':
+    resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+    engines: {node: '>=18.18.0'}
+
+  '@humanfs/node@0.16.7':
+    resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
+    engines: {node: '>=18.18.0'}
+
+  '@humanwhocodes/module-importer@1.0.1':
+    resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+    engines: {node: '>=12.22'}
+
+  '@humanwhocodes/retry@0.4.3':
+    resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+    engines: {node: '>=18.18'}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/source-map@0.3.11':
+    resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
+
+  '@jridgewell/sourcemap-codec@1.5.5':
+    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+  '@jridgewell/trace-mapping@0.3.30':
+    resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+
+  '@koa/cors@5.0.0':
+    resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==}
+    engines: {node: '>= 14.0.0'}
+
+  '@mdn/browser-compat-data@4.2.1':
+    resolution: {integrity: sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==}
+
+  '@nodelib/fs.scandir@2.1.5':
+    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.stat@2.0.5':
+    resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.walk@1.2.8':
+    resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+    engines: {node: '>= 8'}
+
+  '@pkgr/core@0.1.2':
+    resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
+    engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+
+  '@pkgr/core@0.2.9':
+    resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
+    engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+
+  '@rollup/plugin-node-resolve@15.3.1':
+    resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^2.78.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/pluginutils@5.2.0':
+    resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/rollup-android-arm-eabi@4.53.3':
+    resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.53.3':
+    resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.53.3':
+    resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.53.3':
+    resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.53.3':
+    resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.53.3':
+    resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+    resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+    resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.53.3':
+    resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.53.3':
+    resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loong64-gnu@4.53.3':
+    resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+    resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+    resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.53.3':
+    resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.53.3':
+    resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.53.3':
+    resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.53.3':
+    resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-openharmony-arm64@4.53.3':
+    resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@rollup/rollup-win32-arm64-msvc@4.53.3':
+    resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.53.3':
+    resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-gnu@4.53.3':
+    resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.53.3':
+    resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==}
+    cpu: [x64]
+    os: [win32]
+
+  '@rtsao/scc@1.1.0':
+    resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+
+  '@types/accepts@1.3.7':
+    resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
+
+  '@types/body-parser@1.19.6':
+    resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+  '@types/command-line-args@5.2.3':
+    resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
+
+  '@types/connect@3.4.38':
+    resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+  '@types/content-disposition@0.5.9':
+    resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==}
+
+  '@types/cookies@0.9.1':
+    resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==}
+
+  '@types/estree@1.0.8':
+    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+  '@types/express-serve-static-core@5.0.7':
+    resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==}
+
+  '@types/express@5.0.3':
+    resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
+
+  '@types/http-assert@1.5.6':
+    resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
+
+  '@types/http-errors@2.0.5':
+    resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
+  '@types/json-schema@7.0.15':
+    resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+  '@types/json5@0.0.29':
+    resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+
+  '@types/keygrip@1.0.6':
+    resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==}
+
+  '@types/koa-compose@3.2.8':
+    resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==}
+
+  '@types/koa@2.15.0':
+    resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==}
+
+  '@types/koa@3.0.0':
+    resolution: {integrity: sha512-MOcVYdVYmkSutVHZZPh8j3+dAjLyR5Tl59CN0eKgpkE1h/LBSmPAsQQuWs+bKu7WtGNn+hKfJH9Gzml+PulmDg==}
+
+  '@types/mime@1.3.5':
+    resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+
+  '@types/minimist@1.2.5':
+    resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==}
+
+  '@types/node@24.3.0':
+    resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
+
+  '@types/normalize-package-data@2.4.4':
+    resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
+
+  '@types/page@1.11.9':
+    resolution: {integrity: sha512-Ki8IZMwg63i7+tF3UpfDIl4rwBN1B1kWQjZCUzaWoohfMB0m9CYap/dExbz7W21uS2WPoA/8lvlDuwX0X/YfIQ==}
+
+  '@types/parse5@6.0.3':
+    resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
+
+  '@types/qs@6.14.0':
+    resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
+
+  '@types/range-parser@1.2.7':
+    resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
+  '@types/resolve@1.20.2':
+    resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+
+  '@types/send@0.17.5':
+    resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
+
+  '@types/serve-static@1.15.8':
+    resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
+
+  '@types/ws@7.4.7':
+    resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==}
+
+  '@typescript-eslint/eslint-plugin@8.49.0':
+    resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      '@typescript-eslint/parser': ^8.49.0
+      eslint: ^9.39.1
+      typescript: 5.8.2
+
+  '@typescript-eslint/parser@8.49.0':
+    resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^9.39.1
+      typescript: 5.8.2
+
+  '@typescript-eslint/project-service@8.49.0':
+    resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: 5.8.2
+
+  '@typescript-eslint/scope-manager@8.49.0':
+    resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@typescript-eslint/tsconfig-utils@8.49.0':
+    resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: 5.8.2
+
+  '@typescript-eslint/type-utils@8.49.0':
+    resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^9.39.1
+      typescript: 5.8.2
+
+  '@typescript-eslint/types@8.49.0':
+    resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@typescript-eslint/typescript-estree@8.49.0':
+    resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      typescript: 5.8.2
+
+  '@typescript-eslint/utils@8.49.0':
+    resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^9.39.1
+      typescript: 5.8.2
+
+  '@typescript-eslint/visitor-keys@8.49.0':
+    resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  '@vscode/l10n@0.0.18':
+    resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==}
+
+  '@vscode/web-custom-data@0.4.13':
+    resolution: {integrity: sha512-2ZUIRfhofZ/npLlf872EBnPmn27Kt4M2UssmQIfnJvgGgMYZJ5fvtHEDnttBBf2hnVtBgNCqZMVHJA+wsFVqTA==}
+
+  '@web/config-loader@0.3.3':
+    resolution: {integrity: sha512-ilzeQzrPpPLWZhzFCV+4doxKDGm7oKVfdKpW9wiUNVgive34NSzCw+WzXTvjE4Jgr5CkyTDIObEmMrqQEjhT0g==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-core@0.7.5':
+    resolution: {integrity: sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-esbuild@1.0.4':
+    resolution: {integrity: sha512-ia1LxBwwRiQBYhJ7/RtLenHyPjzle3SvTw3jOZaeGv8UGXVPOkQV8fR05caOtW/DPPZaZovNAybzRKVnNiYIZg==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-rollup@0.6.4':
+    resolution: {integrity: sha512-sJZfTGCCrdku5xYnQQG51odGI092hKY9YFM0X3Z0tRY3iXKXcYRaLZrErw5KfCxr6g0JRuhe4BBhqXTA5Q2I3Q==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server@0.4.6':
+    resolution: {integrity: sha512-jj/1bcElAy5EZet8m2CcUdzxT+CRvUjIXGh8Lt7vxtthkN9PzY9wlhWx/9WOs5iwlnG1oj0VGo6f/zvbPO0s9w==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  '@web/parse5-utils@2.1.0':
+    resolution: {integrity: sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==}
+    engines: {node: '>=18.0.0'}
+
+  accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+
+  acorn-jsx@5.3.2:
+    resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+    peerDependencies:
+      acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+  acorn@8.15.0:
+    resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+    engines: {node: '>=0.4.0'}
+    hasBin: true
+
+  ajv@6.12.6:
+    resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+  ansi-escapes@4.3.2:
+    resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
+    engines: {node: '>=8'}
+
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
+  ansi-styles@3.2.1:
+    resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
+    engines: {node: '>=4'}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  are-docs-informative@0.0.2:
+    resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
+    engines: {node: '>=14'}
+
+  argparse@2.0.1:
+    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+  array-back@3.1.0:
+    resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
+    engines: {node: '>=6'}
+
+  array-back@6.2.2:
+    resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==}
+    engines: {node: '>=12.17'}
+
+  array-buffer-byte-length@1.0.2:
+    resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+    engines: {node: '>= 0.4'}
+
+  array-includes@3.1.9:
+    resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
+    engines: {node: '>= 0.4'}
+
+  array.prototype.findlastindex@1.2.6:
+    resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
+    engines: {node: '>= 0.4'}
+
+  array.prototype.flat@1.3.3:
+    resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+    engines: {node: '>= 0.4'}
+
+  array.prototype.flatmap@1.3.3:
+    resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+    engines: {node: '>= 0.4'}
+
+  arraybuffer.prototype.slice@1.0.4:
+    resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+    engines: {node: '>= 0.4'}
+
+  arrify@1.0.1:
+    resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==}
+    engines: {node: '>=0.10.0'}
+
+  async-function@1.0.0:
+    resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+    engines: {node: '>= 0.4'}
+
+  async@3.2.6:
+    resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+
+  available-typed-arrays@1.0.7:
+    resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+    engines: {node: '>= 0.4'}
+
+  balanced-match@1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+  brace-expansion@1.1.12:
+    resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+  brace-expansion@2.0.2:
+    resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+  braces@3.0.3:
+    resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+    engines: {node: '>=8'}
+
+  buffer-from@1.1.2:
+    resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
+  builtins@5.1.0:
+    resolution: {integrity: sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==}
+
+  cache-content-type@1.0.1:
+    resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==}
+    engines: {node: '>= 6.0.0'}
+
+  call-bind-apply-helpers@1.0.2:
+    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+    engines: {node: '>= 0.4'}
+
+  call-bind@1.0.8:
+    resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
+    engines: {node: '>= 0.4'}
+
+  call-bound@1.0.4:
+    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+    engines: {node: '>= 0.4'}
+
+  callsites@3.1.0:
+    resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+    engines: {node: '>=6'}
+
+  camelcase-keys@6.2.2:
+    resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==}
+    engines: {node: '>=8'}
+
+  camelcase@5.3.1:
+    resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+    engines: {node: '>=6'}
+
+  camelcase@6.3.0:
+    resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
+    engines: {node: '>=10'}
+
+  chalk-template@0.4.0:
+    resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
+    engines: {node: '>=12'}
+
+  chalk@2.4.2:
+    resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
+    engines: {node: '>=4'}
+
+  chalk@4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+
+  chardet@0.7.0:
+    resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
+
+  chokidar@4.0.3:
+    resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+    engines: {node: '>= 14.16.0'}
+
+  cli-cursor@3.1.0:
+    resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
+    engines: {node: '>=8'}
+
+  cli-width@3.0.0:
+    resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
+    engines: {node: '>= 10'}
+
+  cliui@8.0.1:
+    resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+    engines: {node: '>=12'}
+
+  clone@2.1.2:
+    resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
+    engines: {node: '>=0.8'}
+
+  co@4.6.0:
+    resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
+    engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
+
+  color-convert@1.9.3:
+    resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.3:
+    resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  command-line-args@5.2.1:
+    resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
+    engines: {node: '>=4.0.0'}
+
+  command-line-usage@7.0.3:
+    resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
+    engines: {node: '>=12.20.0'}
+
+  commander@2.20.3:
+    resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
+
+  comment-parser@1.4.1:
+    resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
+    engines: {node: '>= 12.0.0'}
+
+  concat-map@0.0.1:
+    resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+  content-disposition@0.5.4:
+    resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
+    engines: {node: '>= 0.6'}
+
+  content-type@1.0.5:
+    resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+    engines: {node: '>= 0.6'}
+
+  cookies@0.9.1:
+    resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
+    engines: {node: '>= 0.8'}
+
+  cross-spawn@6.0.6:
+    resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==}
+    engines: {node: '>=4.8'}
+
+  cross-spawn@7.0.6:
+    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+    engines: {node: '>= 8'}
+
+  data-view-buffer@1.0.2:
+    resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+    engines: {node: '>= 0.4'}
+
+  data-view-byte-length@1.0.2:
+    resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+    engines: {node: '>= 0.4'}
+
+  data-view-byte-offset@1.0.1:
+    resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+    engines: {node: '>= 0.4'}
+
+  debounce@1.2.1:
+    resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
+
+  debug@3.2.7:
+    resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  decamelize-keys@1.1.1:
+    resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==}
+    engines: {node: '>=0.10.0'}
+
+  decamelize@1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+
+  deep-equal@1.0.1:
+    resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
+
+  deep-is@0.1.4:
+    resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+  deepmerge@4.3.1:
+    resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+    engines: {node: '>=0.10.0'}
+
+  default-gateway@6.0.3:
+    resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==}
+    engines: {node: '>= 10'}
+
+  define-data-property@1.1.4:
+    resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+    engines: {node: '>= 0.4'}
+
+  define-lazy-prop@2.0.0:
+    resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+    engines: {node: '>=8'}
+
+  define-properties@1.2.1:
+    resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+    engines: {node: '>= 0.4'}
+
+  delegates@1.0.0:
+    resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
+
+  depd@1.1.2:
+    resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
+    engines: {node: '>= 0.6'}
+
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
+  destroy@1.2.0:
+    resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
+  didyoumean2@4.1.0:
+    resolution: {integrity: sha512-qTBmfQoXvhKO75D/05C8m+fteQmn4U46FWYiLhXtZQInzitXLWY0EQ/2oKnpAz9g2lQWW8jYcLcT+hPJGT+kig==}
+    engines: {node: '>=10.13'}
+
+  doctrine@2.1.0:
+    resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
+    engines: {node: '>=0.10.0'}
+
+  dom-serializer@2.0.0:
+    resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+  domelementtype@2.3.0:
+    resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+  domhandler@5.0.3:
+    resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+    engines: {node: '>= 4'}
+
+  domutils@3.2.2:
+    resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
+  dunder-proto@1.0.1:
+    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+    engines: {node: '>= 0.4'}
+
+  ee-first@1.1.1:
+    resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+  encodeurl@1.0.2:
+    resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
+    engines: {node: '>= 0.8'}
+
+  enhanced-resolve@5.18.3:
+    resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
+    engines: {node: '>=10.13.0'}
+
+  entities@4.5.0:
+    resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+    engines: {node: '>=0.12'}
+
+  entities@6.0.1:
+    resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+    engines: {node: '>=0.12'}
+
+  error-ex@1.3.2:
+    resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
+
+  es-abstract@1.24.0:
+    resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==}
+    engines: {node: '>= 0.4'}
+
+  es-define-property@1.0.1:
+    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+    engines: {node: '>= 0.4'}
+
+  es-errors@1.3.0:
+    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+    engines: {node: '>= 0.4'}
+
+  es-module-lexer@1.7.0:
+    resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+  es-object-atoms@1.1.1:
+    resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+    engines: {node: '>= 0.4'}
+
+  es-set-tostringtag@2.1.0:
+    resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+    engines: {node: '>= 0.4'}
+
+  es-shim-unscopables@1.1.0:
+    resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+    engines: {node: '>= 0.4'}
+
+  es-to-primitive@1.3.0:
+    resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
+    engines: {node: '>= 0.4'}
+
+  esbuild@0.25.9:
+    resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escalade@3.2.0:
+    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+    engines: {node: '>=6'}
+
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+  escape-string-regexp@1.0.5:
+    resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
+    engines: {node: '>=0.8.0'}
+
+  escape-string-regexp@4.0.0:
+    resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+    engines: {node: '>=10'}
+
+  eslint-compat-utils@0.5.1:
+    resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-config-google@0.14.0:
+    resolution: {integrity: sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==}
+    engines: {node: '>=0.10.0'}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-config-prettier@9.1.0:
+    resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==}
+    hasBin: true
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-import-resolver-node@0.3.9:
+    resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==}
+
+  eslint-module-utils@2.12.1:
+    resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==}
+    engines: {node: '>=4'}
+    peerDependencies:
+      '@typescript-eslint/parser': '*'
+      eslint: '*'
+      eslint-import-resolver-node: '*'
+      eslint-import-resolver-typescript: '*'
+      eslint-import-resolver-webpack: '*'
+    peerDependenciesMeta:
+      '@typescript-eslint/parser':
+        optional: true
+      eslint:
+        optional: true
+      eslint-import-resolver-node:
+        optional: true
+      eslint-import-resolver-typescript:
+        optional: true
+      eslint-import-resolver-webpack:
+        optional: true
+
+  eslint-plugin-es-x@7.8.0:
+    resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-plugin-es-x@8.0.0:
+    resolution: {integrity: sha512-kPIagK5FxZBDwVxZXCsxmZUjU2aYGeTs4/wfAauI2FAThsbeLgred5b+6S0x7Hhx04GPzrB4j0h60bnsyLpzEA==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-plugin-es@4.1.0:
+    resolution: {integrity: sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==}
+    engines: {node: '>=8.10.0'}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-plugin-html@8.1.3:
+    resolution: {integrity: sha512-cnCdO7yb/jrvgSJJAfRkGDOwLu1AOvNdw8WCD6nh/2C4RnxuI4tz6QjMEAmmSiHSeugq/fXcIO8yBpIBQrMZCg==}
+    engines: {node: '>=16.0.0'}
+
+  eslint-plugin-import@2.32.0:
+    resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
+    engines: {node: '>=4'}
+    peerDependencies:
+      '@typescript-eslint/parser': '*'
+      eslint: ^9.39.1
+    peerDependenciesMeta:
+      '@typescript-eslint/parser':
+        optional: true
+
+  eslint-plugin-jsdoc@50.8.0:
+    resolution: {integrity: sha512-UyGb5755LMFWPrZTEqqvTJ3urLz1iqj+bYOHFNag+sw3NvaMWP9K2z+uIn37XfNALmQLQyrBlJ5mkiVPL7ADEg==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-plugin-lit@1.15.0:
+    resolution: {integrity: sha512-Yhr2MYNz6Ln8megKcX503aVZQln8wsywCG49g0heiJ/Qr5UjkE4pGr4Usez2anNcc7NvlvHbQWMYwWcgH3XRKA==}
+    engines: {node: '>= 12'}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-plugin-n@15.7.0:
+    resolution: {integrity: sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==}
+    engines: {node: '>=12.22.0'}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-plugin-n@17.23.1:
+    resolution: {integrity: sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-plugin-prettier@5.2.1:
+    resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      '@types/eslint': '>=8.0.0'
+      eslint: ^9.39.1
+      eslint-config-prettier: '*'
+      prettier: '>=3.0.0'
+    peerDependenciesMeta:
+      '@types/eslint':
+        optional: true
+      eslint-config-prettier:
+        optional: true
+
+  eslint-plugin-prettier@5.5.4:
+    resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      '@types/eslint': '>=8.0.0'
+      eslint: ^9.39.1
+      eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
+      prettier: '>=3.0.0'
+    peerDependenciesMeta:
+      '@types/eslint':
+        optional: true
+      eslint-config-prettier:
+        optional: true
+
+  eslint-plugin-regex@1.10.0:
+    resolution: {integrity: sha512-C8/qYKkkbIb0epxKzaz4aw7oVAOmm19fJpR/moUrUToq/vc4xW4sEKMlTQqH6EtNGpvLjYsbbZRlWNWwQGeTSA==}
+    engines: {node: '>=6.0.0'}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-scope@8.4.0:
+    resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  eslint-utils@2.1.0:
+    resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==}
+    engines: {node: '>=6'}
+
+  eslint-utils@3.0.0:
+    resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==}
+    engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0}
+    peerDependencies:
+      eslint: ^9.39.1
+
+  eslint-visitor-keys@1.3.0:
+    resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==}
+    engines: {node: '>=4'}
+
+  eslint-visitor-keys@2.1.0:
+    resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==}
+    engines: {node: '>=10'}
+
+  eslint-visitor-keys@3.4.3:
+    resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+  eslint-visitor-keys@4.2.1:
+    resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  eslint@9.39.1:
+    resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+    hasBin: true
+    peerDependencies:
+      jiti: '*'
+    peerDependenciesMeta:
+      jiti:
+        optional: true
+
+  espree@10.4.0:
+    resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+    engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+  esquery@1.6.0:
+    resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
+    engines: {node: '>=0.10'}
+
+  esrecurse@4.3.0:
+    resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+    engines: {node: '>=4.0'}
+
+  estraverse@5.3.0:
+    resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+    engines: {node: '>=4.0'}
+
+  estree-walker@2.0.2:
+    resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+  esutils@2.0.3:
+    resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+    engines: {node: '>=0.10.0'}
+
+  etag@1.8.1:
+    resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+    engines: {node: '>= 0.6'}
+
+  execa@5.1.1:
+    resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+    engines: {node: '>=10'}
+
+  external-editor@3.1.0:
+    resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
+    engines: {node: '>=4'}
+
+  fast-deep-equal@3.1.3:
+    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+  fast-diff@1.3.0:
+    resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
+
+  fast-glob@3.3.3:
+    resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+    engines: {node: '>=8.6.0'}
+
+  fast-json-stable-stringify@2.1.0:
+    resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+  fast-levenshtein@2.0.6:
+    resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+  fastq@1.19.1:
+    resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
+
+  fdir@6.5.0:
+    resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+    engines: {node: '>=12.0.0'}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
+  figures@3.2.0:
+    resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
+    engines: {node: '>=8'}
+
+  file-entry-cache@8.0.0:
+    resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+    engines: {node: '>=16.0.0'}
+
+  fill-range@7.1.1:
+    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+    engines: {node: '>=8'}
+
+  find-replace@3.0.0:
+    resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
+    engines: {node: '>=4.0.0'}
+
+  find-up@4.1.0:
+    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+    engines: {node: '>=8'}
+
+  find-up@5.0.0:
+    resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+    engines: {node: '>=10'}
+
+  flat-cache@4.0.1:
+    resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+    engines: {node: '>=16'}
+
+  flatted@3.3.3:
+    resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+
+  for-each@0.3.5:
+    resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+    engines: {node: '>= 0.4'}
+
+  fresh@0.5.2:
+    resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
+    engines: {node: '>= 0.6'}
+
+  fs.realpath@1.0.0:
+    resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  function.prototype.name@1.1.8:
+    resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==}
+    engines: {node: '>= 0.4'}
+
+  functions-have-names@1.2.3:
+    resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
+  get-intrinsic@1.3.0:
+    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+    engines: {node: '>= 0.4'}
+
+  get-proto@1.0.1:
+    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+    engines: {node: '>= 0.4'}
+
+  get-stream@6.0.1:
+    resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+    engines: {node: '>=10'}
+
+  get-symbol-description@1.1.0:
+    resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+    engines: {node: '>= 0.4'}
+
+  get-tsconfig@4.13.0:
+    resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
+
+  glob-parent@5.1.2:
+    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+    engines: {node: '>= 6'}
+
+  glob-parent@6.0.2:
+    resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+    engines: {node: '>=10.13.0'}
+
+  glob@7.2.3:
+    resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+
+  globals@14.0.0:
+    resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+    engines: {node: '>=18'}
+
+  globals@15.15.0:
+    resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
+    engines: {node: '>=18'}
+
+  globalthis@1.0.4:
+    resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+    engines: {node: '>= 0.4'}
+
+  globrex@0.1.2:
+    resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
+
+  gopd@1.2.0:
+    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+    engines: {node: '>= 0.4'}
+
+  graceful-fs@4.2.11:
+    resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+  gts@6.0.2:
+    resolution: {integrity: sha512-lp9+eDzzm6TYqiBpgGY00EInxBHFTJiU5brsVp11qXCJEw7Q6WNNngja0spZeqSFWSquaRuHQUuWxdZLaxnKmw==}
+    engines: {node: '>=18'}
+    hasBin: true
+    peerDependencies:
+      typescript: 5.8.2
+
+  hard-rejection@2.1.0:
+    resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==}
+    engines: {node: '>=6'}
+
+  has-bigints@1.1.0:
+    resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+    engines: {node: '>= 0.4'}
+
+  has-flag@3.0.0:
+    resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
+    engines: {node: '>=4'}
+
+  has-flag@4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+
+  has-property-descriptors@1.0.2:
+    resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+  has-proto@1.2.0:
+    resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+    engines: {node: '>= 0.4'}
+
+  has-symbols@1.1.0:
+    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+    engines: {node: '>= 0.4'}
+
+  has-tostringtag@1.0.2:
+    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+    engines: {node: '>= 0.4'}
+
+  hasown@2.0.2:
+    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+    engines: {node: '>= 0.4'}
+
+  hosted-git-info@2.8.9:
+    resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
+
+  hosted-git-info@4.1.0:
+    resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
+    engines: {node: '>=10'}
+
+  htmlparser2@10.0.0:
+    resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
+
+  http-assert@1.5.0:
+    resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==}
+    engines: {node: '>= 0.8'}
+
+  http-errors@1.6.3:
+    resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==}
+    engines: {node: '>= 0.6'}
+
+  http-errors@1.8.1:
+    resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==}
+    engines: {node: '>= 0.6'}
+
+  human-signals@2.1.0:
+    resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+    engines: {node: '>=10.17.0'}
+
+  iconv-lite@0.4.24:
+    resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
+    engines: {node: '>=0.10.0'}
+
+  ignore@5.3.2:
+    resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+    engines: {node: '>= 4'}
+
+  ignore@7.0.5:
+    resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+    engines: {node: '>= 4'}
+
+  import-fresh@3.3.1:
+    resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+    engines: {node: '>=6'}
+
+  imurmurhash@0.1.4:
+    resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+    engines: {node: '>=0.8.19'}
+
+  indent-string@4.0.0:
+    resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+    engines: {node: '>=8'}
+
+  inflight@1.0.6:
+    resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+    deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
+
+  inherits@2.0.3:
+    resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==}
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+  inquirer@7.3.3:
+    resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==}
+    engines: {node: '>=8.0.0'}
+
+  internal-ip@6.2.0:
+    resolution: {integrity: sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==}
+    engines: {node: '>=10'}
+
+  internal-slot@1.1.0:
+    resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+    engines: {node: '>= 0.4'}
+
+  ip-regex@4.3.0:
+    resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
+    engines: {node: '>=8'}
+
+  ipaddr.js@1.9.1:
+    resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+    engines: {node: '>= 0.10'}
+
+  is-array-buffer@3.0.5:
+    resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+    engines: {node: '>= 0.4'}
+
+  is-arrayish@0.2.1:
+    resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+
+  is-async-function@2.1.1:
+    resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+    engines: {node: '>= 0.4'}
+
+  is-bigint@1.1.0:
+    resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+    engines: {node: '>= 0.4'}
+
+  is-boolean-object@1.2.2:
+    resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+    engines: {node: '>= 0.4'}
+
+  is-callable@1.2.7:
+    resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+    engines: {node: '>= 0.4'}
+
+  is-core-module@2.16.1:
+    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
+    engines: {node: '>= 0.4'}
+
+  is-data-view@1.0.2:
+    resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+    engines: {node: '>= 0.4'}
+
+  is-date-object@1.1.0:
+    resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+    engines: {node: '>= 0.4'}
+
+  is-docker@2.2.1:
+    resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+    engines: {node: '>=8'}
+    hasBin: true
+
+  is-extglob@2.1.1:
+    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+    engines: {node: '>=0.10.0'}
+
+  is-finalizationregistry@1.1.1:
+    resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+    engines: {node: '>= 0.4'}
+
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
+  is-generator-function@1.1.0:
+    resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==}
+    engines: {node: '>= 0.4'}
+
+  is-glob@4.0.3:
+    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+    engines: {node: '>=0.10.0'}
+
+  is-ip@3.1.0:
+    resolution: {integrity: sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==}
+    engines: {node: '>=8'}
+
+  is-map@2.0.3:
+    resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+    engines: {node: '>= 0.4'}
+
+  is-module@1.0.0:
+    resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+
+  is-negative-zero@2.0.3:
+    resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+    engines: {node: '>= 0.4'}
+
+  is-number-object@1.1.1:
+    resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+    engines: {node: '>= 0.4'}
+
+  is-number@7.0.0:
+    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+    engines: {node: '>=0.12.0'}
+
+  is-plain-obj@1.1.0:
+    resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==}
+    engines: {node: '>=0.10.0'}
+
+  is-regex@1.2.1:
+    resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+    engines: {node: '>= 0.4'}
+
+  is-set@2.0.3:
+    resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+    engines: {node: '>= 0.4'}
+
+  is-shared-array-buffer@1.0.4:
+    resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+    engines: {node: '>= 0.4'}
+
+  is-stream@2.0.1:
+    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+    engines: {node: '>=8'}
+
+  is-string@1.1.1:
+    resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+    engines: {node: '>= 0.4'}
+
+  is-symbol@1.1.1:
+    resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+    engines: {node: '>= 0.4'}
+
+  is-typed-array@1.1.15:
+    resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+    engines: {node: '>= 0.4'}
+
+  is-weakmap@2.0.2:
+    resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+    engines: {node: '>= 0.4'}
+
+  is-weakref@1.1.1:
+    resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+    engines: {node: '>= 0.4'}
+
+  is-weakset@2.0.4:
+    resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+    engines: {node: '>= 0.4'}
+
+  is-wsl@2.2.0:
+    resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+    engines: {node: '>=8'}
+
+  isarray@2.0.5:
+    resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+  isbinaryfile@5.0.5:
+    resolution: {integrity: sha512-vh9MWXjhhblwrHlt/yutrubDuBD01kKFscyVndE2/VEeEU5aAizrzuWAsEaCjo997k+IluhN6C4jgxfS69SCIw==}
+    engines: {node: '>= 18.0.0'}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  js-yaml@4.1.1:
+    resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+    hasBin: true
+
+  jsdoc-type-pratt-parser@4.1.0:
+    resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==}
+    engines: {node: '>=12.0.0'}
+
+  json-buffer@3.0.1:
+    resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+  json-parse-better-errors@1.0.2:
+    resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==}
+
+  json-parse-even-better-errors@2.3.1:
+    resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+
+  json-schema-traverse@0.4.1:
+    resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+  json-stable-stringify-without-jsonify@1.0.1:
+    resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+  json5@1.0.2:
+    resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
+    hasBin: true
+
+  json5@2.2.3:
+    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+    engines: {node: '>=6'}
+    hasBin: true
+
+  keygrip@1.1.0:
+    resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
+    engines: {node: '>= 0.6'}
+
+  keyv@4.5.4:
+    resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+  kind-of@6.0.3:
+    resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
+    engines: {node: '>=0.10.0'}
+
+  koa-compose@4.1.0:
+    resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==}
+
+  koa-convert@2.0.0:
+    resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==}
+    engines: {node: '>= 10'}
+
+  koa-etag@4.0.0:
+    resolution: {integrity: sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==}
+
+  koa-send@5.0.1:
+    resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==}
+    engines: {node: '>= 8'}
+
+  koa-static@5.0.0:
+    resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==}
+    engines: {node: '>= 7.6.0'}
+
+  koa@2.16.2:
+    resolution: {integrity: sha512-+CCssgnrWKx9aI3OeZwroa/ckG4JICxvIFnSiOUyl2Uv+UTI+xIw0FfFrWS7cQFpoePpr9o8csss7KzsTzNL8Q==}
+    engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4}
+
+  leven@3.1.0:
+    resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
+    engines: {node: '>=6'}
+
+  levn@0.4.1:
+    resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+    engines: {node: '>= 0.8.0'}
+
+  lines-and-columns@1.2.4:
+    resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+  lit-analyzer@2.0.3:
+    resolution: {integrity: sha512-XiAjnwVipNrKav7r3CSEZpWt+mwYxrhPRVC7h8knDmn/HWTzzWJvPe+mwBcL2brn4xhItAMzZhFC8tzzqHKmiQ==}
+    hasBin: true
+
+  load-json-file@4.0.0:
+    resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
+    engines: {node: '>=4'}
+
+  locate-path@5.0.0:
+    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+    engines: {node: '>=8'}
+
+  locate-path@6.0.0:
+    resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+    engines: {node: '>=10'}
+
+  lodash.camelcase@4.3.0:
+    resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
+
+  lodash.deburr@4.1.0:
+    resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==}
+
+  lodash.merge@4.6.2:
+    resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+  lodash@4.17.21:
+    resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+
+  lru-cache@6.0.0:
+    resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
+    engines: {node: '>=10'}
+
+  lru-cache@8.0.5:
+    resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
+    engines: {node: '>=16.14'}
+
+  map-obj@1.0.1:
+    resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==}
+    engines: {node: '>=0.10.0'}
+
+  map-obj@4.3.0:
+    resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
+    engines: {node: '>=8'}
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  media-typer@0.3.0:
+    resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+    engines: {node: '>= 0.6'}
+
+  memorystream@0.3.1:
+    resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
+    engines: {node: '>= 0.10.0'}
+
+  meow@9.0.0:
+    resolution: {integrity: sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==}
+    engines: {node: '>=10'}
+
+  merge-stream@2.0.0:
+    resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+
+  merge2@1.4.1:
+    resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+    engines: {node: '>= 8'}
+
+  micromatch@4.0.8:
+    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+    engines: {node: '>=8.6'}
+
+  mime-db@1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+
+  mimic-fn@2.1.0:
+    resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+    engines: {node: '>=6'}
+
+  min-indent@1.0.1:
+    resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+    engines: {node: '>=4'}
+
+  minimatch@3.1.2:
+    resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+  minimatch@9.0.5:
+    resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+    engines: {node: '>=16 || 14 >=14.17'}
+
+  minimist-options@4.1.0:
+    resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==}
+    engines: {node: '>= 6'}
+
+  minimist@1.2.8:
+    resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  mute-stream@0.0.8:
+    resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
+
+  nanocolors@0.2.13:
+    resolution: {integrity: sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==}
+
+  natural-compare@1.4.0:
+    resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+  ncp@2.0.0:
+    resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==}
+    hasBin: true
+
+  negotiator@0.6.3:
+    resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+    engines: {node: '>= 0.6'}
+
+  nice-try@1.0.5:
+    resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
+
+  normalize-package-data@2.5.0:
+    resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
+
+  normalize-package-data@3.0.3:
+    resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==}
+    engines: {node: '>=10'}
+
+  npm-run-all@4.1.5:
+    resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==}
+    engines: {node: '>= 4'}
+    hasBin: true
+
+  npm-run-path@4.0.1:
+    resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+    engines: {node: '>=8'}
+
+  object-inspect@1.13.4:
+    resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+    engines: {node: '>= 0.4'}
+
+  object-keys@1.1.1:
+    resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+    engines: {node: '>= 0.4'}
+
+  object.assign@4.1.7:
+    resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+    engines: {node: '>= 0.4'}
+
+  object.fromentries@2.0.8:
+    resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+    engines: {node: '>= 0.4'}
+
+  object.groupby@1.0.3:
+    resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
+    engines: {node: '>= 0.4'}
+
+  object.values@1.2.1:
+    resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+    engines: {node: '>= 0.4'}
+
+  on-finished@2.4.1:
+    resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+    engines: {node: '>= 0.8'}
+
+  once@1.4.0:
+    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+  onetime@5.1.2:
+    resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+    engines: {node: '>=6'}
+
+  only@0.0.2:
+    resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==}
+
+  open@8.4.2:
+    resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
+    engines: {node: '>=12'}
+
+  optionator@0.9.4:
+    resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+    engines: {node: '>= 0.8.0'}
+
+  os-tmpdir@1.0.2:
+    resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
+    engines: {node: '>=0.10.0'}
+
+  own-keys@1.0.1:
+    resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
+    engines: {node: '>= 0.4'}
+
+  p-event@4.2.0:
+    resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
+    engines: {node: '>=8'}
+
+  p-finally@1.0.0:
+    resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
+    engines: {node: '>=4'}
+
+  p-limit@2.3.0:
+    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+    engines: {node: '>=6'}
+
+  p-limit@3.1.0:
+    resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+    engines: {node: '>=10'}
+
+  p-locate@4.1.0:
+    resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+    engines: {node: '>=8'}
+
+  p-locate@5.0.0:
+    resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+    engines: {node: '>=10'}
+
+  p-timeout@3.2.0:
+    resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
+    engines: {node: '>=8'}
+
+  p-try@2.2.0:
+    resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+    engines: {node: '>=6'}
+
+  parent-module@1.0.1:
+    resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+    engines: {node: '>=6'}
+
+  parse-imports-exports@0.2.4:
+    resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==}
+
+  parse-json@4.0.0:
+    resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==}
+    engines: {node: '>=4'}
+
+  parse-json@5.2.0:
+    resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
+    engines: {node: '>=8'}
+
+  parse-statements@1.0.11:
+    resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
+
+  parse5-htmlparser2-tree-adapter@6.0.1:
+    resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==}
+
+  parse5@5.1.0:
+    resolution: {integrity: sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==}
+
+  parse5@6.0.1:
+    resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
+
+  parseurl@1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+
+  path-exists@4.0.0:
+    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+    engines: {node: '>=8'}
+
+  path-is-absolute@1.0.1:
+    resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+    engines: {node: '>=0.10.0'}
+
+  path-key@2.0.1:
+    resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==}
+    engines: {node: '>=4'}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  path-parse@1.0.7:
+    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+  path-type@3.0.0:
+    resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==}
+    engines: {node: '>=4'}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@2.3.1:
+    resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+    engines: {node: '>=8.6'}
+
+  picomatch@4.0.3:
+    resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+    engines: {node: '>=12'}
+
+  pidtree@0.3.1:
+    resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==}
+    engines: {node: '>=0.10'}
+    hasBin: true
+
+  pify@3.0.0:
+    resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==}
+    engines: {node: '>=4'}
+
+  portfinder@1.0.37:
+    resolution: {integrity: sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==}
+    engines: {node: '>= 10.12'}
+
+  possible-typed-array-names@1.1.0:
+    resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+    engines: {node: '>= 0.4'}
+
+  prelude-ls@1.2.1:
+    resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+    engines: {node: '>= 0.8.0'}
+
+  prettier-linter-helpers@1.0.0:
+    resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
+    engines: {node: '>=6.0.0'}
+
+  prettier@2.8.8:
+    resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+
+  prettier@3.3.3:
+    resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==}
+    engines: {node: '>=14'}
+    hasBin: true
+
+  punycode@2.3.1:
+    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+    engines: {node: '>=6'}
+
+  queue-microtask@1.2.3:
+    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+  quick-lru@4.0.1:
+    resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
+    engines: {node: '>=8'}
+
+  read-pkg-up@7.0.1:
+    resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==}
+    engines: {node: '>=8'}
+
+  read-pkg@3.0.0:
+    resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==}
+    engines: {node: '>=4'}
+
+  read-pkg@5.2.0:
+    resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
+    engines: {node: '>=8'}
+
+  readdirp@4.1.2:
+    resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+    engines: {node: '>= 14.18.0'}
+
+  redent@3.0.0:
+    resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+    engines: {node: '>=8'}
+
+  reflect.getprototypeof@1.0.10:
+    resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+    engines: {node: '>= 0.4'}
+
+  regexp.prototype.flags@1.5.4:
+    resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+    engines: {node: '>= 0.4'}
+
+  regexpp@3.2.0:
+    resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==}
+    engines: {node: '>=8'}
+
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  requireindex@1.2.0:
+    resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
+    engines: {node: '>=0.10.5'}
+
+  resolve-from@4.0.0:
+    resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+    engines: {node: '>=4'}
+
+  resolve-path@1.4.0:
+    resolution: {integrity: sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==}
+    engines: {node: '>= 0.8'}
+
+  resolve-pkg-maps@1.0.0:
+    resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+  resolve@1.22.10:
+    resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
+    engines: {node: '>= 0.4'}
+    hasBin: true
+
+  restore-cursor@3.1.0:
+    resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
+    engines: {node: '>=8'}
+
+  reusify@1.1.0:
+    resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+  rimraf@3.0.2:
+    resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
+    deprecated: Rimraf versions prior to v4 are no longer supported
+    hasBin: true
+
+  rollup@4.53.3:
+    resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  run-async@2.4.1:
+    resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
+    engines: {node: '>=0.12.0'}
+
+  run-parallel@1.2.0:
+    resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+  rxjs@6.6.7:
+    resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
+    engines: {npm: '>=2.0.0'}
+
+  safe-array-concat@1.1.3:
+    resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
+    engines: {node: '>=0.4'}
+
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+  safe-push-apply@1.0.0:
+    resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+    engines: {node: '>= 0.4'}
+
+  safe-regex-test@1.1.0:
+    resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+    engines: {node: '>= 0.4'}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  semver@5.7.2:
+    resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
+    hasBin: true
+
+  semver@6.3.1:
+    resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+    hasBin: true
+
+  semver@7.7.3:
+    resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  set-function-length@1.2.2:
+    resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+    engines: {node: '>= 0.4'}
+
+  set-function-name@2.0.2:
+    resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+    engines: {node: '>= 0.4'}
+
+  set-proto@1.0.0:
+    resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+    engines: {node: '>= 0.4'}
+
+  setprototypeof@1.1.0:
+    resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
+
+  setprototypeof@1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+  shebang-command@1.2.0:
+    resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
+    engines: {node: '>=0.10.0'}
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@1.0.0:
+    resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==}
+    engines: {node: '>=0.10.0'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  shell-quote@1.8.3:
+    resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-list@1.0.0:
+    resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-map@1.0.1:
+    resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-weakmap@1.0.2:
+    resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+    engines: {node: '>= 0.4'}
+
+  side-channel@1.1.0:
+    resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+    engines: {node: '>= 0.4'}
+
+  signal-exit@3.0.7:
+    resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
+  source-map-support@0.5.21:
+    resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
+  source-map@0.6.1:
+    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+    engines: {node: '>=0.10.0'}
+
+  spdx-correct@3.2.0:
+    resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
+
+  spdx-exceptions@2.5.0:
+    resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
+
+  spdx-expression-parse@3.0.1:
+    resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
+
+  spdx-expression-parse@4.0.0:
+    resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==}
+
+  spdx-license-ids@3.0.22:
+    resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==}
+
+  statuses@1.5.0:
+    resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+    engines: {node: '>= 0.6'}
+
+  stop-iteration-iterator@1.1.0:
+    resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+    engines: {node: '>= 0.4'}
+
+  string-width@4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+
+  string.prototype.padend@3.1.6:
+    resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==}
+    engines: {node: '>= 0.4'}
+
+  string.prototype.trim@1.2.10:
+    resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
+    engines: {node: '>= 0.4'}
+
+  string.prototype.trimend@1.0.9:
+    resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==}
+    engines: {node: '>= 0.4'}
+
+  string.prototype.trimstart@1.0.8:
+    resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+    engines: {node: '>= 0.4'}
+
+  strip-ansi@6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+
+  strip-bom@3.0.0:
+    resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
+    engines: {node: '>=4'}
+
+  strip-final-newline@2.0.0:
+    resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+    engines: {node: '>=6'}
+
+  strip-indent@3.0.0:
+    resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+    engines: {node: '>=8'}
+
+  strip-json-comments@3.1.1:
+    resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+    engines: {node: '>=8'}
+
+  supports-color@5.5.0:
+    resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
+    engines: {node: '>=4'}
+
+  supports-color@7.2.0:
+    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+    engines: {node: '>=8'}
+
+  supports-preserve-symlinks-flag@1.0.0:
+    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+    engines: {node: '>= 0.4'}
+
+  synckit@0.11.11:
+    resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+
+  synckit@0.9.3:
+    resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+
+  table-layout@4.1.1:
+    resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
+    engines: {node: '>=12.17'}
+
+  tapable@2.3.0:
+    resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+    engines: {node: '>=6'}
+
+  terser@5.39.2:
+    resolution: {integrity: sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  through@2.3.8:
+    resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
+
+  tinyglobby@0.2.15:
+    resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+    engines: {node: '>=12.0.0'}
+
+  tmp@0.0.33:
+    resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
+    engines: {node: '>=0.6.0'}
+
+  to-regex-range@5.0.1:
+    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+    engines: {node: '>=8.0'}
+
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
+  tr46@5.1.1:
+    resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+    engines: {node: '>=18'}
+
+  trim-newlines@3.0.1:
+    resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
+    engines: {node: '>=8'}
+
+  ts-api-utils@2.1.0:
+    resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
+    engines: {node: '>=18.12'}
+    peerDependencies:
+      typescript: 5.8.2
+
+  ts-declaration-location@1.0.7:
+    resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==}
+    peerDependencies:
+      typescript: 5.8.2
+
+  ts-lit-plugin@2.0.2:
+    resolution: {integrity: sha512-DPXlVxhjWHxg8AyBLcfSYt2JXgpANV1ssxxwjY98o26gD8MzeiM68HFW9c2VeDd1CjoR3w7B/6/uKxwBQe+ioA==}
+
+  ts-simple-type@2.0.0-next.0:
+    resolution: {integrity: sha512-A+hLX83gS+yH6DtzNAhzZbPfU+D9D8lHlTSd7GeoMRBjOt3GRylDqLTYbdmjA4biWvq2xSfpqfIDj2l0OA/BVg==}
+
+  tsconfig-paths@3.15.0:
+    resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+
+  tslib@1.14.1:
+    resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+  tsscmp@1.0.6:
+    resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
+    engines: {node: '>=0.6.x'}
+
+  type-check@0.4.0:
+    resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+    engines: {node: '>= 0.8.0'}
+
+  type-fest@0.18.1:
+    resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==}
+    engines: {node: '>=10'}
+
+  type-fest@0.21.3:
+    resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
+    engines: {node: '>=10'}
+
+  type-fest@0.6.0:
+    resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==}
+    engines: {node: '>=8'}
+
+  type-fest@0.8.1:
+    resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
+    engines: {node: '>=8'}
+
+  type-is@1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+
+  typed-array-buffer@1.0.3:
+    resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+    engines: {node: '>= 0.4'}
+
+  typed-array-byte-length@1.0.3:
+    resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+    engines: {node: '>= 0.4'}
+
+  typed-array-byte-offset@1.0.4:
+    resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+    engines: {node: '>= 0.4'}
+
+  typed-array-length@1.0.7:
+    resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
+    engines: {node: '>= 0.4'}
+
+  typescript@5.8.2:
+    resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  typical@4.0.0:
+    resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
+    engines: {node: '>=8'}
+
+  typical@7.3.0:
+    resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
+    engines: {node: '>=12.17'}
+
+  ua-parser-js@1.0.41:
+    resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
+    hasBin: true
+
+  unbox-primitive@1.1.0:
+    resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+    engines: {node: '>= 0.4'}
+
+  undici-types@7.10.0:
+    resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
+
+  uri-js@4.4.1:
+    resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+  validate-npm-package-license@3.0.4:
+    resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
+
+  vary@1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+
+  vscode-css-languageservice@6.3.9:
+    resolution: {integrity: sha512-1tLWfp+TDM5ZuVWht3jmaY5y7O6aZmpeXLoHl5bv1QtRsRKt4xYGRMmdJa5Pqx/FTkgRbsna9R+Gn2xE+evVuA==}
+
+  vscode-html-languageservice@5.6.1:
+    resolution: {integrity: sha512-5Mrqy5CLfFZUgkyhNZLA1Ye5g12Cb/v6VM7SxUzZUaRKWMDz4md+y26PrfRTSU0/eQAl3XpO9m2og+GGtDMuaA==}
+
+  vscode-languageserver-textdocument@1.0.12:
+    resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==}
+
+  vscode-languageserver-types@3.17.5:
+    resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
+
+  vscode-uri@3.1.0:
+    resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+
+  web-component-analyzer@2.0.0:
+    resolution: {integrity: sha512-UEvwfpD+XQw99sLKiH5B1T4QwpwNyWJxp59cnlRwFfhUW6JsQpw5jMeMwi7580sNou8YL3kYoS7BWLm+yJ/jVQ==}
+    hasBin: true
+
+  webidl-conversions@7.0.0:
+    resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+    engines: {node: '>=12'}
+
+  whatwg-url@14.2.0:
+    resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+    engines: {node: '>=18'}
+
+  which-boxed-primitive@1.1.1:
+    resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+    engines: {node: '>= 0.4'}
+
+  which-builtin-type@1.2.1:
+    resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+    engines: {node: '>= 0.4'}
+
+  which-collection@1.0.2:
+    resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+    engines: {node: '>= 0.4'}
+
+  which-typed-array@1.1.19:
+    resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
+    engines: {node: '>= 0.4'}
+
+  which@1.3.1:
+    resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
+    hasBin: true
+
+  which@2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  word-wrap@1.2.5:
+    resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+    engines: {node: '>=0.10.0'}
+
+  wordwrapjs@5.1.0:
+    resolution: {integrity: sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==}
+    engines: {node: '>=12.17'}
+
+  wrap-ansi@7.0.0:
+    resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+    engines: {node: '>=10'}
+
+  wrappy@1.0.2:
+    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+  write-file-atomic@4.0.2:
+    resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
+    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+
+  ws@7.5.10:
+    resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
+    engines: {node: '>=8.3.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: ^5.0.2
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  y18n@5.0.8:
+    resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+    engines: {node: '>=10'}
+
+  yallist@4.0.0:
+    resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
+  yargs-parser@20.2.9:
+    resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
+    engines: {node: '>=10'}
+
+  yargs-parser@21.1.1:
+    resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+    engines: {node: '>=12'}
+
+  yargs@17.7.2:
+    resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+    engines: {node: '>=12'}
+
+  ylru@1.4.0:
+    resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==}
+    engines: {node: '>= 4.0.0'}
+
+  yocto-queue@0.1.0:
+    resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+    engines: {node: '>=10'}
+
+snapshots:
+
+  '@babel/code-frame@7.27.1':
+    dependencies:
+      '@babel/helper-validator-identifier': 7.27.1
+      js-tokens: 4.0.0
+      picocolors: 1.1.1
+
+  '@babel/helper-validator-identifier@7.27.1': {}
+
+  '@babel/runtime@7.28.3': {}
+
+  '@es-joy/jsdoccomment@0.50.2':
+    dependencies:
+      '@types/estree': 1.0.8
+      '@typescript-eslint/types': 8.49.0
+      comment-parser: 1.4.1
+      esquery: 1.6.0
+      jsdoc-type-pratt-parser: 4.1.0
+
+  '@esbuild/aix-ppc64@0.25.9':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/android-arm@0.25.9':
+    optional: true
+
+  '@esbuild/android-x64@0.25.9':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.9':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.9':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.9':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.9':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.9':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.9':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.9':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.9':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.9':
+    optional: true
+
+  '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1)':
+    dependencies:
+      eslint: 9.39.1
+      eslint-visitor-keys: 3.4.3
+
+  '@eslint-community/regexpp@4.12.2': {}
+
+  '@eslint/config-array@0.21.1':
+    dependencies:
+      '@eslint/object-schema': 2.1.7
+      debug: 4.4.3
+      minimatch: 3.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@eslint/config-helpers@0.4.2':
+    dependencies:
+      '@eslint/core': 0.17.0
+
+  '@eslint/core@0.17.0':
+    dependencies:
+      '@types/json-schema': 7.0.15
+
+  '@eslint/eslintrc@3.3.3':
+    dependencies:
+      ajv: 6.12.6
+      debug: 4.4.3
+      espree: 10.4.0
+      globals: 14.0.0
+      ignore: 5.3.2
+      import-fresh: 3.3.1
+      js-yaml: 4.1.1
+      minimatch: 3.1.2
+      strip-json-comments: 3.1.1
+    transitivePeerDependencies:
+      - supports-color
+
+  '@eslint/js@9.39.1': {}
+
+  '@eslint/object-schema@2.1.7': {}
+
+  '@eslint/plugin-kit@0.4.1':
+    dependencies:
+      '@eslint/core': 0.17.0
+      levn: 0.4.1
+
+  '@humanfs/core@0.19.1': {}
+
+  '@humanfs/node@0.16.7':
+    dependencies:
+      '@humanfs/core': 0.19.1
+      '@humanwhocodes/retry': 0.4.3
+
+  '@humanwhocodes/module-importer@1.0.1': {}
+
+  '@humanwhocodes/retry@0.4.3': {}
+
+  '@jridgewell/gen-mapping@0.3.13':
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.5
+      '@jridgewell/trace-mapping': 0.3.30
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/source-map@0.3.11':
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.13
+      '@jridgewell/trace-mapping': 0.3.30
+
+  '@jridgewell/sourcemap-codec@1.5.5': {}
+
+  '@jridgewell/trace-mapping@0.3.30':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.5.5
+
+  '@koa/cors@5.0.0':
+    dependencies:
+      vary: 1.1.2
+
+  '@mdn/browser-compat-data@4.2.1': {}
+
+  '@nodelib/fs.scandir@2.1.5':
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      run-parallel: 1.2.0
+
+  '@nodelib/fs.stat@2.0.5': {}
+
+  '@nodelib/fs.walk@1.2.8':
+    dependencies:
+      '@nodelib/fs.scandir': 2.1.5
+      fastq: 1.19.1
+
+  '@pkgr/core@0.1.2': {}
+
+  '@pkgr/core@0.2.9': {}
+
+  '@rollup/plugin-node-resolve@15.3.1(rollup@4.53.3)':
+    dependencies:
+      '@rollup/pluginutils': 5.2.0(rollup@4.53.3)
+      '@types/resolve': 1.20.2
+      deepmerge: 4.3.1
+      is-module: 1.0.0
+      resolve: 1.22.10
+    optionalDependencies:
+      rollup: 4.53.3
+
+  '@rollup/pluginutils@5.2.0(rollup@4.53.3)':
+    dependencies:
+      '@types/estree': 1.0.8
+      estree-walker: 2.0.2
+      picomatch: 4.0.3
+    optionalDependencies:
+      rollup: 4.53.3
+
+  '@rollup/rollup-android-arm-eabi@4.53.3':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.53.3':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.53.3':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.53.3':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.53.3':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-loong64-gnu@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.53.3':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.53.3':
+    optional: true
+
+  '@rollup/rollup-openharmony-arm64@4.53.3':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.53.3':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.53.3':
+    optional: true
+
+  '@rollup/rollup-win32-x64-gnu@4.53.3':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.53.3':
+    optional: true
+
+  '@rtsao/scc@1.1.0': {}
+
+  '@types/accepts@1.3.7':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/body-parser@1.19.6':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/node': 24.3.0
+
+  '@types/command-line-args@5.2.3': {}
+
+  '@types/connect@3.4.38':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/content-disposition@0.5.9': {}
+
+  '@types/cookies@0.9.1':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/express': 5.0.3
+      '@types/keygrip': 1.0.6
+      '@types/node': 24.3.0
+
+  '@types/estree@1.0.8': {}
+
+  '@types/express-serve-static-core@5.0.7':
+    dependencies:
+      '@types/node': 24.3.0
+      '@types/qs': 6.14.0
+      '@types/range-parser': 1.2.7
+      '@types/send': 0.17.5
+
+  '@types/express@5.0.3':
+    dependencies:
+      '@types/body-parser': 1.19.6
+      '@types/express-serve-static-core': 5.0.7
+      '@types/serve-static': 1.15.8
+
+  '@types/http-assert@1.5.6': {}
+
+  '@types/http-errors@2.0.5': {}
+
+  '@types/json-schema@7.0.15': {}
+
+  '@types/json5@0.0.29': {}
+
+  '@types/keygrip@1.0.6': {}
+
+  '@types/koa-compose@3.2.8':
+    dependencies:
+      '@types/koa': 3.0.0
+
+  '@types/koa@2.15.0':
+    dependencies:
+      '@types/accepts': 1.3.7
+      '@types/content-disposition': 0.5.9
+      '@types/cookies': 0.9.1
+      '@types/http-assert': 1.5.6
+      '@types/http-errors': 2.0.5
+      '@types/keygrip': 1.0.6
+      '@types/koa-compose': 3.2.8
+      '@types/node': 24.3.0
+
+  '@types/koa@3.0.0':
+    dependencies:
+      '@types/accepts': 1.3.7
+      '@types/content-disposition': 0.5.9
+      '@types/cookies': 0.9.1
+      '@types/http-assert': 1.5.6
+      '@types/http-errors': 2.0.5
+      '@types/keygrip': 1.0.6
+      '@types/koa-compose': 3.2.8
+      '@types/node': 24.3.0
+
+  '@types/mime@1.3.5': {}
+
+  '@types/minimist@1.2.5': {}
+
+  '@types/node@24.3.0':
+    dependencies:
+      undici-types: 7.10.0
+
+  '@types/normalize-package-data@2.4.4': {}
+
+  '@types/page@1.11.9': {}
+
+  '@types/parse5@6.0.3': {}
+
+  '@types/qs@6.14.0': {}
+
+  '@types/range-parser@1.2.7': {}
+
+  '@types/resolve@1.20.2': {}
+
+  '@types/send@0.17.5':
+    dependencies:
+      '@types/mime': 1.3.5
+      '@types/node': 24.3.0
+
+  '@types/serve-static@1.15.8':
+    dependencies:
+      '@types/http-errors': 2.0.5
+      '@types/node': 24.3.0
+      '@types/send': 0.17.5
+
+  '@types/ws@7.4.7':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2))(eslint@9.39.1)(typescript@5.8.2)':
+    dependencies:
+      '@eslint-community/regexpp': 4.12.2
+      '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+      '@typescript-eslint/scope-manager': 8.49.0
+      '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+      '@typescript-eslint/utils': 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+      '@typescript-eslint/visitor-keys': 8.49.0
+      eslint: 9.39.1
+      ignore: 7.0.5
+      natural-compare: 1.4.0
+      ts-api-utils: 2.1.0(typescript@5.8.2)
+      typescript: 5.8.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2)':
+    dependencies:
+      '@typescript-eslint/scope-manager': 8.49.0
+      '@typescript-eslint/types': 8.49.0
+      '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.8.2)
+      '@typescript-eslint/visitor-keys': 8.49.0
+      debug: 4.4.3
+      eslint: 9.39.1
+      typescript: 5.8.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/project-service@8.49.0(typescript@5.8.2)':
+    dependencies:
+      '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.8.2)
+      '@typescript-eslint/types': 8.49.0
+      debug: 4.4.3
+      typescript: 5.8.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/scope-manager@8.49.0':
+    dependencies:
+      '@typescript-eslint/types': 8.49.0
+      '@typescript-eslint/visitor-keys': 8.49.0
+
+  '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.8.2)':
+    dependencies:
+      typescript: 5.8.2
+
+  '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1)(typescript@5.8.2)':
+    dependencies:
+      '@typescript-eslint/types': 8.49.0
+      '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.8.2)
+      '@typescript-eslint/utils': 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+      debug: 4.4.3
+      eslint: 9.39.1
+      ts-api-utils: 2.1.0(typescript@5.8.2)
+      typescript: 5.8.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/types@8.49.0': {}
+
+  '@typescript-eslint/typescript-estree@8.49.0(typescript@5.8.2)':
+    dependencies:
+      '@typescript-eslint/project-service': 8.49.0(typescript@5.8.2)
+      '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.8.2)
+      '@typescript-eslint/types': 8.49.0
+      '@typescript-eslint/visitor-keys': 8.49.0
+      debug: 4.4.3
+      minimatch: 9.0.5
+      semver: 7.7.3
+      tinyglobby: 0.2.15
+      ts-api-utils: 2.1.0(typescript@5.8.2)
+      typescript: 5.8.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/utils@8.49.0(eslint@9.39.1)(typescript@5.8.2)':
+    dependencies:
+      '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1)
+      '@typescript-eslint/scope-manager': 8.49.0
+      '@typescript-eslint/types': 8.49.0
+      '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.8.2)
+      eslint: 9.39.1
+      typescript: 5.8.2
+    transitivePeerDependencies:
+      - supports-color
+
+  '@typescript-eslint/visitor-keys@8.49.0':
+    dependencies:
+      '@typescript-eslint/types': 8.49.0
+      eslint-visitor-keys: 4.2.1
+
+  '@vscode/l10n@0.0.18': {}
+
+  '@vscode/web-custom-data@0.4.13': {}
+
+  '@web/config-loader@0.3.3': {}
+
+  '@web/dev-server-core@0.7.5':
+    dependencies:
+      '@types/koa': 2.15.0
+      '@types/ws': 7.4.7
+      '@web/parse5-utils': 2.1.0
+      chokidar: 4.0.3
+      clone: 2.1.2
+      es-module-lexer: 1.7.0
+      get-stream: 6.0.1
+      is-stream: 2.0.1
+      isbinaryfile: 5.0.5
+      koa: 2.16.2
+      koa-etag: 4.0.0
+      koa-send: 5.0.1
+      koa-static: 5.0.0
+      lru-cache: 8.0.5
+      mime-types: 2.1.35
+      parse5: 6.0.1
+      picomatch: 2.3.1
+      ws: 7.5.10
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server-esbuild@1.0.4':
+    dependencies:
+      '@mdn/browser-compat-data': 4.2.1
+      '@web/dev-server-core': 0.7.5
+      esbuild: 0.25.9
+      parse5: 6.0.1
+      ua-parser-js: 1.0.41
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server-rollup@0.6.4':
+    dependencies:
+      '@rollup/plugin-node-resolve': 15.3.1(rollup@4.53.3)
+      '@web/dev-server-core': 0.7.5
+      nanocolors: 0.2.13
+      parse5: 6.0.1
+      rollup: 4.53.3
+      whatwg-url: 14.2.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server@0.4.6':
+    dependencies:
+      '@babel/code-frame': 7.27.1
+      '@types/command-line-args': 5.2.3
+      '@web/config-loader': 0.3.3
+      '@web/dev-server-core': 0.7.5
+      '@web/dev-server-rollup': 0.6.4
+      camelcase: 6.3.0
+      command-line-args: 5.2.1
+      command-line-usage: 7.0.3
+      debounce: 1.2.1
+      deepmerge: 4.3.1
+      internal-ip: 6.2.0
+      nanocolors: 0.2.13
+      open: 8.4.2
+      portfinder: 1.0.37
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/parse5-utils@2.1.0':
+    dependencies:
+      '@types/parse5': 6.0.3
+      parse5: 6.0.1
+
+  accepts@1.3.8:
+    dependencies:
+      mime-types: 2.1.35
+      negotiator: 0.6.3
+
+  acorn-jsx@5.3.2(acorn@8.15.0):
+    dependencies:
+      acorn: 8.15.0
+
+  acorn@8.15.0: {}
+
+  ajv@6.12.6:
+    dependencies:
+      fast-deep-equal: 3.1.3
+      fast-json-stable-stringify: 2.1.0
+      json-schema-traverse: 0.4.1
+      uri-js: 4.4.1
+
+  ansi-escapes@4.3.2:
+    dependencies:
+      type-fest: 0.21.3
+
+  ansi-regex@5.0.1: {}
+
+  ansi-styles@3.2.1:
+    dependencies:
+      color-convert: 1.9.3
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  are-docs-informative@0.0.2: {}
+
+  argparse@2.0.1: {}
+
+  array-back@3.1.0: {}
+
+  array-back@6.2.2: {}
+
+  array-buffer-byte-length@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      is-array-buffer: 3.0.5
+
+  array-includes@3.1.9:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-object-atoms: 1.1.1
+      get-intrinsic: 1.3.0
+      is-string: 1.1.1
+      math-intrinsics: 1.1.0
+
+  array.prototype.findlastindex@1.2.6:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.1
+      es-shim-unscopables: 1.1.0
+
+  array.prototype.flat@1.3.3:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-shim-unscopables: 1.1.0
+
+  array.prototype.flatmap@1.3.3:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-shim-unscopables: 1.1.0
+
+  arraybuffer.prototype.slice@1.0.4:
+    dependencies:
+      array-buffer-byte-length: 1.0.2
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      is-array-buffer: 3.0.5
+
+  arrify@1.0.1: {}
+
+  async-function@1.0.0: {}
+
+  async@3.2.6: {}
+
+  available-typed-arrays@1.0.7:
+    dependencies:
+      possible-typed-array-names: 1.1.0
+
+  balanced-match@1.0.2: {}
+
+  brace-expansion@1.1.12:
+    dependencies:
+      balanced-match: 1.0.2
+      concat-map: 0.0.1
+
+  brace-expansion@2.0.2:
+    dependencies:
+      balanced-match: 1.0.2
+
+  braces@3.0.3:
+    dependencies:
+      fill-range: 7.1.1
+
+  buffer-from@1.1.2: {}
+
+  builtins@5.1.0:
+    dependencies:
+      semver: 7.7.3
+
+  cache-content-type@1.0.1:
+    dependencies:
+      mime-types: 2.1.35
+      ylru: 1.4.0
+
+  call-bind-apply-helpers@1.0.2:
+    dependencies:
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+
+  call-bind@1.0.8:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      get-intrinsic: 1.3.0
+      set-function-length: 1.2.2
+
+  call-bound@1.0.4:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      get-intrinsic: 1.3.0
+
+  callsites@3.1.0: {}
+
+  camelcase-keys@6.2.2:
+    dependencies:
+      camelcase: 5.3.1
+      map-obj: 4.3.0
+      quick-lru: 4.0.1
+
+  camelcase@5.3.1: {}
+
+  camelcase@6.3.0: {}
+
+  chalk-template@0.4.0:
+    dependencies:
+      chalk: 4.1.2
+
+  chalk@2.4.2:
+    dependencies:
+      ansi-styles: 3.2.1
+      escape-string-regexp: 1.0.5
+      supports-color: 5.5.0
+
+  chalk@4.1.2:
+    dependencies:
+      ansi-styles: 4.3.0
+      supports-color: 7.2.0
+
+  chardet@0.7.0: {}
+
+  chokidar@4.0.3:
+    dependencies:
+      readdirp: 4.1.2
+
+  cli-cursor@3.1.0:
+    dependencies:
+      restore-cursor: 3.1.0
+
+  cli-width@3.0.0: {}
+
+  cliui@8.0.1:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 7.0.0
+
+  clone@2.1.2: {}
+
+  co@4.6.0: {}
+
+  color-convert@1.9.3:
+    dependencies:
+      color-name: 1.1.3
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.3: {}
+
+  color-name@1.1.4: {}
+
+  command-line-args@5.2.1:
+    dependencies:
+      array-back: 3.1.0
+      find-replace: 3.0.0
+      lodash.camelcase: 4.3.0
+      typical: 4.0.0
+
+  command-line-usage@7.0.3:
+    dependencies:
+      array-back: 6.2.2
+      chalk-template: 0.4.0
+      table-layout: 4.1.1
+      typical: 7.3.0
+
+  commander@2.20.3: {}
+
+  comment-parser@1.4.1: {}
+
+  concat-map@0.0.1: {}
+
+  content-disposition@0.5.4:
+    dependencies:
+      safe-buffer: 5.2.1
+
+  content-type@1.0.5: {}
+
+  cookies@0.9.1:
+    dependencies:
+      depd: 2.0.0
+      keygrip: 1.1.0
+
+  cross-spawn@6.0.6:
+    dependencies:
+      nice-try: 1.0.5
+      path-key: 2.0.1
+      semver: 5.7.2
+      shebang-command: 1.2.0
+      which: 1.3.1
+
+  cross-spawn@7.0.6:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+
+  data-view-buffer@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-data-view: 1.0.2
+
+  data-view-byte-length@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-data-view: 1.0.2
+
+  data-view-byte-offset@1.0.1:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-data-view: 1.0.2
+
+  debounce@1.2.1: {}
+
+  debug@3.2.7:
+    dependencies:
+      ms: 2.1.3
+
+  debug@4.4.3:
+    dependencies:
+      ms: 2.1.3
+
+  decamelize-keys@1.1.1:
+    dependencies:
+      decamelize: 1.2.0
+      map-obj: 1.0.1
+
+  decamelize@1.2.0: {}
+
+  deep-equal@1.0.1: {}
+
+  deep-is@0.1.4: {}
+
+  deepmerge@4.3.1: {}
+
+  default-gateway@6.0.3:
+    dependencies:
+      execa: 5.1.1
+
+  define-data-property@1.1.4:
+    dependencies:
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  define-lazy-prop@2.0.0: {}
+
+  define-properties@1.2.1:
+    dependencies:
+      define-data-property: 1.1.4
+      has-property-descriptors: 1.0.2
+      object-keys: 1.1.1
+
+  delegates@1.0.0: {}
+
+  depd@1.1.2: {}
+
+  depd@2.0.0: {}
+
+  destroy@1.2.0: {}
+
+  didyoumean2@4.1.0:
+    dependencies:
+      '@babel/runtime': 7.28.3
+      leven: 3.1.0
+      lodash.deburr: 4.1.0
+
+  doctrine@2.1.0:
+    dependencies:
+      esutils: 2.0.3
+
+  dom-serializer@2.0.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      entities: 4.5.0
+
+  domelementtype@2.3.0: {}
+
+  domhandler@5.0.3:
+    dependencies:
+      domelementtype: 2.3.0
+
+  domutils@3.2.2:
+    dependencies:
+      dom-serializer: 2.0.0
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+
+  dunder-proto@1.0.1:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  ee-first@1.1.1: {}
+
+  emoji-regex@8.0.0: {}
+
+  encodeurl@1.0.2: {}
+
+  enhanced-resolve@5.18.3:
+    dependencies:
+      graceful-fs: 4.2.11
+      tapable: 2.3.0
+
+  entities@4.5.0: {}
+
+  entities@6.0.1: {}
+
+  error-ex@1.3.2:
+    dependencies:
+      is-arrayish: 0.2.1
+
+  es-abstract@1.24.0:
+    dependencies:
+      array-buffer-byte-length: 1.0.2
+      arraybuffer.prototype.slice: 1.0.4
+      available-typed-arrays: 1.0.7
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      data-view-buffer: 1.0.2
+      data-view-byte-length: 1.0.2
+      data-view-byte-offset: 1.0.1
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.1
+      es-set-tostringtag: 2.1.0
+      es-to-primitive: 1.3.0
+      function.prototype.name: 1.1.8
+      get-intrinsic: 1.3.0
+      get-proto: 1.0.1
+      get-symbol-description: 1.1.0
+      globalthis: 1.0.4
+      gopd: 1.2.0
+      has-property-descriptors: 1.0.2
+      has-proto: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.2
+      internal-slot: 1.1.0
+      is-array-buffer: 3.0.5
+      is-callable: 1.2.7
+      is-data-view: 1.0.2
+      is-negative-zero: 2.0.3
+      is-regex: 1.2.1
+      is-set: 2.0.3
+      is-shared-array-buffer: 1.0.4
+      is-string: 1.1.1
+      is-typed-array: 1.1.15
+      is-weakref: 1.1.1
+      math-intrinsics: 1.1.0
+      object-inspect: 1.13.4
+      object-keys: 1.1.1
+      object.assign: 4.1.7
+      own-keys: 1.0.1
+      regexp.prototype.flags: 1.5.4
+      safe-array-concat: 1.1.3
+      safe-push-apply: 1.0.0
+      safe-regex-test: 1.1.0
+      set-proto: 1.0.0
+      stop-iteration-iterator: 1.1.0
+      string.prototype.trim: 1.2.10
+      string.prototype.trimend: 1.0.9
+      string.prototype.trimstart: 1.0.8
+      typed-array-buffer: 1.0.3
+      typed-array-byte-length: 1.0.3
+      typed-array-byte-offset: 1.0.4
+      typed-array-length: 1.0.7
+      unbox-primitive: 1.1.0
+      which-typed-array: 1.1.19
+
+  es-define-property@1.0.1: {}
+
+  es-errors@1.3.0: {}
+
+  es-module-lexer@1.7.0: {}
+
+  es-object-atoms@1.1.1:
+    dependencies:
+      es-errors: 1.3.0
+
+  es-set-tostringtag@2.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      has-tostringtag: 1.0.2
+      hasown: 2.0.2
+
+  es-shim-unscopables@1.1.0:
+    dependencies:
+      hasown: 2.0.2
+
+  es-to-primitive@1.3.0:
+    dependencies:
+      is-callable: 1.2.7
+      is-date-object: 1.1.0
+      is-symbol: 1.1.1
+
+  esbuild@0.25.9:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.9
+      '@esbuild/android-arm': 0.25.9
+      '@esbuild/android-arm64': 0.25.9
+      '@esbuild/android-x64': 0.25.9
+      '@esbuild/darwin-arm64': 0.25.9
+      '@esbuild/darwin-x64': 0.25.9
+      '@esbuild/freebsd-arm64': 0.25.9
+      '@esbuild/freebsd-x64': 0.25.9
+      '@esbuild/linux-arm': 0.25.9
+      '@esbuild/linux-arm64': 0.25.9
+      '@esbuild/linux-ia32': 0.25.9
+      '@esbuild/linux-loong64': 0.25.9
+      '@esbuild/linux-mips64el': 0.25.9
+      '@esbuild/linux-ppc64': 0.25.9
+      '@esbuild/linux-riscv64': 0.25.9
+      '@esbuild/linux-s390x': 0.25.9
+      '@esbuild/linux-x64': 0.25.9
+      '@esbuild/netbsd-arm64': 0.25.9
+      '@esbuild/netbsd-x64': 0.25.9
+      '@esbuild/openbsd-arm64': 0.25.9
+      '@esbuild/openbsd-x64': 0.25.9
+      '@esbuild/openharmony-arm64': 0.25.9
+      '@esbuild/sunos-x64': 0.25.9
+      '@esbuild/win32-arm64': 0.25.9
+      '@esbuild/win32-ia32': 0.25.9
+      '@esbuild/win32-x64': 0.25.9
+
+  escalade@3.2.0: {}
+
+  escape-html@1.0.3: {}
+
+  escape-string-regexp@1.0.5: {}
+
+  escape-string-regexp@4.0.0: {}
+
+  eslint-compat-utils@0.5.1(eslint@9.39.1):
+    dependencies:
+      eslint: 9.39.1
+      semver: 7.7.3
+
+  eslint-config-google@0.14.0(eslint@9.39.1):
+    dependencies:
+      eslint: 9.39.1
+
+  eslint-config-prettier@9.1.0(eslint@9.39.1):
+    dependencies:
+      eslint: 9.39.1
+
+  eslint-import-resolver-node@0.3.9:
+    dependencies:
+      debug: 3.2.7
+      is-core-module: 2.16.1
+      resolve: 1.22.10
+    transitivePeerDependencies:
+      - supports-color
+
+  eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1):
+    dependencies:
+      debug: 3.2.7
+    optionalDependencies:
+      '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+      eslint: 9.39.1
+      eslint-import-resolver-node: 0.3.9
+    transitivePeerDependencies:
+      - supports-color
+
+  eslint-plugin-es-x@7.8.0(eslint@9.39.1):
+    dependencies:
+      '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1)
+      '@eslint-community/regexpp': 4.12.2
+      eslint: 9.39.1
+      eslint-compat-utils: 0.5.1(eslint@9.39.1)
+
+  eslint-plugin-es-x@8.0.0(eslint@9.39.1):
+    dependencies:
+      '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1)
+      '@eslint-community/regexpp': 4.12.2
+      eslint: 9.39.1
+      eslint-compat-utils: 0.5.1(eslint@9.39.1)
+
+  eslint-plugin-es@4.1.0(eslint@9.39.1):
+    dependencies:
+      eslint: 9.39.1
+      eslint-utils: 2.1.0
+      regexpp: 3.2.0
+
+  eslint-plugin-html@8.1.3:
+    dependencies:
+      htmlparser2: 10.0.0
+
+  eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2))(eslint@9.39.1):
+    dependencies:
+      '@rtsao/scc': 1.1.0
+      array-includes: 3.1.9
+      array.prototype.findlastindex: 1.2.6
+      array.prototype.flat: 1.3.3
+      array.prototype.flatmap: 1.3.3
+      debug: 3.2.7
+      doctrine: 2.1.0
+      eslint: 9.39.1
+      eslint-import-resolver-node: 0.3.9
+      eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1)
+      hasown: 2.0.2
+      is-core-module: 2.16.1
+      is-glob: 4.0.3
+      minimatch: 3.1.2
+      object.fromentries: 2.0.8
+      object.groupby: 1.0.3
+      object.values: 1.2.1
+      semver: 6.3.1
+      string.prototype.trimend: 1.0.9
+      tsconfig-paths: 3.15.0
+    optionalDependencies:
+      '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+    transitivePeerDependencies:
+      - eslint-import-resolver-typescript
+      - eslint-import-resolver-webpack
+      - supports-color
+
+  eslint-plugin-jsdoc@50.8.0(eslint@9.39.1):
+    dependencies:
+      '@es-joy/jsdoccomment': 0.50.2
+      are-docs-informative: 0.0.2
+      comment-parser: 1.4.1
+      debug: 4.4.3
+      escape-string-regexp: 4.0.0
+      eslint: 9.39.1
+      espree: 10.4.0
+      esquery: 1.6.0
+      parse-imports-exports: 0.2.4
+      semver: 7.7.3
+      spdx-expression-parse: 4.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  eslint-plugin-lit@1.15.0(eslint@9.39.1):
+    dependencies:
+      eslint: 9.39.1
+      parse5: 6.0.1
+      parse5-htmlparser2-tree-adapter: 6.0.1
+      requireindex: 1.2.0
+
+  eslint-plugin-n@15.7.0(eslint@9.39.1):
+    dependencies:
+      builtins: 5.1.0
+      eslint: 9.39.1
+      eslint-plugin-es: 4.1.0(eslint@9.39.1)
+      eslint-utils: 3.0.0(eslint@9.39.1)
+      ignore: 5.3.2
+      is-core-module: 2.16.1
+      minimatch: 3.1.2
+      resolve: 1.22.10
+      semver: 7.7.3
+
+  eslint-plugin-n@17.23.1(eslint@9.39.1)(typescript@5.8.2):
+    dependencies:
+      '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1)
+      enhanced-resolve: 5.18.3
+      eslint: 9.39.1
+      eslint-plugin-es-x: 7.8.0(eslint@9.39.1)
+      get-tsconfig: 4.13.0
+      globals: 15.15.0
+      globrex: 0.1.2
+      ignore: 5.3.2
+      semver: 7.7.3
+      ts-declaration-location: 1.0.7(typescript@5.8.2)
+    transitivePeerDependencies:
+      - typescript
+
+  eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@9.39.1))(eslint@9.39.1)(prettier@3.3.3):
+    dependencies:
+      eslint: 9.39.1
+      prettier: 3.3.3
+      prettier-linter-helpers: 1.0.0
+      synckit: 0.9.3
+    optionalDependencies:
+      eslint-config-prettier: 9.1.0(eslint@9.39.1)
+
+  eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.0(eslint@9.39.1))(eslint@9.39.1)(prettier@2.8.8):
+    dependencies:
+      eslint: 9.39.1
+      prettier: 2.8.8
+      prettier-linter-helpers: 1.0.0
+      synckit: 0.11.11
+    optionalDependencies:
+      eslint-config-prettier: 9.1.0(eslint@9.39.1)
+
+  eslint-plugin-regex@1.10.0(eslint@9.39.1):
+    dependencies:
+      eslint: 9.39.1
+
+  eslint-scope@8.4.0:
+    dependencies:
+      esrecurse: 4.3.0
+      estraverse: 5.3.0
+
+  eslint-utils@2.1.0:
+    dependencies:
+      eslint-visitor-keys: 1.3.0
+
+  eslint-utils@3.0.0(eslint@9.39.1):
+    dependencies:
+      eslint: 9.39.1
+      eslint-visitor-keys: 2.1.0
+
+  eslint-visitor-keys@1.3.0: {}
+
+  eslint-visitor-keys@2.1.0: {}
+
+  eslint-visitor-keys@3.4.3: {}
+
+  eslint-visitor-keys@4.2.1: {}
+
+  eslint@9.39.1:
+    dependencies:
+      '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1)
+      '@eslint-community/regexpp': 4.12.2
+      '@eslint/config-array': 0.21.1
+      '@eslint/config-helpers': 0.4.2
+      '@eslint/core': 0.17.0
+      '@eslint/eslintrc': 3.3.3
+      '@eslint/js': 9.39.1
+      '@eslint/plugin-kit': 0.4.1
+      '@humanfs/node': 0.16.7
+      '@humanwhocodes/module-importer': 1.0.1
+      '@humanwhocodes/retry': 0.4.3
+      '@types/estree': 1.0.8
+      ajv: 6.12.6
+      chalk: 4.1.2
+      cross-spawn: 7.0.6
+      debug: 4.4.3
+      escape-string-regexp: 4.0.0
+      eslint-scope: 8.4.0
+      eslint-visitor-keys: 4.2.1
+      espree: 10.4.0
+      esquery: 1.6.0
+      esutils: 2.0.3
+      fast-deep-equal: 3.1.3
+      file-entry-cache: 8.0.0
+      find-up: 5.0.0
+      glob-parent: 6.0.2
+      ignore: 5.3.2
+      imurmurhash: 0.1.4
+      is-glob: 4.0.3
+      json-stable-stringify-without-jsonify: 1.0.1
+      lodash.merge: 4.6.2
+      minimatch: 3.1.2
+      natural-compare: 1.4.0
+      optionator: 0.9.4
+    transitivePeerDependencies:
+      - supports-color
+
+  espree@10.4.0:
+    dependencies:
+      acorn: 8.15.0
+      acorn-jsx: 5.3.2(acorn@8.15.0)
+      eslint-visitor-keys: 4.2.1
+
+  esquery@1.6.0:
+    dependencies:
+      estraverse: 5.3.0
+
+  esrecurse@4.3.0:
+    dependencies:
+      estraverse: 5.3.0
+
+  estraverse@5.3.0: {}
+
+  estree-walker@2.0.2: {}
+
+  esutils@2.0.3: {}
+
+  etag@1.8.1: {}
+
+  execa@5.1.1:
+    dependencies:
+      cross-spawn: 7.0.6
+      get-stream: 6.0.1
+      human-signals: 2.1.0
+      is-stream: 2.0.1
+      merge-stream: 2.0.0
+      npm-run-path: 4.0.1
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+      strip-final-newline: 2.0.0
+
+  external-editor@3.1.0:
+    dependencies:
+      chardet: 0.7.0
+      iconv-lite: 0.4.24
+      tmp: 0.0.33
+
+  fast-deep-equal@3.1.3: {}
+
+  fast-diff@1.3.0: {}
+
+  fast-glob@3.3.3:
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      '@nodelib/fs.walk': 1.2.8
+      glob-parent: 5.1.2
+      merge2: 1.4.1
+      micromatch: 4.0.8
+
+  fast-json-stable-stringify@2.1.0: {}
+
+  fast-levenshtein@2.0.6: {}
+
+  fastq@1.19.1:
+    dependencies:
+      reusify: 1.1.0
+
+  fdir@6.5.0(picomatch@4.0.3):
+    optionalDependencies:
+      picomatch: 4.0.3
+
+  figures@3.2.0:
+    dependencies:
+      escape-string-regexp: 1.0.5
+
+  file-entry-cache@8.0.0:
+    dependencies:
+      flat-cache: 4.0.1
+
+  fill-range@7.1.1:
+    dependencies:
+      to-regex-range: 5.0.1
+
+  find-replace@3.0.0:
+    dependencies:
+      array-back: 3.1.0
+
+  find-up@4.1.0:
+    dependencies:
+      locate-path: 5.0.0
+      path-exists: 4.0.0
+
+  find-up@5.0.0:
+    dependencies:
+      locate-path: 6.0.0
+      path-exists: 4.0.0
+
+  flat-cache@4.0.1:
+    dependencies:
+      flatted: 3.3.3
+      keyv: 4.5.4
+
+  flatted@3.3.3: {}
+
+  for-each@0.3.5:
+    dependencies:
+      is-callable: 1.2.7
+
+  fresh@0.5.2: {}
+
+  fs.realpath@1.0.0: {}
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  function.prototype.name@1.1.8:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      define-properties: 1.2.1
+      functions-have-names: 1.2.3
+      hasown: 2.0.2
+      is-callable: 1.2.7
+
+  functions-have-names@1.2.3: {}
+
+  get-caller-file@2.0.5: {}
+
+  get-intrinsic@1.3.0:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.1
+      function-bind: 1.1.2
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.2
+      math-intrinsics: 1.1.0
+
+  get-proto@1.0.1:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-object-atoms: 1.1.1
+
+  get-stream@6.0.1: {}
+
+  get-symbol-description@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+
+  get-tsconfig@4.13.0:
+    dependencies:
+      resolve-pkg-maps: 1.0.0
+
+  glob-parent@5.1.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob-parent@6.0.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob@7.2.3:
+    dependencies:
+      fs.realpath: 1.0.0
+      inflight: 1.0.6
+      inherits: 2.0.4
+      minimatch: 3.1.2
+      once: 1.4.0
+      path-is-absolute: 1.0.1
+
+  globals@14.0.0: {}
+
+  globals@15.15.0: {}
+
+  globalthis@1.0.4:
+    dependencies:
+      define-properties: 1.2.1
+      gopd: 1.2.0
+
+  globrex@0.1.2: {}
+
+  gopd@1.2.0: {}
+
+  graceful-fs@4.2.11: {}
+
+  gts@6.0.2(typescript@5.8.2):
+    dependencies:
+      '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.8.2))(eslint@9.39.1)(typescript@5.8.2)
+      '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.8.2)
+      chalk: 4.1.2
+      eslint: 9.39.1
+      eslint-config-prettier: 9.1.0(eslint@9.39.1)
+      eslint-plugin-n: 15.7.0(eslint@9.39.1)
+      eslint-plugin-prettier: 5.2.1(eslint-config-prettier@9.1.0(eslint@9.39.1))(eslint@9.39.1)(prettier@3.3.3)
+      execa: 5.1.1
+      inquirer: 7.3.3
+      json5: 2.2.3
+      meow: 9.0.0
+      ncp: 2.0.0
+      prettier: 3.3.3
+      rimraf: 3.0.2
+      typescript: 5.8.2
+      write-file-atomic: 4.0.2
+    transitivePeerDependencies:
+      - '@types/eslint'
+      - jiti
+      - supports-color
+
+  hard-rejection@2.1.0: {}
+
+  has-bigints@1.1.0: {}
+
+  has-flag@3.0.0: {}
+
+  has-flag@4.0.0: {}
+
+  has-property-descriptors@1.0.2:
+    dependencies:
+      es-define-property: 1.0.1
+
+  has-proto@1.2.0:
+    dependencies:
+      dunder-proto: 1.0.1
+
+  has-symbols@1.1.0: {}
+
+  has-tostringtag@1.0.2:
+    dependencies:
+      has-symbols: 1.1.0
+
+  hasown@2.0.2:
+    dependencies:
+      function-bind: 1.1.2
+
+  hosted-git-info@2.8.9: {}
+
+  hosted-git-info@4.1.0:
+    dependencies:
+      lru-cache: 6.0.0
+
+  htmlparser2@10.0.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      domutils: 3.2.2
+      entities: 6.0.1
+
+  http-assert@1.5.0:
+    dependencies:
+      deep-equal: 1.0.1
+      http-errors: 1.8.1
+
+  http-errors@1.6.3:
+    dependencies:
+      depd: 1.1.2
+      inherits: 2.0.3
+      setprototypeof: 1.1.0
+      statuses: 1.5.0
+
+  http-errors@1.8.1:
+    dependencies:
+      depd: 1.1.2
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 1.5.0
+      toidentifier: 1.0.1
+
+  human-signals@2.1.0: {}
+
+  iconv-lite@0.4.24:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  ignore@5.3.2: {}
+
+  ignore@7.0.5: {}
+
+  import-fresh@3.3.1:
+    dependencies:
+      parent-module: 1.0.1
+      resolve-from: 4.0.0
+
+  imurmurhash@0.1.4: {}
+
+  indent-string@4.0.0: {}
+
+  inflight@1.0.6:
+    dependencies:
+      once: 1.4.0
+      wrappy: 1.0.2
+
+  inherits@2.0.3: {}
+
+  inherits@2.0.4: {}
+
+  inquirer@7.3.3:
+    dependencies:
+      ansi-escapes: 4.3.2
+      chalk: 4.1.2
+      cli-cursor: 3.1.0
+      cli-width: 3.0.0
+      external-editor: 3.1.0
+      figures: 3.2.0
+      lodash: 4.17.21
+      mute-stream: 0.0.8
+      run-async: 2.4.1
+      rxjs: 6.6.7
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      through: 2.3.8
+
+  internal-ip@6.2.0:
+    dependencies:
+      default-gateway: 6.0.3
+      ipaddr.js: 1.9.1
+      is-ip: 3.1.0
+      p-event: 4.2.0
+
+  internal-slot@1.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      hasown: 2.0.2
+      side-channel: 1.1.0
+
+  ip-regex@4.3.0: {}
+
+  ipaddr.js@1.9.1: {}
+
+  is-array-buffer@3.0.5:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      get-intrinsic: 1.3.0
+
+  is-arrayish@0.2.1: {}
+
+  is-async-function@2.1.1:
+    dependencies:
+      async-function: 1.0.0
+      call-bound: 1.0.4
+      get-proto: 1.0.1
+      has-tostringtag: 1.0.2
+      safe-regex-test: 1.1.0
+
+  is-bigint@1.1.0:
+    dependencies:
+      has-bigints: 1.1.0
+
+  is-boolean-object@1.2.2:
+    dependencies:
+      call-bound: 1.0.4
+      has-tostringtag: 1.0.2
+
+  is-callable@1.2.7: {}
+
+  is-core-module@2.16.1:
+    dependencies:
+      hasown: 2.0.2
+
+  is-data-view@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      get-intrinsic: 1.3.0
+      is-typed-array: 1.1.15
+
+  is-date-object@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      has-tostringtag: 1.0.2
+
+  is-docker@2.2.1: {}
+
+  is-extglob@2.1.1: {}
+
+  is-finalizationregistry@1.1.1:
+    dependencies:
+      call-bound: 1.0.4
+
+  is-fullwidth-code-point@3.0.0: {}
+
+  is-generator-function@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      get-proto: 1.0.1
+      has-tostringtag: 1.0.2
+      safe-regex-test: 1.1.0
+
+  is-glob@4.0.3:
+    dependencies:
+      is-extglob: 2.1.1
+
+  is-ip@3.1.0:
+    dependencies:
+      ip-regex: 4.3.0
+
+  is-map@2.0.3: {}
+
+  is-module@1.0.0: {}
+
+  is-negative-zero@2.0.3: {}
+
+  is-number-object@1.1.1:
+    dependencies:
+      call-bound: 1.0.4
+      has-tostringtag: 1.0.2
+
+  is-number@7.0.0: {}
+
+  is-plain-obj@1.1.0: {}
+
+  is-regex@1.2.1:
+    dependencies:
+      call-bound: 1.0.4
+      gopd: 1.2.0
+      has-tostringtag: 1.0.2
+      hasown: 2.0.2
+
+  is-set@2.0.3: {}
+
+  is-shared-array-buffer@1.0.4:
+    dependencies:
+      call-bound: 1.0.4
+
+  is-stream@2.0.1: {}
+
+  is-string@1.1.1:
+    dependencies:
+      call-bound: 1.0.4
+      has-tostringtag: 1.0.2
+
+  is-symbol@1.1.1:
+    dependencies:
+      call-bound: 1.0.4
+      has-symbols: 1.1.0
+      safe-regex-test: 1.1.0
+
+  is-typed-array@1.1.15:
+    dependencies:
+      which-typed-array: 1.1.19
+
+  is-weakmap@2.0.2: {}
+
+  is-weakref@1.1.1:
+    dependencies:
+      call-bound: 1.0.4
+
+  is-weakset@2.0.4:
+    dependencies:
+      call-bound: 1.0.4
+      get-intrinsic: 1.3.0
+
+  is-wsl@2.2.0:
+    dependencies:
+      is-docker: 2.2.1
+
+  isarray@2.0.5: {}
+
+  isbinaryfile@5.0.5: {}
+
+  isexe@2.0.0: {}
+
+  js-tokens@4.0.0: {}
+
+  js-yaml@4.1.1:
+    dependencies:
+      argparse: 2.0.1
+
+  jsdoc-type-pratt-parser@4.1.0: {}
+
+  json-buffer@3.0.1: {}
+
+  json-parse-better-errors@1.0.2: {}
+
+  json-parse-even-better-errors@2.3.1: {}
+
+  json-schema-traverse@0.4.1: {}
+
+  json-stable-stringify-without-jsonify@1.0.1: {}
+
+  json5@1.0.2:
+    dependencies:
+      minimist: 1.2.8
+
+  json5@2.2.3: {}
+
+  keygrip@1.1.0:
+    dependencies:
+      tsscmp: 1.0.6
+
+  keyv@4.5.4:
+    dependencies:
+      json-buffer: 3.0.1
+
+  kind-of@6.0.3: {}
+
+  koa-compose@4.1.0: {}
+
+  koa-convert@2.0.0:
+    dependencies:
+      co: 4.6.0
+      koa-compose: 4.1.0
+
+  koa-etag@4.0.0:
+    dependencies:
+      etag: 1.8.1
+
+  koa-send@5.0.1:
+    dependencies:
+      debug: 4.4.3
+      http-errors: 1.8.1
+      resolve-path: 1.4.0
+    transitivePeerDependencies:
+      - supports-color
+
+  koa-static@5.0.0:
+    dependencies:
+      debug: 3.2.7
+      koa-send: 5.0.1
+    transitivePeerDependencies:
+      - supports-color
+
+  koa@2.16.2:
+    dependencies:
+      accepts: 1.3.8
+      cache-content-type: 1.0.1
+      content-disposition: 0.5.4
+      content-type: 1.0.5
+      cookies: 0.9.1
+      debug: 4.4.3
+      delegates: 1.0.0
+      depd: 2.0.0
+      destroy: 1.2.0
+      encodeurl: 1.0.2
+      escape-html: 1.0.3
+      fresh: 0.5.2
+      http-assert: 1.5.0
+      http-errors: 1.8.1
+      is-generator-function: 1.1.0
+      koa-compose: 4.1.0
+      koa-convert: 2.0.0
+      on-finished: 2.4.1
+      only: 0.0.2
+      parseurl: 1.3.3
+      statuses: 1.5.0
+      type-is: 1.6.18
+      vary: 1.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  leven@3.1.0: {}
+
+  levn@0.4.1:
+    dependencies:
+      prelude-ls: 1.2.1
+      type-check: 0.4.0
+
+  lines-and-columns@1.2.4: {}
+
+  lit-analyzer@2.0.3:
+    dependencies:
+      '@vscode/web-custom-data': 0.4.13
+      chalk: 2.4.2
+      didyoumean2: 4.1.0
+      fast-glob: 3.3.3
+      parse5: 5.1.0
+      ts-simple-type: 2.0.0-next.0
+      vscode-css-languageservice: 6.3.9
+      vscode-html-languageservice: 5.6.1
+      web-component-analyzer: 2.0.0
+
+  load-json-file@4.0.0:
+    dependencies:
+      graceful-fs: 4.2.11
+      parse-json: 4.0.0
+      pify: 3.0.0
+      strip-bom: 3.0.0
+
+  locate-path@5.0.0:
+    dependencies:
+      p-locate: 4.1.0
+
+  locate-path@6.0.0:
+    dependencies:
+      p-locate: 5.0.0
+
+  lodash.camelcase@4.3.0: {}
+
+  lodash.deburr@4.1.0: {}
+
+  lodash.merge@4.6.2: {}
+
+  lodash@4.17.21: {}
+
+  lru-cache@6.0.0:
+    dependencies:
+      yallist: 4.0.0
+
+  lru-cache@8.0.5: {}
+
+  map-obj@1.0.1: {}
+
+  map-obj@4.3.0: {}
+
+  math-intrinsics@1.1.0: {}
+
+  media-typer@0.3.0: {}
+
+  memorystream@0.3.1: {}
+
+  meow@9.0.0:
+    dependencies:
+      '@types/minimist': 1.2.5
+      camelcase-keys: 6.2.2
+      decamelize: 1.2.0
+      decamelize-keys: 1.1.1
+      hard-rejection: 2.1.0
+      minimist-options: 4.1.0
+      normalize-package-data: 3.0.3
+      read-pkg-up: 7.0.1
+      redent: 3.0.0
+      trim-newlines: 3.0.1
+      type-fest: 0.18.1
+      yargs-parser: 20.2.9
+
+  merge-stream@2.0.0: {}
+
+  merge2@1.4.1: {}
+
+  micromatch@4.0.8:
+    dependencies:
+      braces: 3.0.3
+      picomatch: 2.3.1
+
+  mime-db@1.52.0: {}
+
+  mime-types@2.1.35:
+    dependencies:
+      mime-db: 1.52.0
+
+  mimic-fn@2.1.0: {}
+
+  min-indent@1.0.1: {}
+
+  minimatch@3.1.2:
+    dependencies:
+      brace-expansion: 1.1.12
+
+  minimatch@9.0.5:
+    dependencies:
+      brace-expansion: 2.0.2
+
+  minimist-options@4.1.0:
+    dependencies:
+      arrify: 1.0.1
+      is-plain-obj: 1.1.0
+      kind-of: 6.0.3
+
+  minimist@1.2.8: {}
+
+  ms@2.1.3: {}
+
+  mute-stream@0.0.8: {}
+
+  nanocolors@0.2.13: {}
+
+  natural-compare@1.4.0: {}
+
+  ncp@2.0.0: {}
+
+  negotiator@0.6.3: {}
+
+  nice-try@1.0.5: {}
+
+  normalize-package-data@2.5.0:
+    dependencies:
+      hosted-git-info: 2.8.9
+      resolve: 1.22.10
+      semver: 5.7.2
+      validate-npm-package-license: 3.0.4
+
+  normalize-package-data@3.0.3:
+    dependencies:
+      hosted-git-info: 4.1.0
+      is-core-module: 2.16.1
+      semver: 7.7.3
+      validate-npm-package-license: 3.0.4
+
+  npm-run-all@4.1.5:
+    dependencies:
+      ansi-styles: 3.2.1
+      chalk: 2.4.2
+      cross-spawn: 6.0.6
+      memorystream: 0.3.1
+      minimatch: 3.1.2
+      pidtree: 0.3.1
+      read-pkg: 3.0.0
+      shell-quote: 1.8.3
+      string.prototype.padend: 3.1.6
+
+  npm-run-path@4.0.1:
+    dependencies:
+      path-key: 3.1.1
+
+  object-inspect@1.13.4: {}
+
+  object-keys@1.1.1: {}
+
+  object.assign@4.1.7:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      define-properties: 1.2.1
+      es-object-atoms: 1.1.1
+      has-symbols: 1.1.0
+      object-keys: 1.1.1
+
+  object.fromentries@2.0.8:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-object-atoms: 1.1.1
+
+  object.groupby@1.0.3:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+
+  object.values@1.2.1:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      define-properties: 1.2.1
+      es-object-atoms: 1.1.1
+
+  on-finished@2.4.1:
+    dependencies:
+      ee-first: 1.1.1
+
+  once@1.4.0:
+    dependencies:
+      wrappy: 1.0.2
+
+  onetime@5.1.2:
+    dependencies:
+      mimic-fn: 2.1.0
+
+  only@0.0.2: {}
+
+  open@8.4.2:
+    dependencies:
+      define-lazy-prop: 2.0.0
+      is-docker: 2.2.1
+      is-wsl: 2.2.0
+
+  optionator@0.9.4:
+    dependencies:
+      deep-is: 0.1.4
+      fast-levenshtein: 2.0.6
+      levn: 0.4.1
+      prelude-ls: 1.2.1
+      type-check: 0.4.0
+      word-wrap: 1.2.5
+
+  os-tmpdir@1.0.2: {}
+
+  own-keys@1.0.1:
+    dependencies:
+      get-intrinsic: 1.3.0
+      object-keys: 1.1.1
+      safe-push-apply: 1.0.0
+
+  p-event@4.2.0:
+    dependencies:
+      p-timeout: 3.2.0
+
+  p-finally@1.0.0: {}
+
+  p-limit@2.3.0:
+    dependencies:
+      p-try: 2.2.0
+
+  p-limit@3.1.0:
+    dependencies:
+      yocto-queue: 0.1.0
+
+  p-locate@4.1.0:
+    dependencies:
+      p-limit: 2.3.0
+
+  p-locate@5.0.0:
+    dependencies:
+      p-limit: 3.1.0
+
+  p-timeout@3.2.0:
+    dependencies:
+      p-finally: 1.0.0
+
+  p-try@2.2.0: {}
+
+  parent-module@1.0.1:
+    dependencies:
+      callsites: 3.1.0
+
+  parse-imports-exports@0.2.4:
+    dependencies:
+      parse-statements: 1.0.11
+
+  parse-json@4.0.0:
+    dependencies:
+      error-ex: 1.3.2
+      json-parse-better-errors: 1.0.2
+
+  parse-json@5.2.0:
+    dependencies:
+      '@babel/code-frame': 7.27.1
+      error-ex: 1.3.2
+      json-parse-even-better-errors: 2.3.1
+      lines-and-columns: 1.2.4
+
+  parse-statements@1.0.11: {}
+
+  parse5-htmlparser2-tree-adapter@6.0.1:
+    dependencies:
+      parse5: 6.0.1
+
+  parse5@5.1.0: {}
+
+  parse5@6.0.1: {}
+
+  parseurl@1.3.3: {}
+
+  path-exists@4.0.0: {}
+
+  path-is-absolute@1.0.1: {}
+
+  path-key@2.0.1: {}
+
+  path-key@3.1.1: {}
+
+  path-parse@1.0.7: {}
+
+  path-type@3.0.0:
+    dependencies:
+      pify: 3.0.0
+
+  picocolors@1.1.1: {}
+
+  picomatch@2.3.1: {}
+
+  picomatch@4.0.3: {}
+
+  pidtree@0.3.1: {}
+
+  pify@3.0.0: {}
+
+  portfinder@1.0.37:
+    dependencies:
+      async: 3.2.6
+      debug: 4.4.3
+    transitivePeerDependencies:
+      - supports-color
+
+  possible-typed-array-names@1.1.0: {}
+
+  prelude-ls@1.2.1: {}
+
+  prettier-linter-helpers@1.0.0:
+    dependencies:
+      fast-diff: 1.3.0
+
+  prettier@2.8.8: {}
+
+  prettier@3.3.3: {}
+
+  punycode@2.3.1: {}
+
+  queue-microtask@1.2.3: {}
+
+  quick-lru@4.0.1: {}
+
+  read-pkg-up@7.0.1:
+    dependencies:
+      find-up: 4.1.0
+      read-pkg: 5.2.0
+      type-fest: 0.8.1
+
+  read-pkg@3.0.0:
+    dependencies:
+      load-json-file: 4.0.0
+      normalize-package-data: 2.5.0
+      path-type: 3.0.0
+
+  read-pkg@5.2.0:
+    dependencies:
+      '@types/normalize-package-data': 2.4.4
+      normalize-package-data: 2.5.0
+      parse-json: 5.2.0
+      type-fest: 0.6.0
+
+  readdirp@4.1.2: {}
+
+  redent@3.0.0:
+    dependencies:
+      indent-string: 4.0.0
+      strip-indent: 3.0.0
+
+  reflect.getprototypeof@1.0.10:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.1
+      get-intrinsic: 1.3.0
+      get-proto: 1.0.1
+      which-builtin-type: 1.2.1
+
+  regexp.prototype.flags@1.5.4:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-errors: 1.3.0
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      set-function-name: 2.0.2
+
+  regexpp@3.2.0: {}
+
+  require-directory@2.1.1: {}
+
+  requireindex@1.2.0: {}
+
+  resolve-from@4.0.0: {}
+
+  resolve-path@1.4.0:
+    dependencies:
+      http-errors: 1.6.3
+      path-is-absolute: 1.0.1
+
+  resolve-pkg-maps@1.0.0: {}
+
+  resolve@1.22.10:
+    dependencies:
+      is-core-module: 2.16.1
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+
+  restore-cursor@3.1.0:
+    dependencies:
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+
+  reusify@1.1.0: {}
+
+  rimraf@3.0.2:
+    dependencies:
+      glob: 7.2.3
+
+  rollup@4.53.3:
+    dependencies:
+      '@types/estree': 1.0.8
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.53.3
+      '@rollup/rollup-android-arm64': 4.53.3
+      '@rollup/rollup-darwin-arm64': 4.53.3
+      '@rollup/rollup-darwin-x64': 4.53.3
+      '@rollup/rollup-freebsd-arm64': 4.53.3
+      '@rollup/rollup-freebsd-x64': 4.53.3
+      '@rollup/rollup-linux-arm-gnueabihf': 4.53.3
+      '@rollup/rollup-linux-arm-musleabihf': 4.53.3
+      '@rollup/rollup-linux-arm64-gnu': 4.53.3
+      '@rollup/rollup-linux-arm64-musl': 4.53.3
+      '@rollup/rollup-linux-loong64-gnu': 4.53.3
+      '@rollup/rollup-linux-ppc64-gnu': 4.53.3
+      '@rollup/rollup-linux-riscv64-gnu': 4.53.3
+      '@rollup/rollup-linux-riscv64-musl': 4.53.3
+      '@rollup/rollup-linux-s390x-gnu': 4.53.3
+      '@rollup/rollup-linux-x64-gnu': 4.53.3
+      '@rollup/rollup-linux-x64-musl': 4.53.3
+      '@rollup/rollup-openharmony-arm64': 4.53.3
+      '@rollup/rollup-win32-arm64-msvc': 4.53.3
+      '@rollup/rollup-win32-ia32-msvc': 4.53.3
+      '@rollup/rollup-win32-x64-gnu': 4.53.3
+      '@rollup/rollup-win32-x64-msvc': 4.53.3
+      fsevents: 2.3.3
+
+  run-async@2.4.1: {}
+
+  run-parallel@1.2.0:
+    dependencies:
+      queue-microtask: 1.2.3
+
+  rxjs@6.6.7:
+    dependencies:
+      tslib: 1.14.1
+
+  safe-array-concat@1.1.3:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      get-intrinsic: 1.3.0
+      has-symbols: 1.1.0
+      isarray: 2.0.5
+
+  safe-buffer@5.2.1: {}
+
+  safe-push-apply@1.0.0:
+    dependencies:
+      es-errors: 1.3.0
+      isarray: 2.0.5
+
+  safe-regex-test@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-regex: 1.2.1
+
+  safer-buffer@2.1.2: {}
+
+  semver@5.7.2: {}
+
+  semver@6.3.1: {}
+
+  semver@7.7.3: {}
+
+  set-function-length@1.2.2:
+    dependencies:
+      define-data-property: 1.1.4
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+      get-intrinsic: 1.3.0
+      gopd: 1.2.0
+      has-property-descriptors: 1.0.2
+
+  set-function-name@2.0.2:
+    dependencies:
+      define-data-property: 1.1.4
+      es-errors: 1.3.0
+      functions-have-names: 1.2.3
+      has-property-descriptors: 1.0.2
+
+  set-proto@1.0.0:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.1
+
+  setprototypeof@1.1.0: {}
+
+  setprototypeof@1.2.0: {}
+
+  shebang-command@1.2.0:
+    dependencies:
+      shebang-regex: 1.0.0
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@1.0.0: {}
+
+  shebang-regex@3.0.0: {}
+
+  shell-quote@1.8.3: {}
+
+  side-channel-list@1.0.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-map@1.0.1:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-weakmap@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-map: 1.0.1
+
+  side-channel@1.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-list: 1.0.0
+      side-channel-map: 1.0.1
+      side-channel-weakmap: 1.0.2
+
+  signal-exit@3.0.7: {}
+
+  source-map-support@0.5.21:
+    dependencies:
+      buffer-from: 1.1.2
+      source-map: 0.6.1
+
+  source-map@0.6.1: {}
+
+  spdx-correct@3.2.0:
+    dependencies:
+      spdx-expression-parse: 3.0.1
+      spdx-license-ids: 3.0.22
+
+  spdx-exceptions@2.5.0: {}
+
+  spdx-expression-parse@3.0.1:
+    dependencies:
+      spdx-exceptions: 2.5.0
+      spdx-license-ids: 3.0.22
+
+  spdx-expression-parse@4.0.0:
+    dependencies:
+      spdx-exceptions: 2.5.0
+      spdx-license-ids: 3.0.22
+
+  spdx-license-ids@3.0.22: {}
+
+  statuses@1.5.0: {}
+
+  stop-iteration-iterator@1.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      internal-slot: 1.1.0
+
+  string-width@4.2.3:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+
+  string.prototype.padend@3.1.6:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-object-atoms: 1.1.1
+
+  string.prototype.trim@1.2.10:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      define-data-property: 1.1.4
+      define-properties: 1.2.1
+      es-abstract: 1.24.0
+      es-object-atoms: 1.1.1
+      has-property-descriptors: 1.0.2
+
+  string.prototype.trimend@1.0.9:
+    dependencies:
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      define-properties: 1.2.1
+      es-object-atoms: 1.1.1
+
+  string.prototype.trimstart@1.0.8:
+    dependencies:
+      call-bind: 1.0.8
+      define-properties: 1.2.1
+      es-object-atoms: 1.1.1
+
+  strip-ansi@6.0.1:
+    dependencies:
+      ansi-regex: 5.0.1
+
+  strip-bom@3.0.0: {}
+
+  strip-final-newline@2.0.0: {}
+
+  strip-indent@3.0.0:
+    dependencies:
+      min-indent: 1.0.1
+
+  strip-json-comments@3.1.1: {}
+
+  supports-color@5.5.0:
+    dependencies:
+      has-flag: 3.0.0
+
+  supports-color@7.2.0:
+    dependencies:
+      has-flag: 4.0.0
+
+  supports-preserve-symlinks-flag@1.0.0: {}
+
+  synckit@0.11.11:
+    dependencies:
+      '@pkgr/core': 0.2.9
+
+  synckit@0.9.3:
+    dependencies:
+      '@pkgr/core': 0.1.2
+      tslib: 2.8.1
+
+  table-layout@4.1.1:
+    dependencies:
+      array-back: 6.2.2
+      wordwrapjs: 5.1.0
+
+  tapable@2.3.0: {}
+
+  terser@5.39.2:
+    dependencies:
+      '@jridgewell/source-map': 0.3.11
+      acorn: 8.15.0
+      commander: 2.20.3
+      source-map-support: 0.5.21
+
+  through@2.3.8: {}
+
+  tinyglobby@0.2.15:
+    dependencies:
+      fdir: 6.5.0(picomatch@4.0.3)
+      picomatch: 4.0.3
+
+  tmp@0.0.33:
+    dependencies:
+      os-tmpdir: 1.0.2
+
+  to-regex-range@5.0.1:
+    dependencies:
+      is-number: 7.0.0
+
+  toidentifier@1.0.1: {}
+
+  tr46@5.1.1:
+    dependencies:
+      punycode: 2.3.1
+
+  trim-newlines@3.0.1: {}
+
+  ts-api-utils@2.1.0(typescript@5.8.2):
+    dependencies:
+      typescript: 5.8.2
+
+  ts-declaration-location@1.0.7(typescript@5.8.2):
+    dependencies:
+      picomatch: 4.0.3
+      typescript: 5.8.2
+
+  ts-lit-plugin@2.0.2:
+    dependencies:
+      lit-analyzer: 2.0.3
+      web-component-analyzer: 2.0.0
+
+  ts-simple-type@2.0.0-next.0: {}
+
+  tsconfig-paths@3.15.0:
+    dependencies:
+      '@types/json5': 0.0.29
+      json5: 1.0.2
+      minimist: 1.2.8
+      strip-bom: 3.0.0
+
+  tslib@1.14.1: {}
+
+  tslib@2.8.1: {}
+
+  tsscmp@1.0.6: {}
+
+  type-check@0.4.0:
+    dependencies:
+      prelude-ls: 1.2.1
+
+  type-fest@0.18.1: {}
+
+  type-fest@0.21.3: {}
+
+  type-fest@0.6.0: {}
+
+  type-fest@0.8.1: {}
+
+  type-is@1.6.18:
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.35
+
+  typed-array-buffer@1.0.3:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-typed-array: 1.1.15
+
+  typed-array-byte-length@1.0.3:
+    dependencies:
+      call-bind: 1.0.8
+      for-each: 0.3.5
+      gopd: 1.2.0
+      has-proto: 1.2.0
+      is-typed-array: 1.1.15
+
+  typed-array-byte-offset@1.0.4:
+    dependencies:
+      available-typed-arrays: 1.0.7
+      call-bind: 1.0.8
+      for-each: 0.3.5
+      gopd: 1.2.0
+      has-proto: 1.2.0
+      is-typed-array: 1.1.15
+      reflect.getprototypeof: 1.0.10
+
+  typed-array-length@1.0.7:
+    dependencies:
+      call-bind: 1.0.8
+      for-each: 0.3.5
+      gopd: 1.2.0
+      is-typed-array: 1.1.15
+      possible-typed-array-names: 1.1.0
+      reflect.getprototypeof: 1.0.10
+
+  typescript@5.8.2: {}
+
+  typical@4.0.0: {}
+
+  typical@7.3.0: {}
+
+  ua-parser-js@1.0.41: {}
+
+  unbox-primitive@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      has-bigints: 1.1.0
+      has-symbols: 1.1.0
+      which-boxed-primitive: 1.1.1
+
+  undici-types@7.10.0: {}
+
+  uri-js@4.4.1:
+    dependencies:
+      punycode: 2.3.1
+
+  validate-npm-package-license@3.0.4:
+    dependencies:
+      spdx-correct: 3.2.0
+      spdx-expression-parse: 3.0.1
+
+  vary@1.1.2: {}
+
+  vscode-css-languageservice@6.3.9:
+    dependencies:
+      '@vscode/l10n': 0.0.18
+      vscode-languageserver-textdocument: 1.0.12
+      vscode-languageserver-types: 3.17.5
+      vscode-uri: 3.1.0
+
+  vscode-html-languageservice@5.6.1:
+    dependencies:
+      '@vscode/l10n': 0.0.18
+      vscode-languageserver-textdocument: 1.0.12
+      vscode-languageserver-types: 3.17.5
+      vscode-uri: 3.1.0
+
+  vscode-languageserver-textdocument@1.0.12: {}
+
+  vscode-languageserver-types@3.17.5: {}
+
+  vscode-uri@3.1.0: {}
+
+  web-component-analyzer@2.0.0:
+    dependencies:
+      fast-glob: 3.3.3
+      ts-simple-type: 2.0.0-next.0
+      typescript: 5.8.2
+      yargs: 17.7.2
+
+  webidl-conversions@7.0.0: {}
+
+  whatwg-url@14.2.0:
+    dependencies:
+      tr46: 5.1.1
+      webidl-conversions: 7.0.0
+
+  which-boxed-primitive@1.1.1:
+    dependencies:
+      is-bigint: 1.1.0
+      is-boolean-object: 1.2.2
+      is-number-object: 1.1.1
+      is-string: 1.1.1
+      is-symbol: 1.1.1
+
+  which-builtin-type@1.2.1:
+    dependencies:
+      call-bound: 1.0.4
+      function.prototype.name: 1.1.8
+      has-tostringtag: 1.0.2
+      is-async-function: 2.1.1
+      is-date-object: 1.1.0
+      is-finalizationregistry: 1.1.1
+      is-generator-function: 1.1.0
+      is-regex: 1.2.1
+      is-weakref: 1.1.1
+      isarray: 2.0.5
+      which-boxed-primitive: 1.1.1
+      which-collection: 1.0.2
+      which-typed-array: 1.1.19
+
+  which-collection@1.0.2:
+    dependencies:
+      is-map: 2.0.3
+      is-set: 2.0.3
+      is-weakmap: 2.0.2
+      is-weakset: 2.0.4
+
+  which-typed-array@1.1.19:
+    dependencies:
+      available-typed-arrays: 1.0.7
+      call-bind: 1.0.8
+      call-bound: 1.0.4
+      for-each: 0.3.5
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-tostringtag: 1.0.2
+
+  which@1.3.1:
+    dependencies:
+      isexe: 2.0.0
+
+  which@2.0.2:
+    dependencies:
+      isexe: 2.0.0
+
+  word-wrap@1.2.5: {}
+
+  wordwrapjs@5.1.0: {}
+
+  wrap-ansi@7.0.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  wrappy@1.0.2: {}
+
+  write-file-atomic@4.0.2:
+    dependencies:
+      imurmurhash: 0.1.4
+      signal-exit: 3.0.7
+
+  ws@7.5.10: {}
+
+  y18n@5.0.8: {}
+
+  yallist@4.0.0: {}
+
+  yargs-parser@20.2.9: {}
+
+  yargs-parser@21.1.1: {}
+
+  yargs@17.7.2:
+    dependencies:
+      cliui: 8.0.1
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 21.1.1
+
+  ylru@1.4.0: {}
+
+  yocto-queue@0.1.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..7d40629
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1 @@
+onlyBuiltDependencies: []
diff --git a/polygerrit-ui/GEMINI.md b/polygerrit-ui/AGENTS.md
similarity index 96%
rename from polygerrit-ui/GEMINI.md
rename to polygerrit-ui/AGENTS.md
index 7478a77..089162d 100644
--- a/polygerrit-ui/GEMINI.md
+++ b/polygerrit-ui/AGENTS.md
@@ -1,4 +1,4 @@
-# Gemini Project Profile: gerrit/polygerrit-ui
+# AI Agent Project Profile: gerrit/polygerrit-ui
 
 This document provides a summary of the front-end development environment for the `polygerrit-ui` part of the gerrit project.
 
@@ -44,6 +44,12 @@
 - `yarn test:screenshot`: Run visual regression tests.
 - `yarn test:screenshot-update`: Run visual regression tests and update baseline images.
 
+Bazel equivalents (what CI runs; all three are tagged `manual` and must be named explicitly):
+
+- `bazelisk test //polygerrit-ui:web_test_runner`: Unit tests only — same set as `yarn test`.
+- `bazelisk test //polygerrit-ui:web_test_runner_screenshots`: Screenshot regression tests only — same set as `yarn test:screenshot`.
+- `bazelisk test //polygerrit-ui:web_test_runner_all`: Both buckets in one invocation.
+
 ## Running Single Tests
 
 Running the full test suite can be slow. For a faster feedback loop during development, you can run tests for a single file. Use the `test:single:nowatch` script with the path to the test file. This will run the test once and exit.
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index a42e2dd..8cf523d 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,6 +1,10 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
+load("@ui_dev_npm//:defs.bzl", "npm_link_all_packages")
+load("@ui_dev_npm//polygerrit-ui:@web/test-runner/package_json.bzl", web_test_runner_npm_bin = "bin")
 load("//tools/bzl:js.bzl", "web_test_runner")
 
+npm_link_all_packages(name = "node_modules")
+
 package(default_visibility = ["//visibility:public"])
 
 genrule2(
@@ -22,24 +26,55 @@
 
 # This is a dependency for web_test_runner rule in js.bzl that is only used by
 # plugins.
-sh_binary(
+web_test_runner_npm_bin.wtr_binary(
     name = "web_test_runner_bin",
-    srcs = ["@ui_dev_npm//:node_modules/@web/test-runner/dist/bin.js"],
-    data = [
-        "@ui_dev_npm//@web/dev-server-esbuild",
-        "@ui_dev_npm//@web/test-runner",
-        "@ui_dev_npm//@web/test-runner-playwright",
-        "@ui_dev_npm//@web/test-runner-visual-regression",
-    ],
 )
 
 web_test_runner(
     name = "web_test_runner",
     srcs = ["web_test_runner.sh"],
     data = [
+        "//polygerrit-ui:node_modules",
+        "//polygerrit-ui/app:node_modules",
+        "//polygerrit-ui/app:web-test-runner_app-sources",
+    ],
+)
+
+# Baseline PNGs used by *_screenshot_test.ts files. The failed/ directory is
+# gitignored and intentionally excluded.
+filegroup(
+    name = "screenshot_baselines",
+    srcs = glob(["screenshots/**/baseline/**"]),
+)
+
+web_test_runner(
+    name = "web_test_runner_screenshots",
+    srcs = ["web_test_runner.sh"],
+    args = ["--run-screenshots"],
+    data = [
         "//polygerrit-ui/app:web-test-runner_app-sources",
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
+        ":screenshot_baselines",
+        # fonts.css references /polygerrit-ui/app/fonts/, which the runtime
+        # middleware in web-test-runner.config.mjs rewrites to /lib/fonts/.
+        # Pull the actual font files into the runfiles so that rewrite
+        # resolves to a real path; otherwise glyphs fall back to system
+        # fonts and every screenshot differs in height by a few pixels.
+        "//lib/fonts:robotofonts",
+        "//lib/fonts:material-icons",
+    ],
+)
+
+# Convenience target that runs both the regular unit tests and the screenshot
+# regression tests. Tagged manual so it is not picked up by `bazel test //...`;
+# CI invokes it explicitly.
+test_suite(
+    name = "web_test_runner_all",
+    tags = ["manual"],
+    tests = [
+        ":web_test_runner",
+        ":web_test_runner_screenshots",
     ],
 )
 
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 8924d67..6baaa66 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -2,16 +2,17 @@
 
 Follow the
 [setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
-where applicable, the most important command is:
+where applicable. The most important command is:
 
 ```sh
 git clone --recurse-submodules https://gerrit.googlesource.com/gerrit
 ```
 
-The --recurse-submodules option is needed on git clone to ensure that the core plugins, which are included as git submodules, are also cloned.
+The `--recurse-submodules` option is needed on `git clone` to ensure that the
+core plugins, which are included as git submodules, are also cloned.
 
-Then make sure to install the commit-hook that will set up the `ChangeId` for
-each push to gerrit-reviews.
+Then make sure to install the commit hook that will set up the `Change-Id` for
+each push to Gerrit:
 
 ```sh
 cd gerrit && (
@@ -24,113 +25,152 @@
 
 Follow the instructions
 [here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
-to get and install Bazel. The `npm install -g @bazel/bazelisk` method is
-probably easiest since you will have npm as part of Nodejs.
+to get and install Bazel. Using Bazelisk is usually the easiest option.
 
-## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
+## Installing [Node.js](https://nodejs.org/en/download/) yarn and pnpm
 
-At the time of writing (November 2023) you should use version 18 of nodejs.
+Use a recent Node.js version that is supported by the repository. If in doubt,
+use the version that is used in CI or documented in the root `package.json`.
+
+Examples:
 
 ```sh
-# Debian experimental
-sudo apt-get install nodejs
-sudo apt-get install npm
+# Debian / Ubuntu
+sudo apt-get install nodejs npm
 
-# OS X with Homebrew
-brew install node@18
-brew install npm
+# macOS with Homebrew
+brew install node
 ```
 
 All other platforms:
-[download from nodejs.org](https://nodejs.org/en/download/).
+[download from nodejs.org](https://nodejs.org/en/download/),
 
 or use [nvm - Node Version Manager](https://github.com/nvm-sh/nvm).
 
-### Additional packages
-
-We have several bazel commands to install packages we may need for FE development.
-
-For first time users to get the local server up, `bazel build gerrit` should be enough and will take care of all of them for you.
+Install Yarn:
 
 ```sh
-# Install yarn package manager
 npm install -g yarn
-
-# Install packages from all packages.json files
-yarn setup
 ```
 
-More information for installing and using nodejs rules can be found here https://bazelbuild.github.io/rules_nodejs/install.html
-
-### Upgrade to @bazel-scoped packages
-
-It might be necessary to run this command to upgrade to major `rules_nodejs` release:
+Install pnpm:
 
 ```sh
-yarn remove @bazel/...
+npm install -g pnpm
 ```
 
-## Setup typescript support in the IDE
+## Dependency Management (Yarn → pnpm Transition)
 
-Modern IDEs should automatically handle typescript settings from the
-`polygerrit-ui/app/tsconfig.json` files. The `tsc` compiler places compiled
-files in the `.ts-out/pg` directory at the root of gerrit workspace and you can
-configure the IDE to exclude the whole .ts-out directory. To do it in the
-IntelliJ IDEA click on this directory and select "Mark Directory As > Excluded"
-in the context menu.
+- yarn.lock is authoritative
+- pnpm-lock.yaml is generated via `pnpm import`
+- Do NOT edit pnpm-lock.yaml manually
 
-However, if you receive some errors from IDE, you can try to configure IDE
+## Expected Bazel Behavior
+
+First run may fail with:
+
+pnpm-lock.yaml file updated. Please run your build again.
+
+Rerun the same command.
+
+## Workflow
+
+1. Edit package.json
+2. Run: yarn install
+3. Run: bazel build gerrit
+4. If lock updated → rerun build
+
+## Why Yarn stays
+
+- gradual migration from `yarn` to `pnpm`
+- stable dependency resolution
+- avoids breaking workflows
+
+## DO NOT RUN
+
+```sh
+pnpm install
+```
+
+It creates a different dependency graph.
+
+## Long-term
+
+Eventually:
+- pnpm-lock.yaml becomes canonical
+- yarn.lock removed
+
+## Setup TypeScript support in the IDE
+
+Modern IDEs should automatically handle TypeScript settings from
+`polygerrit-ui/app/tsconfig.json`. The `tsc` compiler places compiled files in
+the `.ts-out/pg` directory at the root of the Gerrit workspace, and you can
+configure the IDE to exclude the whole `.ts-out` directory. In IntelliJ IDEA,
+right-click the directory and select **Mark Directory As > Excluded**.
+
+If your IDE still reports errors, then you can try configuring TypeScript
 manually. For example, if IntelliJ IDEA shows
-`Cannot find parent 'tsconfig.json'` error, you can try to setup typescript
-options `--project polygerrit-ui/app/tsconfig.json` in the IDE settings.
+`Cannot find parent 'tsconfig.json'`, then set the TypeScript options to:
 
+```sh
+--project polygerrit-ui/app/tsconfig.json
+```
 
 ## Developing locally
 
 The preferred method for development is to serve the web files locally using the
-Web Dev Server and then view a running gerrit instance (local or otherwise) to
+Web Dev Server and then view a running Gerrit instance (local or otherwise) and
 replace its web client with the local one using the Gerrit FE Dev Helper
 extension.
 
 ### Web Dev Server
 
 The [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/) serves
-the compiled web files and dependencies unbundled over localhost. Start it using
-this command:
+the compiled web files and dependencies unbundled over localhost. Start it with:
 
 ```sh
-yarn start
+pnpm start
 ```
 
-To inject plugins or other files, we use the [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd) Chrome extension.
+To inject plugins or other files, we use the
+[Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd)
+Chrome extension.
 
-If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
-
+If you run into problems, refer to the troubleshooting section below or contact
+the team.
 
 ### Chrome extension: Gerrit FE Dev Helper
 
-To be able to bypass the auth and also help improve the productivity of Gerrit FE developers,
-we created this chrome extension: [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd).
+To help frontend development, including bypassing auth in local dev workflows,
+we created this Chrome extension:
+[Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd).
 
-It basically works as a proxy that will block / redirect requests from current sites to any given url base on certain rules.
+It basically works as a proxy that blocks or redirects requests from the current
+site to a configured URL according to rules.
 
-The source code is in [Gerrit - gerrit-fe-dev-helper](https://gerrit-review.googlesource.com/q/project:gerrit-fe-dev-helper), contributions are welcomed!
+The source code is in
+[Gerrit - gerrit-fe-dev-helper](https://gerrit-review.googlesource.com/q/project:gerrit-fe-dev-helper),
+and contributions are welcome.
 
-To use this extension, just follow its [readme here](https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/master/README.md).
+To use this extension, follow its
+[README](https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/master/README.md).
 
 ### Running locally against a Gerrit test site
 
 Set up a local test site once:
 
 1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
-2. [Set up a local test site](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
-3. Optionally [populate](https://gerrit.googlesource.com/gerrit/+/master/contrib/populate-fixture-data.py) your test site with some test data.
+2. [Set up a local test site](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init)
+3. Optionally [populate](https://gerrit.googlesource.com/gerrit/+/master/contrib/populate-fixture-data.py)
+   your test site with test data
 
 For running a locally built Gerrit war against your test instance use
 [this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon).
 
-If you want to serve the Lit frontend directly from the sources in `polygerrit_ui/app/` instead of from the war:
-1. Start [Web Dev Server](#web-dev-server)
+If you want to serve the Lit frontend directly from the sources in
+`polygerrit-ui/app/` instead of from the war:
+
+1. Start the [Web Dev Server](#web-dev-server)
 2. Add the `--dev-cdn` option:
 
 ```sh
@@ -142,316 +182,155 @@
     --dev-cdn http://localhost:8081
 ```
 
-The Web Dev Server is currently not serving fonts or other static assets. Follow
-[Issue 40015119](https://issues.gerritcodereview.com/issues/40015119) for
-fixing this issue.
+The Web Dev Server currently does not serve fonts or some other static assets.
+Follow
+[Issue 40015119](https://issues.gerritcodereview.com/issues/40015119)
+for updates.
 
-*NOTE* You can use any other cdn here, for example: https://cdn.googlesource.com/polygerrit_ui/678.0
+*Note:* You can also use another CDN here, for example:
+https://cdn.googlesource.com/polygerrit_ui/678.0
 
-## Running Tests
+## Running tests
 
 For daily development you typically only want to run and debug individual tests.
 Our tests run using the
-[Web Test Runner](https://modern-web.dev/docs/test-runner/overview/). There are
-several ways to trigger tests:
+[Web Test Runner](https://modern-web.dev/docs/test-runner/overview/).
+
+Common commands:
 
 * Run all tests once:
+
 ```sh
-yarn test
+pnpm test
 ```
 
-* Run all tests and then watches for changes. Change a file will trigger all
-tests affected by the changes.
+* Run all tests in watch mode. Changing a file reruns affected tests:
+
 ```sh
-yarn test:watch
+pnpm test:watch
 ```
 
-* Run all tests once under bazel:
+* Run all tests once under Bazel:
+
 ```sh
 ./polygerrit-ui/app/run_test.sh
 ```
 
-* Run a single test file and rerun on any changes affecting it:
-```
-yarn test:single "**/gr-comment_test.ts"
+* Run a single test file and rerun when affected files change:
+
+```sh
+pnpm test:single "**/gr-comment_test.ts"
 ```
 
-### Screenshot Tests
+### Screenshot tests
 
 We use screenshot tests to prevent unintended visual regressions.
 
 To run the screenshot tests:
+
 ```sh
-yarn test:screenshot
+pnpm test:screenshot
 ```
 
-If a test fails, it means the component's appearance has changed. New screenshots will be generated in the `polygerrit-ui/screenshots/Chromium/failed/` directory. In case of a mismatch with an existing baseline, a diff image will also be created there.
-
-If the change is intended, you need to approve the new screenshots as the baseline. To do this, move the new screenshot files from the `failed` directory to the `baseline` directory, overwriting the old ones. The diff images in the `failed` directory can be deleted.
-
+Or via Bazel, which matches what CI runs and uses the Bazel-managed
+dependency tree:
 ```sh
-# Move all failed screenshots at once:
-mv polygerrit-ui/screenshots/Chromium/failed/*.png polygerrit-ui/screenshots/Chromium/baseline/
+bazelisk test //polygerrit-ui:web_test_runner_screenshots
 ```
 
-After moving the file(s), run `yarn test:screenshot` again to confirm that they pass.
+The `//polygerrit-ui:web_test_runner_all` target runs both the unit and
+screenshot buckets in a single invocation. Both screenshot targets are
+tagged `manual`, so they are never picked up by a bare `bazel test //...`
+and must be named explicitly.
 
-Compiling code:
+If a test fails, then the component's appearance has changed. New screenshots
+are generated in the `polygerrit-ui/screenshots/Chromium/failed/` directory.
+If an existing baseline differs, then a diff image is also created there.
+
+If the change is intended, then approve the new screenshots as the baseline by
+moving the new screenshot files from `failed` to `baseline`, overwriting the old
+ones. The diff images in `failed` can be deleted.
+
 ```sh
-# Compile frontend once to check for type errors:
-yarn compile
+mv polygerrit-ui/screenshots/Chromium/failed/*.png \
+  polygerrit-ui/screenshots/Chromium/baseline/
+```
 
-# Watch mode:
-yarn compile:watch
+After moving the file(s), run `pnpm test:screenshot` again to confirm they pass.
+
+### Compiling code
+
+```sh
+# Compile frontend once to check for type errors
+pnpm compile
+
+# Watch mode
+pnpm compile:watch
 ```
 
 ## Style guide
 
-We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
-with a few exceptions. When in doubt, remain consistent with the code around you.
+We follow the
+[Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
+with a few exceptions. When in doubt, remain consistent with the surrounding
+code.
 
-In addition, we encourage the use of [ESLint](http://eslint.org/).
-It is available as a command line utility, as well as a plugin for most editors
-and IDEs.
+In addition, we encourage the use of [ESLint](http://eslint.org/). It is
+available as a command-line utility as well as a plugin for most editors and
+IDEs.
 
-`eslint-config-google` is a port of the Google JS Style Guide to an ESLint
-config module, and `eslint-plugin-html` allows ESLint to lint scripts inside
-HTML.
-We have an eslint-bazel.config.js config file in the polygerrit-ui/ directory configured
-to enforce the preferred style of the PolyGerrit project.
-After installing, you can use `eslint` on any new file you create.
-In addition, you can supply the `--fix` flag to apply some suggested fixes for
-simple style issues.
-If you modify JS inside of `<script>` tags, like for test suites, you may have
-to supply the `--ext .html` flag.
+We have an ESLint flat config in `polygerrit-ui/app/eslint-bazel.config.js`
+configured to enforce the preferred style of the PolyGerrit project.
 
 Some useful commands:
 
-* To run ESLint on the whole app, less some dependency code:
+* Run ESLint on the whole app:
 
 ```sh
-yarn eslint
+pnpm eslint
 ```
 
-* To run ESLint and apply changes on the whole app:
+* Run ESLint and apply automatic fixes:
 
 ```sh
-yarn eslintfix
+pnpm eslintfix
 ```
 
-* To run ESLint on just the subdirectory you modified:
+* Run ESLint on a specific subdirectory:
 
 ```sh
-node_modules/eslint/bin/eslint.js --ext .html,.js polygerrit-ui/app/$YOUR_DIR_HERE
+pnpm exec eslint --config polygerrit-ui/app/eslint-bazel.config.js \
+  --ext .html,.js,.ts polygerrit-ui/app/$YOUR_DIR_HERE
 ```
 
-* To run the linter on all of your local changes:
+* Run ESLint on all locally changed frontend files:
 
 ```sh
-git diff --name-only HEAD | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
+git diff --name-only HEAD | xargs pnpm exec eslint \
+  --config polygerrit-ui/app/eslint-bazel.config.js \
+  --ext .html,.js,.ts
 ```
 
-## Migrating tests to Typescript
-
-You can use the following steps for migrating tests to Typescript:
-
-1. Rename the `_test.js` file to `_test.ts`
-2. Remove `.js` extensions from all imports:
-   ```
-   // Before:
-   import ... from 'x/y/z.js`
-
-   // After
-   import .. from 'x/y/z'
-   ```
-3. Fix typescript and eslint errors.
-
-Common errors and fixes are:
-
-* An object in the test doesn't have all required properties. You can use
-existing helpers to create an object with all required properties:
-```
-// Before:
-sinon.stub(element.restApiService, 'getPreferences').returns(
-    Promise.resolve({default_diff_view: 'UNIFIED'}));
-
-// After:
-Promise.resolve({
-  ...createPreferences(),
-  default_diff_view: DiffViewMode.UNIFIED,
-})
-```
-
-Some helpers receive parameters:
-```
-// Before
-element._change = {
-  change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-  revisions: {
-    rev1: {_number: 1, commit: {parents: []}},
-    rev2: {_number: 2, commit: {parents: []}},
-  },
-  current_revision: 'rev1',
-  status: ChangeStatus.MERGED,
-  labels: {},
-  actions: {},
-};
-
-// After
-element._change = {
-  ...createChange(),
-  // The change_id is set by createChange.
-  // The exact change_id is not important in the test, so it was removed.
-  revisions: {
-    rev1: createRevision(1), // _number is a parameter here
-    rev2: createRevision(2), // _number is a parameter here
-  },
-  current_revision: 'rev1' as CommitId,
-  status: ChangeStatus.MERGED,
-  labels: {},
-  actions: {},
-};
-```
-* Typescript reports some weird messages about `window` property - sometimes an
-IDE adds wrong import. Just remove it.
-```
-// The wrong import added by IDE, must be removed
-import window = Mocha.reporters.Base.window;
-```
-
-* `TS2531: Object is possibly 'null'`. To fix use either non-null assertion
-operator `!` or nullish coalescing operator `?.`:
-```
-// Before:
-const rows = element
-  .shadowRoot.querySelector('table')
-  .querySelectorAll('tbody tr');
-...
-// The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
-assert.equal(element._robotCommentThreads.length, 2);
-
-// Fix with non-null assertion operator:
-const rows = element
-  .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
-  .querySelectorAll('tbody tr');
-
-assert.equal(element._robotCommentThreads!.length, 2);
-
-// Fix with nullish coalescing operator:
- assert.equal(element._robotCommentThreads?.length, 2);
-```
-Usually the fix with `!` is preferable, because it gives more clear error
-when an intermediate property is `null/undefined`. If the _robotComments is
-`undefined` in the example above, the `element._robotCommentThreads!.length`
-crashes with the error `Cannot read property 'length' of undefined`. At the
-same time the fix with
-`?.` doesn't distinct between 2 cases: _robotCommentThreads is `undefined`
-and `length` is `undefined`.
-
-* `TS2339: Property '...' does not exist on type 'Element'.` for elements
-returned by `querySelector/querySelectorAll`. To fix it, use generic versions
-of those methods:
-```
-// Before:
-const radios = parentTable
-  .querySelectorAll('input[type=radio]');
-const radio = parentRow
-  .querySelector('input[type=radio]');
-
-// After:
-const radios = parentTable
-  .querySelectorAll<HTMLInputElement>('input[type=radio]');
-const radio = parentRow
-  .querySelector<HTMLInputElement>('input[type=radio]');
-```
-
-* Sinon: `TS2339: Property 'lastCall' does not exist on type '...` (the same
-for other sinon properties). Store stub/spy in a variable and then use the
-variable:
-```
-// Before:
-const navService = testResolver(navigationToken);
-sinon.stub(navService, 'setUrl');
-...
-assert.equal(navService.setUrl.lastCall.firstArg, '/c/123');
-
-// After:
-const navService = testResolver(navigationToken);
-const setUrlStub = sinon.stub(navService, 'setUrl');
-...
-assert.equal(setUrlStub.lastCall.firstArg, '/c/123');
-```
-
-If you need to define a type for such variable, you can use one of the following
-options:
-```
-suite('my suite', () => {
-    // Non static members, option 1
-    let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
-    // Non static members, option 2
-    let updateHeightSpy_prototype: SinonSpyMember<typeof GrChangeView.prototype._updateRelatedChangeMaxHeight>;
-    // Static members
-    let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>;
-    // For interfaces
-    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
-});
-```
-
-* Typescript reports errors when stubbing/faking methods:
-```
-// The JS code:
-const reloadStub = sinon
-    .stub(element, '_reload')
-    .callsFake(() => Promise.resolve());
-
-stubRestApi('getDiffComments').returns(Promise.resolve({}));
-stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({}));
-```
-
-In such cases, validate the input and output of a stub/fake method. Quite often
-tests return null instead of undefined or `[]` instead of `{}`, etc...
-Fix types if they are not correct:
-```
-const reloadStub = sinon
-  .stub(element, '_reload')
-  // GrChangeView._reload method returns an array
-  .callsFake(() => Promise.resolve([])); // return [] here
-
-  ...
-  // Fix return type:
-  stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({} as ParsedJSON));
-});
-```
-
-* If a test requires a `@types/...` library, install the required library
-in the `polygerrit_ui/node_modules` and update the `typeRoots` in the
-`polygerrit-ui/app/tsconfig_bazel_test.json` file.
-
-The same update should be done if a test requires a .d.ts file from a library
-that already exists in `polygerrit_ui/node_modules`.
-
-**Note:** Types from a library located in `polygerrit_ui/app/node_modules` are
-handle automatically.
-
-* If a test imports a library from `polygerrit_ui/node_modules` - update
-`paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
+When running ESLint manually, invoke it from the repository root so that
+repo-root-relative paths in the flat config resolve correctly.
 
 ## Contributing
 
-Our users report bugs / feature requests related to the UI through the Gerrit
-Tracker on the [WebFrontend](https://issues.gerritcodereview.com/issues?q=componentid:1369968)
+Our users report bugs and feature requests related to the UI through the Gerrit
+Tracker in the
+[WebFrontend](https://issues.gerritcodereview.com/issues?q=componentid:1369968)
 component.
 
-If you want to help, feel free to grab one from those `New` issues without
-assignees and send us a change.
+If you want to help, feel free to pick one of the `New` issues without assignees
+and send us a change.
 
-If you don't know who to assign to review your code change, you can use
-this special account: `gerrit-fe-reviewers@api-project-164060093628.iam.gserviceaccount.com`
-and just assign to that account, it will automatically pick two volunteers
-from the queue we have for FE reviewers.
+If you don't know who to assign as reviewer for your change, then you can use
+this special account:
 
-If you are willing to join the queue and help the community review changes,
-you can create an issue through Monorail and request to join the queue!
-We will review your request and start from there.
+`gerrit-fe-reviewers@api-project-164060093628.iam.gserviceaccount.com`
+
+Assigning that account automatically picks two volunteers from the frontend
+reviewer queue.
+
+If you are willing to join that queue and help review community changes, then
+create an issue through Gerrit issue tracker and ask to join.
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 4d55ddc4..f2ae231 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,8 +1,11 @@
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
-load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
+load("@npm//:lit-analyzer/package_json.bzl", lit_analyzer_bin = "bin")
+load("@ui_npm//:defs.bzl", "npm_link_all_packages")
 load("//tools/js:eslint.bzl", "eslint")
 load(":rules.bzl", "polygerrit_bundle")
 
+npm_link_all_packages(name = "node_modules")
+
 package(default_visibility = ["//visibility:public"])
 
 # This list must be in sync with the "include" list in the follwoing files:
@@ -46,10 +49,9 @@
     allow_js = True,
     incremental = True,
     out_dir = "_pg_ts_out",
-    tsc = "//tools/node_tools:tsc-bin",
     tsconfig = ":ts_config_bazel",
     deps = [
-        "@ui_npm//:node_modules",
+        ":node_modules",
     ],
 )
 
@@ -80,11 +82,10 @@
     allow_js = True,
     incremental = True,
     out_dir = "_pg_with_tests_out",
-    tsc = "//tools/node_tools:tsc-bin",
     tsconfig = ":ts_config_bazel_test",
     deps = [
-        "@ui_dev_npm//:node_modules",
-        "@ui_npm//:node_modules",
+        ":node_modules",
+        "//polygerrit-ui:node_modules",
     ],
 )
 
@@ -109,8 +110,8 @@
             "node_modules_licenses/**",
         ],
     ) + [
-        "@ui_dev_npm//:node_modules",
-        "@ui_npm//:node_modules",
+        ":node_modules",
+        "//polygerrit-ui:node_modules",
     ],
 )
 
@@ -133,9 +134,9 @@
     name = "test-srcs-fg",
     srcs = [
         "rollup.config.js",
+        ":node_modules",
         ":pg_code",
-        "@ui_dev_npm//:node_modules",
-        "@ui_npm//:node_modules",
+        "//polygerrit-ui:node_modules",
     ],
 )
 
@@ -153,7 +154,7 @@
         "tsconfig_eslint.json",
         # tsconfig_eslint.json extends tsconfig.json, pass it as a dependency
         "tsconfig.json",
-        "@npm//typescript",
+        "//:node_modules/typescript",
     ],
     extensions = [
         ".html",
@@ -161,17 +162,17 @@
         ".ts",
     ],
     plugins = [
-        "@npm//@typescript-eslint/eslint-plugin",
-        "@npm//eslint-config-google",
-        "@npm//eslint-plugin-html",
-        "@npm//eslint-plugin-import",
-        "@npm//eslint-plugin-jsdoc",
-        "@npm//eslint-plugin-lit",
-        "@npm//eslint-plugin-n",
-        "@npm//eslint-plugin-prettier",
-        "@npm//eslint-plugin-regex",
-        "@npm//eslint-plugin-es-x",
-        "@npm//gts",
+        "//:node_modules/@typescript-eslint/eslint-plugin",
+        "//:node_modules/eslint-config-google",
+        "//:node_modules/eslint-plugin-html",
+        "//:node_modules/eslint-plugin-import",
+        "//:node_modules/eslint-plugin-jsdoc",
+        "//:node_modules/eslint-plugin-lit",
+        "//:node_modules/eslint-plugin-n",
+        "//:node_modules/eslint-plugin-prettier",
+        "//:node_modules/eslint-plugin-regex",
+        "//:node_modules/eslint-plugin-es-x",
+        "//:node_modules/gts",
     ],
 )
 
@@ -184,47 +185,47 @@
             "**/*_test.ts",
         ],
     ) + [
-        "@npm//typescript",
-        "@ui_dev_npm//:node_modules",
-        "@ui_npm//:node_modules",
+        "//:node_modules/typescript",
+        "//polygerrit-ui:node_modules",
     ],
 )
 
-nodejs_test(
+lit_analyzer_bin.lit_analyzer_test(
     name = "lit_analysis",
+    args = [
+        "**/elements/**/*.ts",
+        "--strict",
+        "--rules.no-property-visibility-mismatch",
+        "off",
+        "--rules.no-incompatible-property-type",
+        "off",
+        "--rules.no-incompatible-type-binding",
+        "off",
+        "--rules.no-unknown-attribute",
+        "off",
+        "--rules.no-unknown-tag-name",
+        "off",
+    ],
+    chdir = package_name(),
     data = [
         ":lit_analysis_src_code",
-        "@npm//lit-analyzer",
     ],
-    entry_point = "@npm//:node_modules/lit-analyzer/cli.js",
     tags = [
         "local",
         "manual",
     ],
-    templated_args = [
-        "**/elements/**/*.ts",
-        "--strict",
-        "--rules.no-property-visibility-mismatch off",
-        "--rules.no-incompatible-property-type off",
-        "--rules.no-incompatible-type-binding off",
-        # TODO: We would actually like to change this to `error`, but we also
-        # want to allow certain attributes, for example `aria-description`. This
-        # would be possible, if we would run the lit-analyzer as a ts plugin.
-        # In tsconfig.json there is an option `globalAttributes` that we could
-        # use. But that is not available when running lit-analyzer as cli.
-        "--rules.no-unknown-attribute off",
-        # TODO: enable once we migrate from iron elements to material-web
-        "--rules.no-unknown-tag-name off",
-    ],
 )
 
-# app code including tests and tsconfig.json
+# app code including tests, tsconfig.json, and the CSS sheets the
+# Web Test Runner HTML loads (main.css, fonts.css, material-icons.css
+# under styles/).
 filegroup(
     name = "web-test-runner_app-sources",
     srcs = glob(
         [
             "**/*.ts",
             "**/*.js",
+            "**/*.css",
             "**/tsconfig.json",
         ],
         exclude = ["node_modules/**/*"],
diff --git a/polygerrit-ui/app/api/BUILD_for_publishing_api_only b/polygerrit-ui/app/api/BUILD_for_publishing_api_only
index c1bb6bd..b54e157 100644
--- a/polygerrit-ui/app/api/BUILD_for_publishing_api_only
+++ b/polygerrit-ui/app/api/BUILD_for_publishing_api_only
@@ -11,8 +11,8 @@
 # are not visible anymore to the parent BUILD. And if ts_projects depend on each
 # other, then the api/ files would have to be imported with their full package
 # names.
-load("@build_bazel_rules_nodejs//:index.bzl", "pkg_npm")
-load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+load("@aspect_rules_js//npm:defs.bzl", "npm_package")
+load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
 
 filegroup(
     name = "js_plugin_api_srcs",
@@ -21,9 +21,9 @@
 
 ts_config(
     name = "ts_config",
-    src = "tsconfig.json",
+    src = "tsconfig.publish.json",
     deps = [
-        "//plugins:tsconfig-plugins-base.json",
+        ":tsconfig-plugins-base.json",
     ],
 )
 
@@ -31,25 +31,24 @@
     name = "js_plugin_api_compiled",
     srcs = glob(["**/*.ts"]),
     incremental = True,
-    tsc = "//tools/node_tools:tsc-bin",
     tsconfig = ":ts_config",
 )
 
 # Use this rule for publishing the js plugin api as a package to the npm repo.
-pkg_npm(
+npm_package(
     name = "js_plugin_api_npm_package",
-    package_name = "@gerritcodereview/typescript-api",
+    package = "@gerritcodereview/typescript-api",
+    publishable = True,
     srcs = glob(
         ["**/*"],
         exclude = [
             "BUILD",
             "BUILD_for_publishing_api_only",
             "tsconfig.json",
+            "tsconfig.publish.json",
             "publish.sh",
         ],
-    ),
-    deps = [
+    ) + [
         ":js_plugin_api_compiled",
-        "//plugins:tsconfig-plugins-base.json",
     ],
 )
diff --git a/polygerrit-ui/app/api/ai-code-review.ts b/polygerrit-ui/app/api/ai-code-review.ts
index eeac166..edf2711 100644
--- a/polygerrit-ui/app/api/ai-code-review.ts
+++ b/polygerrit-ui/app/api/ai-code-review.ts
@@ -70,6 +70,11 @@
   group_display_text?: string;
   external_contexts?: ContextItem[];
   custom_action_source?: CustomActionSource;
+
+  /**
+   * Optional URL pointing to the definition of this capability.
+   */
+  capability_definition_url?: string;
 }
 
 export declare interface ChatRequest {
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index 7cf200f..38c8141 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -5,6 +5,7 @@
  */
 import {
   CoverageRange,
+  DiffLayer,
   FileRange,
   GrDiff,
   TokenHighlightEventDetails,
@@ -39,6 +40,11 @@
   highlight?: TokenHighlightEventDetails
 ) => void;
 
+/**
+ * Factory function to create a DiffLayer.
+ */
+export type DiffLayerFactory = (details: DiffDetails) => DiffLayer;
+
 export declare interface AnnotationPluginApi {
   /**
    * The specified function will be called when a gr-diff component is built,
@@ -56,8 +62,11 @@
    *
    * The callback receives details of the diff itself and of the highlighted
    * token.
-   *
-   * TODO: Replace with a more general addDiffLayer() endpoint.
    */
   addTokenHoverListener(callback: TokenHoverListener): void;
+
+  /**
+   * Register a factory that creates a DiffLayer for each diff view.
+   */
+  addDiffLayer(factory: DiffLayerFactory): void;
 }
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index d881426..d7524948 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -226,6 +226,7 @@
   // Hides the FILE and LOST diff rows. Default is TRUE.
   show_file_comment_button?: boolean;
   line_wrapping?: boolean;
+  responsive_mode?: DiffResponsiveMode;
 }
 
 /**
@@ -276,6 +277,7 @@
   show_newline_warning_left?: boolean;
   show_newline_warning_right?: boolean;
   use_new_image_diff_ui?: boolean;
+  is_edit_mode?: boolean;
 }
 
 /**
@@ -384,11 +386,6 @@
   path?: string;
 }
 
-// TODO: Currently unused and not fired.
-export declare interface RenderProgressEventDetail {
-  linesRendered: number;
-}
-
 /**
  * The detail of the 'copy-info' event dispatched by gr-diff.
  */
@@ -600,7 +597,15 @@
     lineNum: number,
     side: Side,
     path?: string,
-    intentionalMove?: boolean
+    intentionalMove?: boolean,
+    endLineNum?: number
+  ): void;
+
+  moveToLineRange(
+    startLine: number,
+    endLine: number,
+    side: Side,
+    path?: string
   ): void;
 }
 
diff --git a/polygerrit-ui/app/api/flows.ts b/polygerrit-ui/app/api/flows.ts
index 09d4e4c..150bfe4 100644
--- a/polygerrit-ui/app/api/flows.ts
+++ b/polygerrit-ui/app/api/flows.ts
@@ -41,6 +41,16 @@
   getCustomConditions(change: ChangeInfo): Promise<FlowCustomConditionInfo[]>;
 
   /**
+   * Returns a list of disabled custom conditions.
+   */
+  getDisabledConditions?(): string[];
+
+  /**
+   * Returns a list of disabled actions.
+   */
+  getDisabledActions?(): string[];
+
+  /**
    * Returns a string containing a link to the feature documentation.
    */
   getDocumentation(): string;
diff --git a/polygerrit-ui/app/api/publish.sh b/polygerrit-ui/app/api/publish.sh
index f930fb3..8903490 100755
--- a/polygerrit-ui/app/api/publish.sh
+++ b/polygerrit-ui/app/api/publish.sh
@@ -30,7 +30,9 @@
 
 if [ "$1" == "--pack" ]; then
   echo 'Creating npm package gerritcodereview-typescript-api-<version>.tgz'
-  ${bazel_bin} run //${api_path}:js_plugin_api_npm_package.pack
+  package_dir="$(${bazel_bin} info bazel-bin)/${api_path}/js_plugin_api_npm_package"
+  tgz="$(npm pack "${package_dir}" --pack-destination "${PWD}" | tail -n1)"
+  echo "Created ${PWD}/${tgz}"
 fi
 
 if [ "$1" == "--upload" ]; then
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index dd01069..85dcbc3 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -287,6 +287,7 @@
   revert?: ActionInfo;
   revert_submission?: ActionInfo;
   abandon?: ActionInfo;
+  restore?: ActionInfo;
   submit?: ActionInfo;
   topic?: ActionInfo;
   hashtags?: ActionInfo;
@@ -553,6 +554,7 @@
   author?: AccountInfo;
   tag?: string;
   unresolved?: boolean;
+  is_ai?: boolean;
   change_message_id?: string;
   commit_id?: string;
   context_lines?: ContextLine[];
@@ -773,6 +775,7 @@
   new_mode?: number;
   old_sha?: string;
   new_sha?: string;
+  diffs_too_expensive_to_compute?: boolean;
 }
 
 /**
@@ -792,6 +795,7 @@
   primary_weblink_name?: string;
   instance_id?: string;
   default_branch?: string;
+  submit_commit_url?: string;
 }
 
 export type GitRef = BrandType<string, '_gitRef'>;
@@ -959,6 +963,15 @@
 export type ParentPatchSet = BrandType<'PARENT', '_patchSet'>;
 
 export const PARENT = 'PARENT' as ParentPatchSet;
+export const FIRST_PARENT = -1 as PatchSetNumber;
+
+/**
+ * Same as `PARENT`, but marks it as an explicit user choice, so that the
+ * `default_base_for_merges` preference does not override it. It is encoded as
+ * `0` in the URL and normalized back to `PARENT` by the change model, so it
+ * never reaches the REST API or any view. See `computeBase()`.
+ */
+export const AUTO_MERGE = 'AUTO_MERGE' as ParentPatchSet;
 
 export type PatchSetNum = PatchSetNumber | ParentPatchSet | EditPatchSet;
 
@@ -1229,6 +1242,16 @@
 }
 
 /**
+ * The SubmittedTogetherInfo entity contains information about a collection of
+ * changes that would be submitted together.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together-info
+ */
+export declare interface SubmittedTogetherInfo {
+  changes: ChangeInfo[];
+  non_visible_changes: number;
+}
+
+/**
  * The SuggestInfo entity contains information about Gerritconfiguration from
  * the suggest section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
@@ -1323,6 +1346,7 @@
   FAIL = 'FAIL',
   ERROR = 'ERROR',
   NOT_EVALUATED = 'NOT_EVALUATED',
+  TIMEOUT = 'TIMEOUT',
 }
 
 /**
@@ -1336,6 +1360,7 @@
   NOT_APPLICABLE = 'NOT_APPLICABLE',
   ERROR = 'ERROR',
   FORCED = 'FORCED',
+  TIMEOUT = 'TIMEOUT',
 }
 
 export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index 764cc48..cc5924c 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -32,6 +32,7 @@
   subPage: Style;
   table: Style;
   modal: Style;
+  changeViewIntegration: Style;
 }
 
 /** Accessible via `window.Gerrit.install(plugin => {plugin.styleApi()})`. */
diff --git a/polygerrit-ui/app/api/tsconfig.publish.json b/polygerrit-ui/app/api/tsconfig.publish.json
new file mode 100644
index 0000000..4402487
--- /dev/null
+++ b/polygerrit-ui/app/api/tsconfig.publish.json
@@ -0,0 +1,4 @@
+{
+  "extends": "./tsconfig-plugins-base.json",
+  "include": ["./**/*.ts"]
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index f51032c..f9bf9f6 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -100,12 +100,22 @@
   REVIEWERS = 'Reviewers',
   REPO = 'Repo',
   BRANCH = 'Branch',
+  HASHTAGS = 'Hashtags',
   UPDATED = 'Updated',
   SIZE = 'Size',
   STATUS = 'Status',
 }
 
 /**
+ * Columns that are shown when the user has not customized their change table
+ * preferences. Columns that are opt-in (such as Hashtags) are excluded here,
+ * but still appear in the settings editor via `ColumnNames`.
+ */
+export const DEFAULT_VISIBLE_COLUMNS: string[] = Object.values(
+  ColumnNames
+).filter(col => col !== ColumnNames.HASHTAGS);
+
+/**
  * @description Modes for gr-diff-cursor
  * The scroll behavior for the cursor. Values are 'never' and
  * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
@@ -283,6 +293,7 @@
     ignore_whitespace: 'IGNORE_NONE',
     line_length: 100,
     line_wrapping: false,
+    responsive_mode: 'NONE',
     show_line_endings: true,
     show_tabs: true,
     show_whitespace_errors: true,
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 6d7eced..96bbcfd 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -99,10 +99,14 @@
   PREVIEW_FIX_LOAD = 'PreviewFixLoad',
   // Time to apply fix for a user suggested edit or a fix from checks
   APPLY_FIX_LOAD = 'ApplyFixLoad',
+  // Time to revert a delta hunk in diff edit mode
+  REVERT_DELTA_LOAD = 'RevertDeltaLoad',
   // Time to copy target to clipboard
   COPY_TO_CLIPBOARD = 'CopyToClipboard',
   // Time to autocomplete a comment
   COMMENT_COMPLETION = 'CommentCompletion',
+  // Time for AI chat requests to complete
+  AI_CHAT_REQUEST = 'AiChatRequest',
 }
 
 export enum Interaction {
@@ -173,22 +177,59 @@
   // AI agent suggests comments/fixes to user.
   AI_AGENT_SUGGESTIONS_SHOWN = 'ai-agent-suggestions-shown',
   // AI agent suggestions are promoted to a draft comment by user.
+  // (Also includes when a user clicks "Please Fix" on checks comment).
   AI_AGENT_SUGGESTION_TO_COMMENT = 'ai-agent-suggestion-to-comment',
+  // AI agent suggestion copy interactions.
+  AI_AGENT_SUGGESTION_COPY_BUTTON_CLICKED = 'ai-agent-suggestion-copy-button-clicked',
+  AI_AGENT_SUGGESTION_CONTENT_COPIED = 'ai-agent-suggestion-content-copied',
+  // AI agent "Get AI Fix" button is clicked by user.
+  AI_AGENT_GET_FIX_CLICKED = 'ai-agent-get-fix-clicked',
 
   FLOWS_TAB_RENDERED = 'flows-tab-rendered',
   CREATE_FLOW_DIALOG_OPENED = 'create-flow-dialog-opened',
   FLOW_CREATED = 'flow-created',
+  // AI Chat interaction request failures
+  AI_CHAT_FAILURE = 'ai-chat-failure',
+  // Revert hunk clicked in diff edit mode
+  REVERT_DELTA_CLICKED = 'revert-delta-clicked',
 }
 
 /**
  * EventDetails to be passed to the reportInteraction method for AI agent
- * interactions.
+ * chat interactions.
  */
-export type AiAgentEventDetails = {
+export type AiAgentChatEventDetails = {
   agentId: string;
   conversationId: string;
   // Each agent response in a conversation is a turn.
   turnIndex: number;
   // commentCount is 0 if agent ran but didn't suggest any comments/fixes.
   commentCount?: number;
+  // The response part ID which identifies the specific suggestion/comment in a turn.
+  suggestionId?: number;
+  // Unique ID of the saved comment draft promoted from the suggestion.
+  commentId?: string;
 };
+
+/**
+ * EventDetails to be passed to the reportInteraction method for AI agent
+ * check interactions.
+ */
+export type AiAgentCheckEventDetails = {
+  // The name of the check that was run.
+  checkName: string;
+  // The description from the check
+  checkDescription: string;
+  // The raw external id for the run result
+  externalId: string;
+  // Unique ID of the saved comment draft promoted from the suggestion.
+  commentId?: string;
+};
+
+/**
+ * EventDetails to be passed to the reportInteraction method for AI agent
+ * interactions.
+ */
+export type AiAgentEventDetails =
+  | AiAgentChatEventDetails
+  | AiAgentCheckEventDetails;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 59e0b40..5ef6959 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -57,10 +57,10 @@
   repoName?: RepoName;
 
   // private but used in test
-  @state() branch = '' as BranchName;
+  @property({type: String}) branch = '' as BranchName;
 
   // private but used in test
-  @state() subject = '';
+  @property({type: String}) subject = '';
 
   // private but used in test
   @state() topic?: string;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 5572fad..dea28a5 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-select/gr-select';
 import {BranchName, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {grFormStyles} from '../../../styles/gr-form-styles';
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 970a6de..ef9294b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -165,6 +165,8 @@
         <gr-copy-clipboard
           id="uuid"
           .text=${this.getGroupUUID()}
+          buttonTitle="Copy Group UUID to clipboard"
+          copyTargetName="Group UUID"
         ></gr-copy-clipboard>
       </fieldset>
     `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 8b4192f..3d5051f 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -57,7 +57,12 @@
               <fieldset>
                 <h3 class="heading-3" id="groupUUID">Group UUID</h3>
                 <fieldset>
-                  <gr-copy-clipboard id="uuid"> </gr-copy-clipboard>
+                  <gr-copy-clipboard
+                    buttontitle="Copy Group UUID to clipboard"
+                    copytargetname="Group UUID"
+                    id="uuid"
+                  >
+                  </gr-copy-clipboard>
                 </fieldset>
                 <h3 class="heading-3" id="groupName">Group Name</h3>
                 <fieldset>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 2632ff5..afd1849 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -96,7 +96,7 @@
   @state() local?: EditableLocalAccessSectionInfo;
 
   // private but used in test
-  @state() editing = false;
+  @property({type: Boolean}) editing = false;
 
   // private but used in test
   @state() modified = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 38f76de..9845490 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -59,6 +59,8 @@
   @property({type: Object})
   params?: RepoViewState;
 
+  @property() newItemName = false;
+
   @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
   @state() isOwner = false;
@@ -79,8 +81,6 @@
 
   @state() refName?: GitRef;
 
-  @state() newItemName = false;
-
   @state() isEditing = false;
 
   @state() revisedRef?: GitRef;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index 49b0ab3..04d0ca5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -7,7 +7,7 @@
 import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import '../../shared/gr-select/gr-select';
+
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
 import {css, html, LitElement} from 'lit';
@@ -149,19 +149,20 @@
       `;
     } else if (option.info.type === ConfigParameterInfoType.LIST) {
       return html`
-        <gr-select
-          .bindValue=${option.info.value}
+        <select
+          data-option-key=${option._key}
           @change=${this.handleListChange}
+          ?disabled=${this.disabled || !option.info.editable}
         >
-          <select
-            data-option-key=${option._key}
-            ?disabled=${this.disabled || !option.info.editable}
-          >
-            ${(option.info.permitted_values || []).map(
-              value => html`<option value=${value}>${value}</option>`
-            )}
-          </select>
-        </gr-select>
+          ${(option.info.permitted_values || []).map(
+            value => html`<option
+              value=${value}
+              ?selected=${value === (option.info.value ?? '')}
+            >
+              ${value}
+            </option>`
+          )}
+        </select>
       `;
     } else if (
       option.info.type === ConfigParameterInfoType.STRING ||
@@ -204,7 +205,7 @@
   }
 
   private handleListChange(e: Event) {
-    const el = e.target as HTMLOptionElement;
+    const el = e.target as HTMLSelectElement;
     // In the template, the data-option-key is assigned to each editor
     const key = el.getAttribute('data-option-key')!;
     const configChangeInfo = this.buildConfigChangeInfo(el.value, key);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements-template-dialog.ts b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements-template-dialog.ts
new file mode 100644
index 0000000..38c4380
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements-template-dialog.ts
@@ -0,0 +1,275 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {RepoName, SubmitRequirementInfo} from '../../../types/common';
+import {firePageError} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+import {grFormStyles} from '../../../styles/gr-form-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import '@material/web/textfield/outlined-text-field';
+import {materialStyles} from '../../../styles/gr-material-styles';
+import '../../shared/gr-button/gr-button';
+import {tableStyles} from '../../../styles/gr-table-styles';
+
+@customElement('gr-repo-submit-requirements-template-dialog')
+export class GrRepoSubmitRequirementsTemplateDialog extends LitElement {
+  @property({type: String})
+  repo?: RepoName;
+
+  @query('#templateDialog')
+  private readonly templateDialog?: HTMLDialogElement;
+
+  @state()
+  templates?: SubmitRequirementInfo[];
+
+  @state()
+  loading = false;
+
+  @state()
+  selectedTemplate?: SubmitRequirementInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      materialStyles,
+      sharedStyles,
+      tableStyles,
+      grFormStyles,
+      modalStyles,
+      css`
+        :host {
+          display: block;
+        }
+        gr-dialog {
+          width: 50em;
+        }
+        gr-dialog .footer {
+          width: 100%;
+          display: flex;
+          justify-content: flex-end;
+        }
+        .template-list {
+          max-height: 400px;
+          overflow-y: auto;
+          border: 1px solid var(--border-color);
+          border-radius: 4px;
+        }
+        .template-item {
+          padding: var(--spacing-m);
+          border-bottom: 1px solid var(--border-color);
+          cursor: pointer;
+          transition: background-color 0.2s;
+        }
+        .template-item:hover {
+          background-color: var(--hover-background-color);
+        }
+        .template-item.selected {
+          background-color: var(--selection-background-color);
+          border-left: 3px solid var(--primary-text-color);
+          padding-left: calc(var(--spacing-m) - 3px);
+        }
+        .template-item-name {
+          font-weight: 600;
+          margin-bottom: var(--spacing-s);
+          color: var(--primary-text-color);
+        }
+        .template-field {
+          display: flex;
+          gap: var(--spacing-s);
+          margin-top: var(--spacing-s);
+          flex-wrap: wrap;
+        }
+        .field-label {
+          font-weight: 500;
+          color: var(--deemphasized-text-color);
+          white-space: nowrap;
+          flex-shrink: 0;
+        }
+        .field-value {
+          color: var(--primary-text-color);
+          word-break: break-word;
+        }
+        .field-value.monospace {
+          font-family: monospace;
+          color: var(--secondary-text-color);
+        }
+        .loading {
+          text-align: center;
+          padding: var(--spacing-l);
+          color: var(--deemphasized-text-color);
+        }
+        .no-templates {
+          padding: var(--spacing-l);
+          text-align: center;
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <dialog id="templateDialog" tabindex="-1">
+        <gr-dialog .cancelLabel=${''} .confirmLabel=${''}>
+          <div class="header" slot="header">Create from Template</div>
+          <div class="main" slot="main">
+            <div class="gr-form-styles">
+              ${when(
+                this.loading,
+                () => html`<div class="loading">Loading templates...</div>`,
+                () =>
+                  html`${when(
+                    !this.templates || this.templates.length === 0,
+                    () => html`<div class="no-templates">
+                      No templates available
+                    </div>`,
+                    () => html`<div class="template-list">
+                      ${this.templates!.map(
+                        template => html`
+                          <div
+                            class="template-item ${this.selectedTemplate
+                              ?.name === template.name
+                              ? 'selected'
+                              : ''}"
+                            @click=${() => this.selectTemplate(template)}
+                          >
+                            <div class="template-item-name">
+                              ${template.name}
+                            </div>
+                            ${when(
+                              template.description,
+                              () => html`
+                                <div class="template-field">
+                                  <span class="field-label">Description:</span>
+                                  <span class="field-value"
+                                    >${template.description}</span
+                                  >
+                                </div>
+                              `
+                            )}
+                            <div class="template-field">
+                              <span class="field-label">Submittability:</span>
+                              <span class="field-value monospace"
+                                >${template.submittability_expression}</span
+                              >
+                            </div>
+                            ${when(
+                              template.applicability_expression,
+                              () => html`
+                                <div class="template-field">
+                                  <span class="field-label"
+                                    >Applicability:</span
+                                  >
+                                  <span class="field-value monospace"
+                                    >${template.applicability_expression}</span
+                                  >
+                                </div>
+                              `
+                            )}
+                            ${when(
+                              template.override_expression,
+                              () => html`
+                                <div class="template-field">
+                                  <span class="field-label">Override:</span>
+                                  <span class="field-value monospace"
+                                    >${template.override_expression}</span
+                                  >
+                                </div>
+                              `
+                            )}
+                          </div>
+                        `
+                      )}
+                    </div>`
+                  )}`
+              )}
+            </div>
+          </div>
+          <div class="footer" slot="footer">
+            <gr-button link @click=${this.handleCancel}>Cancel</gr-button>
+            <gr-button
+              link
+              primary
+              ?disabled=${!this.selectedTemplate}
+              @click=${this.handleConfirm}
+            >
+              Select
+            </gr-button>
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  async show() {
+    this.loading = true;
+    this.selectedTemplate = undefined;
+    this.templates = undefined;
+
+    assertIsDefined(this.templateDialog, 'templateDialog');
+    this.templateDialog.showModal();
+
+    await this.fetchTemplates();
+  }
+
+  private async fetchTemplates() {
+    if (!this.repo) {
+      return;
+    }
+
+    try {
+      const errFn: ErrorCallback = response => {
+        firePageError(response);
+      };
+
+      const templates =
+        await this.restApiService.getRepoSubmitRequirementTemplates(
+          this.repo,
+          errFn
+        );
+      this.templates = templates || [];
+    } catch (e) {
+      console.error('Failed to fetch templates:', e);
+      this.templates = [];
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  private selectTemplate(template: SubmitRequirementInfo) {
+    this.selectedTemplate = template;
+  }
+
+  private handleCancel() {
+    assertIsDefined(this.templateDialog, 'templateDialog');
+    this.templateDialog.close();
+  }
+
+  private handleConfirm() {
+    if (!this.selectedTemplate) return;
+    assertIsDefined(this.templateDialog, 'templateDialog');
+    this.templateDialog.close();
+    this.dispatchEvent(
+      new CustomEvent('template-selected', {
+        detail: {template: this.selectedTemplate},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-submit-requirements-template-dialog': GrRepoSubmitRequirementsTemplateDialog;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
index e40b5b8..88859d0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements.ts
@@ -33,6 +33,8 @@
 import {materialStyles} from '../../../styles/gr-material-styles';
 import '@material/web/checkbox/checkbox';
 import {MdCheckbox} from '@material/web/checkbox/checkbox';
+import './gr-repo-submit-requirements-template-dialog';
+import {GrRepoSubmitRequirementsTemplateDialog} from './gr-repo-submit-requirements-template-dialog';
 
 @customElement('gr-repo-submit-requirements')
 export class GrRepoSubmitRequirements extends LitElement {
@@ -48,6 +50,9 @@
   @query('#deleteDialog')
   private readonly deleteDialog?: HTMLDialogElement;
 
+  @query('gr-repo-submit-requirements-template-dialog')
+  private readonly templateDialog?: GrRepoSubmitRequirementsTemplateDialog;
+
   @state()
   loading = true;
 
@@ -109,6 +114,12 @@
         .createButton {
           margin-left: var(--spacing-m);
         }
+        .select-from-template {
+          margin-bottom: var(--spacing-m);
+        }
+        .template-action-row {
+          margin-bottom: var(--spacing-l);
+        }
         .deleteBtn {
           --gr-button-padding: var(--spacing-s) var(--spacing-m);
         }
@@ -198,6 +209,8 @@
           detail: RepoDetailView.SUBMIT_REQUIREMENTS,
         })}
         @create-clicked=${() => this.handleCreateClick()}
+        @create-from-template-clicked=${() =>
+          this.handleCreateFromTemplateClick()}
       >
         <table id="list" class="genericList">
           <tbody>
@@ -273,6 +286,12 @@
       </gr-list-view>
 
       ${this.renderCreateDialog()} ${this.renderDeleteDialog()}
+      <gr-repo-submit-requirements-template-dialog
+        .repo=${this.repo}
+        @template-selected=${(
+          e: CustomEvent<{template: SubmitRequirementInfo}>
+        ) => this.handleTemplateSelected(e.detail.template)}
+      ></gr-repo-submit-requirements-template-dialog>
     `;
   }
 
@@ -338,6 +357,35 @@
     this.createDialog.showModal();
   }
 
+  private handleSelectFromTemplateClick() {
+    assertIsDefined(this.templateDialog, 'templateDialog');
+    this.createDialog?.close();
+    void this.templateDialog.show();
+  }
+
+  private handleTemplateSelected(template: SubmitRequirementInfo) {
+    this.isEditing = false;
+    this.newRequirement = {
+      name: template.name,
+      description: template.description || '',
+      applicability_expression: template.applicability_expression || '',
+      submittability_expression: template.submittability_expression || '',
+      override_expression: template.override_expression || '',
+      allow_override_in_child_projects:
+        template.allow_override_in_child_projects || false,
+    };
+    assertIsDefined(this.createDialog, 'createDialog');
+    this.createDialog.showModal();
+  }
+
+  private handleCreateFromTemplateClick() {
+    if (!this.templateDialog) {
+      console.error('Template dialog not available');
+      return;
+    }
+    this.templateDialog.show();
+  }
+
   private handleEditClick(requirement: SubmitRequirementInfo) {
     this.isEditing = true;
     this.requirementToEdit = requirement;
@@ -419,6 +467,20 @@
             ${this.isEditing ? 'Edit' : 'Create'} Submit Requirement
           </div>
           <div class="main" slot="main">
+            ${when(
+              !this.isEditing,
+              () => html`
+                <div class="template-action-row">
+                  <gr-button
+                    class="action select-from-template"
+                    link
+                    @click=${this.handleSelectFromTemplateClick}
+                  >
+                    Select from Template
+                  </gr-button>
+                </div>
+              `
+            )}
             <div class="gr-form-styles">
               <div id="form">
                 <section>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts
index 99d0bf2..4de1789 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-submit-requirements/gr-repo-submit-requirements_test.ts
@@ -44,29 +44,31 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `<gr-list-view>
-          <table class="genericList" id="list">
-            <tbody>
-              <tr class="headerRow">
-                <th class="topHeader">Name</th>
-                <th class="topHeader">Description</th>
-                <th class="topHeader">Applicability Expression</th>
-                <th class="topHeader">Submittability Expression</th>
-                <th class="topHeader">Override Expression</th>
-                <th
-                  class="topHeader"
-                  title="Whether override is allowed in child projects"
-                >
-                  Allow Override
-                </th>
-              </tr>
-            </tbody>
-            <tbody id="submit-requirements">
-              <tr id="loadingContainer">
-                <td>Loading...</td>
-              </tr>
-            </tbody>
-          </table>
-        </gr-list-view>`
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="topHeader">Name</th>
+                  <th class="topHeader">Description</th>
+                  <th class="topHeader">Applicability Expression</th>
+                  <th class="topHeader">Submittability Expression</th>
+                  <th class="topHeader">Override Expression</th>
+                  <th
+                    class="topHeader"
+                    title="Whether override is allowed in child projects"
+                  >
+                    Allow Override
+                  </th>
+                </tr>
+              </tbody>
+              <tbody id="submit-requirements">
+                <tr id="loadingContainer">
+                  <td>Loading...</td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+          <gr-repo-submit-requirements-template-dialog>
+          </gr-repo-submit-requirements-template-dialog>`
       );
     });
 
@@ -108,6 +110,8 @@
               </tbody>
             </table>
           </gr-list-view>
+          <gr-repo-submit-requirements-template-dialog>
+          </gr-repo-submit-requirements-template-dialog>
         `
       );
     });
@@ -177,6 +181,17 @@
             <gr-dialog>
               <div class="header" slot="header">Create Submit Requirement</div>
               <div class="main" slot="main">
+                <div class="template-action-row">
+                  <gr-button
+                    aria-disabled="false"
+                    class="action select-from-template"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Select from Template
+                  </gr-button>
+                </div>
                 <div class="gr-form-styles">
               <div id="form">
                 <section>
@@ -374,10 +389,52 @@
           </div>
         </gr-dialog>
       </dialog>
+      <gr-repo-submit-requirements-template-dialog>
+      </gr-repo-submit-requirements-template-dialog>
     `
       );
     });
 
+    test('template-selected event pre-populates create form', async () => {
+      await waitEventLoop();
+      element.isProjectOwner = true;
+      await element.updateComplete;
+
+      const templateDialog = queryAndAssert<HTMLElement>(
+        element,
+        'gr-repo-submit-requirements-template-dialog'
+      );
+      templateDialog.dispatchEvent(
+        new CustomEvent('template-selected', {
+          detail: {
+            template: {
+              name: 'Template-Verified',
+              description: 'Template description',
+              applicability_expression: '-branch:refs/meta/config',
+              submittability_expression: 'label:Verified=+1',
+              override_expression: 'ownerin:Project-Owners',
+              allow_override_in_child_projects: true,
+            } as SubmitRequirementInfo,
+          },
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await element.updateComplete;
+
+      const dialog = queryAndAssert<HTMLDialogElement>(
+        element,
+        '#createDialog'
+      );
+      assert.isTrue(dialog.open);
+      assert.equal(element.newRequirement.name, 'Template-Verified');
+      assert.equal(
+        element.newRequirement.submittability_expression,
+        'label:Verified=+1'
+      );
+      assert.isTrue(element.newRequirement.allow_override_in_child_projects);
+    });
+
     test('open edit dialog', async () => {
       await waitEventLoop();
       element.isProjectOwner = true;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index bc48aac..e4fc88d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -512,8 +512,8 @@
     return html`
       <section>
         <span class="title">
-          Reject implicit merges when changes are pushed for review</span
-        >
+          Reject implicit merges when changes are uploaded or submitted
+        </span>
         <span class="value">
           <md-outlined-select
             id="rejectImplicitMergesSelect"
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 2f1bf8b..d974130 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -383,7 +383,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -423,7 +423,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -463,7 +463,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -506,7 +506,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -549,7 +549,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -561,7 +561,7 @@
                  </section>
                  <section>
                    <span class="title">
-                     Reject implicit merges when changes are pushed for review
+                     Reject implicit merges when changes are uploaded or submitted
                    </span>
                    <span class="value">
                      <md-outlined-select
@@ -589,7 +589,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -629,7 +629,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -669,7 +669,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -709,7 +709,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -766,7 +766,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -806,7 +806,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -854,7 +854,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
@@ -894,7 +894,7 @@
                        </md-select-option>
                        <md-select-option
                          md-menu-item=""
-                         tabindex="-1"
+                         tabindex="0"
                          value="FALSE"
                        >
                          <div slot="headline">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
index 98781a0..3f8520e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -45,13 +45,15 @@
   }
 
   override render() {
+    const restore = this.isRestoreMode();
+    const action = restore ? 'restore' : 'abandon';
     return html`
       <gr-button
-        id="abandon"
+        id=${action}
         flatten
         .disabled=${!this.isEnabled()}
         @click=${() => this.actionModal.showModal()}
-        >Abandon</gr-button
+        >${restore ? 'Restore' : 'Abandon'}</gr-button
       >
       <dialog id="actionModal" tabindex="-1">
         <gr-dialog
@@ -62,7 +64,7 @@
           .cancelLabel=${'Close'}
         >
           <div slot="header">
-            ${this.selectedChanges.length} changes to abandon
+            ${this.selectedChanges.length} changes to ${action}
           </div>
           <div slot="main">
             <table>
@@ -97,7 +99,24 @@
       : ProgressStatus.NOT_STARTED;
   }
 
+  /**
+   * The button acts as a toggle: when every selected change is abandoned it
+   * offers to restore them, otherwise it offers to abandon them. Changes that
+   * are already in the target state are skipped by the model.
+   */
+  private isRestoreMode() {
+    return (
+      this.selectedChanges.length > 0 &&
+      this.selectedChanges.every(
+        change => change.status === ChangeStatus.ABANDONED
+      )
+    );
+  }
+
   private isEnabled() {
+    if (this.isRestoreMode()) {
+      return this.selectedChanges.every(change => !!change.actions?.restore);
+    }
     return this.selectedChanges.every(
       change =>
         !!change.actions?.abandon || change.status === ChangeStatus.ABANDONED
@@ -129,7 +148,10 @@
     const errFn = (changeNum: NumericChangeId) => {
       throw new Error(`request for ${changeNum} failed`);
     };
-    const promises = this.getBulkActionsModel().abandonChanges('', errFn);
+    const model = this.getBulkActionsModel();
+    const promises = this.isRestoreMode()
+      ? model.restoreChanges('', errFn)
+      : model.abandonChanges('', errFn);
     for (let index = 0; index < promises.length; index++) {
       const changeNum = this.selectedChanges[index]._number;
       promises[index]
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index d99a7c4..dc9db6f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -143,7 +143,69 @@
     assert.isTrue(queryAndAssert<GrButton>(element, '#abandon').disabled);
   });
 
-  test('abandon button is enabled if change is already abandoned', async () => {
+  test('mixed open/abandoned selection shows Abandon', async () => {
+    const changes: ChangeInfo[] = [
+      {...change1, actions: {abandon: {}}},
+      {...change2, actions: {restore: {}}, status: ChangeStatus.ABANDONED},
+    ];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.isNotOk(query(element, '#restore'));
+    const button = queryAndAssert<GrButton>(element, '#abandon');
+    assert.isFalse(button.disabled);
+    assert.equal(button.innerText.trim(), 'Abandon');
+  });
+
+  test('restore button is shown if all changes are abandoned', async () => {
+    const changes: ChangeInfo[] = [
+      {...change1, actions: {restore: {}}, status: ChangeStatus.ABANDONED},
+    ];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.isNotOk(query(element, '#abandon'));
+    const button = queryAndAssert<GrButton>(element, '#restore');
+    assert.isFalse(button.disabled);
+    assert.equal(button.innerText.trim(), 'Restore');
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(element, 'div[slot="header"]')
+        .innerText.replace(/\s+/g, ' ')
+        .trim(),
+      '1 changes to restore'
+    );
+
+    const executeChangeAction = stubRestApi('executeChangeAction').returns(
+      Promise.resolve(new Response())
+    );
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () =>
+        queryAndAssert<HTMLTableDataCellElement>(
+          element,
+          '#status'
+        ).innerText.trim() === `Status: ${ProgressStatus.SUCCESSFUL}`
+    );
+    assert.equal(executeChangeAction.callCount, 1);
+    assert.equal(executeChangeAction.lastCall.args[2], '/restore');
+  });
+
+  test('restore button is disabled without restore permission', async () => {
     const changes: ChangeInfo[] = [
       {...change1, actions: {}, status: ChangeStatus.ABANDONED},
     ];
@@ -156,17 +218,7 @@
     await selectChange(change1);
     await element.updateComplete;
 
-    assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
-
-    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
-
-    await waitUntil(
-      () =>
-        queryAndAssert<HTMLTableDataCellElement>(
-          element,
-          '#status'
-        ).innerText.trim() === `Status: ${ProgressStatus.SUCCESSFUL}`
-    );
+    assert.isTrue(queryAndAssert<GrButton>(element, '#restore').disabled);
   });
 
   test('progress updates as request is resolved', async () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow.ts
index e8eca8a..cae9b44 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow.ts
@@ -89,7 +89,9 @@
           id="copyLinkButton"
           link
           @click=${(e: Event) => {
-            this.copyLinks?.toggleDropdown(e.target as HTMLElement);
+            this.copyLinks?.toggleDropdown(
+              (e.currentTarget ?? e.target) as HTMLElement
+            );
           }}
         >
           Copy Link
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow_screenshot_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow_screenshot_test.ts
new file mode 100644
index 0000000..3b446c5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow_screenshot_test.ts
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-change-list-copy-link-flow';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {GrChangeListCopyLinkFlow} from './gr-change-list-copy-link-flow';
+import {visualDiffDarkTheme, waitUntil} from '../../../test/test-utils';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {waitUntilObserved} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrCopyLinks} from '../../change/gr-copy-links/gr-copy-links';
+
+const change1: ChangeInfo = {
+  ...createChange(),
+  _number: 1 as NumericChangeId,
+  subject: 'Avoid O(P^2) patchset scan for ChangeKind in RevisionJson',
+};
+const change2: ChangeInfo = {
+  ...createChange(),
+  _number: 2 as NumericChangeId,
+  subject: 'Defer submission index query in RevertSubmission#getDescription',
+};
+const change3: ChangeInfo = {
+  ...createChange(),
+  _number: 3 as NumericChangeId,
+  subject: 'Reuse RevWalk in BaseCommitUtil and DiffOperationsImpl',
+};
+const change4: ChangeInfo = {
+  ...createChange(),
+  _number: 4 as NumericChangeId,
+  subject: 'Honor SKIP_DIFFSTAT when formatting revision FileInfo maps',
+};
+const change5: ChangeInfo = {
+  ...createChange(),
+  _number: 5 as NumericChangeId,
+  subject: 'Reuse PermissionBackend.ForChange across labels in LabelsJson',
+};
+const changes = [change1, change2, change3, change4, change5];
+
+suite('gr-change-list-copy-link-flow screenshot tests', () => {
+  let element: GrChangeListCopyLinkFlow;
+  let model: BulkActionsModel;
+  let container: HTMLElement;
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    model.sync(changes);
+
+    container = await fixture(html`
+      <div
+        style="width: 700px; padding: 200px 50px 400px 50px; display: flex; justify-content: flex-end;"
+      >
+        ${wrapInProvider(
+          html`<gr-change-list-copy-link-flow></gr-change-list-copy-link-flow>`,
+          bulkActionsModelToken,
+          model
+        )}
+      </div>
+    `);
+
+    element = container.querySelector('gr-change-list-copy-link-flow')!;
+
+    for (const change of changes) {
+      model.addSelectedChangeNum(change._number);
+    }
+    await waitUntilObserved(model.selectedChanges$, s => s.length === 5);
+    await element.updateComplete;
+
+    const copyLinkButton = queryAndAssert<GrButton>(element, '#copyLinkButton');
+    copyLinkButton.click();
+    await element.updateComplete;
+    const copyLinks = queryAndAssert<GrCopyLinks>(element, 'gr-copy-links');
+    await waitUntil(() => copyLinks.isDropdownOpen);
+    await element.updateComplete;
+  });
+
+  test('copy link dropdown open', async () => {
+    await visualDiff(container, 'gr-change-list-copy-link-flow-open');
+    await visualDiffDarkTheme(container, 'gr-change-list-copy-link-flow-open');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index fbd5dc1..8df9de1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -22,6 +22,7 @@
 import {
   AccountInfo,
   ChangeInfo,
+  Hashtag,
   NumericChangeId,
   ServerInfo,
   Timestamp,
@@ -101,6 +102,9 @@
   sectionName?: string;
 
   @property({type: Boolean})
+  starsLoading = false;
+
+  @property({type: Boolean})
   showNumber = false;
 
   @property({type: String})
@@ -240,6 +244,9 @@
         .requirements {
           white-space: nowrap;
         }
+        .hashtags a.hashtag:not(:last-of-type) {
+          margin-right: var(--spacing-s);
+        }
         .reviewers {
           --account-max-length: 70px;
         }
@@ -346,9 +353,9 @@
       ${this.renderCellNumber(changeUrl)} ${this.renderCellSubject(changeUrl)}
       ${this.renderCellOwner()} ${this.renderCellReviewers()}
       ${this.renderCellRepo()} ${this.renderCellBranch()}
-      ${this.renderCellUpdated()} ${this.renderCellSubmitted()}
-      ${this.renderCellWaiting()} ${this.renderCellSize()}
-      ${this.renderCellRequirements()}
+      ${this.renderCellHashtags()} ${this.renderCellUpdated()}
+      ${this.renderCellSubmitted()} ${this.renderCellWaiting()}
+      ${this.renderCellSize()} ${this.renderCellRequirements()}
       ${this.labelNames?.map(labelNames => this.renderChangeLabels(labelNames))}
       ${this.dynamicCellEndpoints?.map(pluginEndpointName =>
         this.renderChangePluginEndpoint(pluginEndpointName)
@@ -376,7 +383,10 @@
 
     return html`
       <td class="cell star">
-        <gr-change-star .change=${this.change}></gr-change-star>
+        <gr-change-star
+          .change=${this.change}
+          .loading=${this.starsLoading}
+        ></gr-change-star>
       </td>
     `;
   }
@@ -582,6 +592,30 @@
     `;
   }
 
+  private renderCellHashtags() {
+    if (this.computeIsColumnHidden(ColumnNames.HASHTAGS)) return;
+
+    return html`
+      <td class="cell hashtags">
+        ${(this.change?.hashtags ?? []).map(hashtag =>
+          this.renderChangeHashtag(hashtag)
+        )}
+      </td>
+    `;
+  }
+
+  private renderChangeHashtag(hashtag: Hashtag) {
+    return html`
+      <a class="hashtag" href=${this.computeHashtagUrl(hashtag)}>
+        <gr-limited-text .limit=${25} .text=${hashtag}></gr-limited-text>
+      </a>
+    `;
+  }
+
+  private computeHashtagUrl(hashtag: Hashtag) {
+    return createSearchUrl({hashtag, statuses: ['open', 'merged']});
+  }
+
   private renderChangeLabels(labelName: string) {
     return html` <td class="cell label requirement">
       <gr-change-list-column-requirement
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index c62d185..d8d5f54 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -30,6 +30,7 @@
   AccountId,
   BranchName,
   ChangeInfo,
+  Hashtag,
   RepoName,
   TopicName,
 } from '../../../types/common';
@@ -90,6 +91,7 @@
       ColumnNames.UPDATED,
       ColumnNames.SIZE,
       ColumnNames.STATUS,
+      ColumnNames.HASHTAGS,
     ];
 
     await element.updateComplete;
@@ -219,6 +221,7 @@
       ColumnNames.UPDATED,
       ColumnNames.SIZE,
       ColumnNames.STATUS,
+      ColumnNames.HASHTAGS,
     ];
 
     await element.updateComplete;
@@ -233,6 +236,56 @@
     }
   });
 
+  test('hashtags cell not rendered when column is not visible', async () => {
+    element.visibleChangeTableColumns = [
+      ColumnNames.SUBJECT,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS,
+    ];
+    element.change = {
+      ...createChange(),
+      hashtags: ['runway' as Hashtag, 'stability' as Hashtag],
+    };
+
+    await element.updateComplete;
+
+    assert.isNotOk(query(element, '.hashtags'));
+  });
+
+  test('renders hashtags as links to hashtag search', async () => {
+    element.visibleChangeTableColumns = [
+      ColumnNames.SUBJECT,
+      ColumnNames.HASHTAGS,
+    ];
+    element.change = {
+      ...createChange(),
+      hashtags: ['runway' as Hashtag, 'stability' as Hashtag],
+    };
+
+    await element.updateComplete;
+
+    const cell = queryAndAssert(element, '.cell.hashtags');
+    const links = cell.querySelectorAll<HTMLAnchorElement>('a.hashtag');
+    assert.equal(links.length, 2);
+    assert.equal(
+      links[0].getAttribute('href'),
+      '/q/hashtag:"runway"+(status:open OR status:merged)'
+    );
+    assert.equal(
+      links[1].getAttribute('href'),
+      '/q/hashtag:"stability"+(status:open OR status:merged)'
+    );
+    const texts = cell.querySelectorAll('gr-limited-text');
+    assert.equal(texts.length, 2);
+    assert.equal(texts[0].text, 'runway');
+    assert.equal(texts[1].text, 'stability');
+  });
+
   function checkComputeReviewers(
     userId: number | undefined,
     reviewerIds: number[],
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 53bef3c..89673eb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -115,6 +115,7 @@
           display: grid;
           grid-template-columns: min-content 1fr;
           column-gap: var(--spacing-l);
+          align-items: center;
         }
         gr-account-list {
           display: flex;
@@ -128,14 +129,11 @@
           padding: var(--spacing-l);
           padding-left: var(--spacing-xl);
           background-color: var(--yellow-50);
+          margin-top: var(--spacing-l);
         }
         .error {
           background-color: var(--error-background);
         }
-        .grid + .warning,
-        .error {
-          margin-top: var(--spacing-l);
-        }
         .warning + .warning {
           margin-top: var(--spacing-s);
         }
@@ -218,6 +216,8 @@
             <span>CC</span>
             ${this.renderAccountList(ReviewerState.CC, 'cc-list', 'Add CC')}
           </div>
+          ${this.renderConfirmationDialog(ReviewerState.REVIEWER)}
+          ${this.renderConfirmationDialog(ReviewerState.CC)}
           ${this.renderAnyOverwriteWarnings()} ${this.renderErrors()}
         </div>
       </gr-dialog>
@@ -252,7 +252,6 @@
         ) => this.onPendingConfirmationChanged(reviewerState, ev)}
       >
       </gr-account-list>
-      ${this.renderConfirmationDialog(reviewerState)}
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_screenshot_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_screenshot_test.ts
new file mode 100644
index 0000000..4d3614d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_screenshot_test.ts
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-change-list-reviewer-flow';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {GrChangeListReviewerFlow} from './gr-change-list-reviewer-flow';
+import {visualDiffDarkTheme} from '../../../test/test-utils';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+} from '../../../test/test-data-generators';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {stubRestApi, waitUntilObserved} from '../../../test/test-utils';
+import {AccountInfo, ChangeInfo, NumericChangeId} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {queryAndAssert} from '../../../utils/common-util';
+import {ReviewerState} from '../../../constants/constants';
+
+const accounts: AccountInfo[] = [
+  createAccountWithIdNameAndEmail(0),
+  createAccountWithIdNameAndEmail(1),
+  createAccountWithIdNameAndEmail(2),
+  createAccountWithIdNameAndEmail(3),
+];
+
+const changes: ChangeInfo[] = [
+  {
+    ...createChange(),
+    _number: 1 as NumericChangeId,
+    subject: 'Subject 1',
+    owner: accounts[0],
+    reviewers: {},
+  },
+  {
+    ...createChange(),
+    _number: 2 as NumericChangeId,
+    subject: 'Subject 2',
+    owner: accounts[0],
+    reviewers: {},
+  },
+];
+
+suite('gr-change-list-reviewer-flow screenshot tests', () => {
+  let element: GrChangeListReviewerFlow;
+  let model: BulkActionsModel;
+
+  setup(async () => {
+    stubRestApi('getDetailedChangesWithActions').resolves(changes);
+    model = new BulkActionsModel(getAppContext().restApiService);
+    model.sync(changes);
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-reviewer-flow')!;
+
+    model.addSelectedChangeNum(changes[0]._number);
+    model.addSelectedChangeNum(changes[1]._number);
+    await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+    await element.updateComplete;
+
+    const startButton = queryAndAssert<GrButton>(
+      element,
+      'gr-button#start-flow'
+    );
+    startButton.click();
+    await element.updateComplete;
+  });
+
+  test('empty reviewer flow dialog', async () => {
+    const dialog = queryAndAssert(element, '#flow');
+    await visualDiff(dialog, 'gr-change-list-reviewer-flow-empty');
+    await visualDiffDarkTheme(dialog, 'gr-change-list-reviewer-flow-empty');
+  });
+
+  test('reviewer flow dialog with reviewers', async () => {
+    element.updatedAccountsByReviewerState.set(ReviewerState.REVIEWER, [
+      accounts[1],
+      accounts[2],
+    ]);
+    element.requestUpdate();
+    await element.updateComplete;
+
+    const dialog = queryAndAssert(element, '#flow');
+    await visualDiff(dialog, 'gr-change-list-reviewer-flow-with-reviewers');
+    await visualDiffDarkTheme(
+      dialog,
+      'gr-change-list-reviewer-flow-with-reviewers'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index fa8ffba..176c411 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -206,63 +206,47 @@
                 <div class="grid">
                   <span>Reviewers</span>
                   <gr-account-list id="reviewer-list"></gr-account-list>
-                  <dialog id="confirm-reviewer" tabindex="-1">
-                    <div class="confirmation-text">
-                      Group
-                      <span class="groupName"></span>
-                      has
-                      <span class="groupSize"></span>
-                      members.
-                      <br />
-                      Are you sure you want to add them all?
-                    </div>
-                    <div class="confirmation-buttons">
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        Yes
-                      </gr-button>
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        No
-                      </gr-button>
-                    </div>
-                  </dialog>
                   <span>CC</span>
                   <gr-account-list id="cc-list"></gr-account-list>
-                  <dialog id="confirm-cc" tabindex="-1">
-                    <div class="confirmation-text">
-                      Group
-                      <span class="groupName"></span>
-                      has
-                      <span class="groupSize"></span>
-                      members.
-                      <br />
-                      Are you sure you want to add them all?
-                    </div>
-                    <div class="confirmation-buttons">
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        Yes
-                      </gr-button>
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        No
-                      </gr-button>
-                    </div>
-                  </dialog>
                 </div>
+                <dialog id="confirm-reviewer" tabindex="-1">
+                  <div class="confirmation-text">
+                    Group
+                    <span class="groupName"></span>
+                    has
+                    <span class="groupSize"></span>
+                    members.
+                    <br />
+                    Are you sure you want to add them all?
+                  </div>
+                  <div class="confirmation-buttons">
+                    <gr-button aria-disabled="false" role="button" tabindex="0">
+                      Yes
+                    </gr-button>
+                    <gr-button aria-disabled="false" role="button" tabindex="0">
+                      No
+                    </gr-button>
+                  </div>
+                </dialog>
+                <dialog id="confirm-cc" tabindex="-1">
+                  <div class="confirmation-text">
+                    Group
+                    <span class="groupName"></span>
+                    has
+                    <span class="groupSize"></span>
+                    members.
+                    <br />
+                    Are you sure you want to add them all?
+                  </div>
+                  <div class="confirmation-buttons">
+                    <gr-button aria-disabled="false" role="button" tabindex="0">
+                      Yes
+                    </gr-button>
+                    <gr-button aria-disabled="false" role="button" tabindex="0">
+                      No
+                    </gr-button>
+                  </div>
+                </dialog>
               </div>
             </gr-dialog>
             <div id="gr-hovercard-container"></div>
@@ -637,59 +621,59 @@
                 <div class="grid">
                   <span>Reviewers</span>
                   <gr-account-list id="reviewer-list"></gr-account-list>
-                  <dialog tabindex="-1" id="confirm-reviewer">
-                    <div class="confirmation-text">
-                      Group
-                      <span class="groupName"></span>
-                      has
-                      <span class="groupSize"></span>
-                      members.
-                      <br>
-                      Are you sure you want to add them all?
-                    </div>
-                    <div class="confirmation-buttons">
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0">
-                        Yes
-                      </gr-button>
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0">
-                        No
-                      </gr-button>
-                    </div>
-                  </dialog>
                   <span>CC</span>
                   <gr-account-list id="cc-list"></gr-account-list>
-                  <dialog tabindex="-1" id="confirm-cc">
-                    <div class="confirmation-text">
-                      Group
-                      <span class="groupName"></span>
-                      has
-                      <span class="groupSize"></span>
-                      members.
-                      <br>
-                      Are you sure you want to add them all?
-                    </div>
-                    <div class="confirmation-buttons">
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0">
-                        Yes
-                      </gr-button>
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0">
-                        No
-                      </gr-button>
-                    </div>
-                  </dialog>
                 </div>
+                <dialog tabindex="-1" id="confirm-reviewer">
+                  <div class="confirmation-text">
+                    Group
+                    <span class="groupName"></span>
+                    has
+                    <span class="groupSize"></span>
+                    members.
+                    <br>
+                    Are you sure you want to add them all?
+                  </div>
+                  <div class="confirmation-buttons">
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0">
+                      Yes
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0">
+                      No
+                    </gr-button>
+                  </div>
+                </dialog>
+                <dialog tabindex="-1" id="confirm-cc">
+                  <div class="confirmation-text">
+                    Group
+                    <span class="groupName"></span>
+                    has
+                    <span class="groupSize"></span>
+                    members.
+                    <br>
+                    Are you sure you want to add them all?
+                  </div>
+                  <div class="confirmation-buttons">
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0">
+                      Yes
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0">
+                      No
+                    </gr-button>
+                  </div>
+                </dialog>
                 <div class="warning">
                   <gr-icon icon="warning" filled role="img" aria-label="Warning"
                   ></gr-icon>
@@ -757,63 +741,63 @@
                 <div class="grid">
                   <span> Reviewers </span>
                   <gr-account-list id="reviewer-list"> </gr-account-list>
-                  <dialog tabindex="-1" id="confirm-reviewer">
-                    <div class="confirmation-text">
-                      Group
-                      <span class="groupName"> </span>
-                      has
-                      <span class="groupSize"> </span>
-                      members.
-                      <br />
-                      Are you sure you want to add them all?
-                    </div>
-                    <div class="confirmation-buttons">
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        Yes
-                      </gr-button>
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        No
-                      </gr-button>
-                    </div>
-                  </dialog>
                   <span> CC </span>
                   <gr-account-list id="cc-list"> </gr-account-list>
-                  <dialog tabindex="-1" id="confirm-cc">
-                    <div class="confirmation-text">
-                      Group
-                      <span class="groupName"> </span>
-                      has
-                      <span class="groupSize"> </span>
-                      members.
-                      <br />
-                      Are you sure you want to add them all?
-                    </div>
-                    <div class="confirmation-buttons">
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        Yes
-                      </gr-button>
-                      <gr-button
-                        aria-disabled="false"
-                        role="button"
-                        tabindex="0"
-                      >
-                        No
-                      </gr-button>
-                    </div>
-                  </dialog>
                 </div>
+                <dialog tabindex="-1" id="confirm-reviewer">
+                  <div class="confirmation-text">
+                    Group
+                    <span class="groupName"> </span>
+                    has
+                    <span class="groupSize"> </span>
+                    members.
+                    <br />
+                    Are you sure you want to add them all?
+                  </div>
+                  <div class="confirmation-buttons">
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0"
+                    >
+                      Yes
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0"
+                    >
+                      No
+                    </gr-button>
+                  </div>
+                </dialog>
+                <dialog tabindex="-1" id="confirm-cc">
+                  <div class="confirmation-text">
+                    Group
+                    <span class="groupName"> </span>
+                    has
+                    <span class="groupSize"> </span>
+                    members.
+                    <br />
+                    Are you sure you want to add them all?
+                  </div>
+                  <div class="confirmation-buttons">
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0"
+                    >
+                      Yes
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      role="button"
+                      tabindex="0"
+                    >
+                      No
+                    </gr-button>
+                  </div>
+                </dialog>
                 <div class="error">
                   <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
                   Failed to add User-0, User-2, Group 0, and User-3 to changes.
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 0e9cf7b..d3731c9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -77,6 +77,9 @@
   @property({type: Boolean})
   isCursorMoving = false;
 
+  @property({type: Boolean})
+  starsLoading = false;
+
   /**
    * The logged-in user's account, or an empty object if no user is logged
    * in.
@@ -371,6 +374,7 @@
         .selected=${selected}
         .change=${change}
         .sectionName=${this.changeSection.name}
+        .starsLoading=${this.starsLoading}
         .visibleChangeTableColumns=${columns}
         .showNumber=${!!this.showNumber}
         .usp=${this.usp}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index 16fc638..ae2d8bb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -77,7 +77,7 @@
         </md-checkbox>
       </td>
       #
-              SubjectOwnerReviewersRepoBranchUpdatedSizeStatus
+              SubjectOwnerReviewersRepoBranchHashtagsUpdatedSizeStatus
       <gr-change-list-item
         aria-label="Test subject, section: test"
         role="button"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 933d933..503d478 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -17,7 +17,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
-import {customElement, query, state} from 'lit/decorators.js';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   createSearchUrl,
   searchViewModelToken,
@@ -49,7 +49,7 @@
   @state() changesPerPage?: number;
 
   // private but used in test
-  @state() query = '';
+  @property({type: String}) query = '';
 
   // private but used in test
   @state() offset = 0;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 061bd3d..b56a80b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -18,7 +18,11 @@
   UserId,
 } from '../../../types/common';
 import {fire, fireReload} from '../../../utils/event-util';
-import {ColumnNames, ScrollMode} from '../../../constants/constants';
+import {
+  ColumnNames,
+  DEFAULT_VISIBLE_COLUMNS,
+  ScrollMode,
+} from '../../../constants/constants';
 import {
   getRequirements,
   orderSubmitRequirementNames,
@@ -32,7 +36,10 @@
 import {customElement, property, state} from 'lit/decorators.js';
 import {Shortcut, ShortcutController} from '../../lit/shortcut-controller';
 import {queryAll} from '../../../utils/common-util';
-import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
+import {
+  computeLabelShortcut,
+  GrChangeListSection,
+} from '../gr-change-list-section/gr-change-list-section';
 import {ValueChangedEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
@@ -115,9 +122,15 @@
   @property({type: Boolean})
   showNumber?: boolean; // No default value to prevent flickering.
 
+  @property({type: Array})
+  labelFilter: string[] = []; // empty = show all
+
   @property({type: Boolean})
   showReviewedState = false;
 
+  @property({type: Boolean})
+  starsLoading = false;
+
   @property({type: Array})
   changeTableColumns?: string[];
 
@@ -277,6 +290,7 @@
         .isCursorMoving=${this.isCursorMoving}
         .loggedInUser=${this.loggedInUser}
         .dashboardUser=${this.dashboardUser}
+        .starsLoading=${this.starsLoading}
         .selectedIndex=${computeRelativeIndex(
           this.selectedIndex,
           sectionIndex,
@@ -351,7 +365,7 @@
 
     this.changeTableColumns = Object.values(ColumnNames);
     this.showNumber = false;
-    this.visibleChangeTableColumns = Object.values(ColumnNames);
+    this.visibleChangeTableColumns = [...DEFAULT_VISIBLE_COLUMNS];
     if (this.loggedInUser && this.preferences) {
       this.showNumber = !!this.preferences?.legacycid_in_change_table;
       const prefColumns = changeTablePrefs(this.preferences);
@@ -359,26 +373,42 @@
       this.visibleChangeTableColumns = Object.values(ColumnNames).filter(col =>
         prefColumns.includes(col)
       );
+      this.labelFilter = this.preferences.label_filter
+        ? this.preferences.label_filter
+            .split(',')
+            .map(s => s.trim())
+            .filter(s => s.length > 0)
+        : [];
     }
   }
 
-  // private but used in test
   computeLabelNames(sections: ChangeListSection[]) {
     if (!sections) return [];
+
+    let allLabels: string[];
     if (this.config?.submit_requirement_dashboard_columns?.length) {
-      return this.config?.submit_requirement_dashboard_columns;
-    }
-    const changes = sections.map(section => section.results).flat();
-    let labels: string[] = [];
-    if (this.config?.dashboard_show_all_labels) {
-      labels = changes.map(change => Object.keys(change.labels ?? {})).flat();
+      allLabels = this.config.submit_requirement_dashboard_columns;
     } else {
-      labels = changes
-        .map(change => getRequirements(change))
-        .flat()
-        .map(requirement => requirement.name);
+      const changes = sections.map(section => section.results).flat();
+      let labels: string[] = [];
+      if (this.config?.dashboard_show_all_labels) {
+        labels = changes.map(change => Object.keys(change.labels ?? {})).flat();
+      } else {
+        labels = changes
+          .map(change => getRequirements(change))
+          .flat()
+          .map(requirement => requirement.name);
+      }
+      allLabels = orderSubmitRequirementNames(labels.filter(unique));
     }
-    return orderSubmitRequirementNames(labels.filter(unique));
+
+    if (this.labelFilter.length === 0) return allLabels;
+    const normalizedFilter = this.labelFilter.map(f => f.toLowerCase());
+    return allLabels.filter(
+      l =>
+        normalizedFilter.includes(l.toLowerCase()) ||
+        normalizedFilter.includes(computeLabelShortcut(l).toLowerCase())
+    );
   }
 
   private changesChanged() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 477e75b..3419ee0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -22,6 +22,7 @@
 } from '../../../test/test-utils';
 import {Key} from '../../../utils/dom-util';
 import {
+  ColumnNames,
   createDefaultPreferences,
   TimeFormat,
 } from '../../../constants/constants';
@@ -465,8 +466,8 @@
       assert.isTrue(element.showNumber);
     });
 
-    test('all columns visible', () => {
-      for (const column of element.changeTableColumns!) {
+    test('all default columns visible', () => {
+      for (const column of element.visibleChangeTableColumns!) {
         const elementClass = '.' + column.trim().toLowerCase();
         const section = queryAndAssert(element, 'gr-change-list-section');
         assert.isFalse(
@@ -474,6 +475,15 @@
         );
       }
     });
+
+    test('hashtags column is not visible by default', () => {
+      assert.notInclude(
+        element.visibleChangeTableColumns!,
+        ColumnNames.HASHTAGS
+      );
+      const section = queryAndAssert(element, 'gr-change-list-section');
+      assert.isNotOk(query<HTMLElement>(section, '.hashtags'));
+    });
   });
 
   suite('full column preference', () => {
@@ -496,6 +506,7 @@
           'Branch',
           'Updated',
           'Size',
+          'Hashtags',
         ],
       };
       element.config = createServerInfo();
@@ -532,6 +543,7 @@
           'Branch',
           'Updated',
           'Size',
+          'Hashtags',
         ],
       };
       element.config = createServerInfo();
@@ -622,4 +634,115 @@
 
     assert.isNotOk(query<HTMLElement>(element, '.bad'));
   });
+
+  test('show all labels when no label filter set', () => {
+    element.labelFilter = [];
+    element.config = createServerInfo();
+    const sections: ChangeListSection[] = [
+      {
+        results: [
+          {
+            ...createChange(),
+            _number: 0 as NumericChangeId,
+            submit_requirements: [
+              {
+                ...createSubmitRequirementResultInfo(),
+                name: 'Code-Review',
+              },
+              {
+                ...createSubmitRequirementResultInfo(),
+                name: 'Verified',
+              },
+            ],
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(element.computeLabelNames(sections), [
+      'Code-Review',
+      'Verified',
+    ]);
+  });
+
+  test('show only filtered labels when label filter set', () => {
+    element.labelFilter = ['Code-Review'];
+    element.config = createServerInfo();
+    const sections: ChangeListSection[] = [
+      {
+        results: [
+          {
+            ...createChange(),
+            _number: 0 as NumericChangeId,
+            submit_requirements: [
+              {
+                ...createSubmitRequirementResultInfo(),
+                name: 'Code-Review',
+              },
+              {
+                ...createSubmitRequirementResultInfo(),
+                name: 'Verified',
+              },
+            ],
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(element.computeLabelNames(sections), ['Code-Review']);
+  });
+  test('update labels shown according to user preference', async () => {
+    element.loggedInUser = {_account_id: 1001 as AccountId};
+    element.config = createServerInfo();
+
+    // No label filter — show all
+    userModel.setPreferences({
+      ...createDefaultPreferences(),
+      change_table: Object.values(ColumnNames),
+    });
+    await element.updateComplete;
+    assert.deepEqual(element.labelFilter, []);
+
+    // With label filter — show only specified labels
+    userModel.setPreferences({
+      ...createDefaultPreferences(),
+      change_table: Object.values(ColumnNames),
+      label_filter: 'Code-Review,Verified',
+    });
+    await element.updateComplete;
+    assert.deepEqual(element.labelFilter, ['Code-Review', 'Verified']);
+
+    // Empty label filter — show all again
+    userModel.setPreferences({
+      ...createDefaultPreferences(),
+      change_table: Object.values(ColumnNames),
+      label_filter: '',
+    });
+    await element.updateComplete;
+    assert.deepEqual(element.labelFilter, []);
+  });
+  test('label filter is case insensitive', () => {
+    element.labelFilter = ['code-review'];
+    element.config = createServerInfo();
+    const sections: ChangeListSection[] = [
+      {
+        results: [
+          {
+            ...createChange(),
+            _number: 0 as NumericChangeId,
+            submit_requirements: [
+              {
+                ...createSubmitRequirementResultInfo(),
+                name: 'Code-Review',
+              },
+              {
+                ...createSubmitRequirementResultInfo(),
+                name: 'Verified',
+              },
+            ],
+          },
+        ],
+      },
+    ];
+    // 'code-review' matches 'Code-Review' case-insensitively
+    assert.deepEqual(element.computeLabelNames(sections), ['Code-Review']);
+  });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 8a41a2d..db1d081 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -12,12 +12,17 @@
 import '../gr-user-header/gr-user-header';
 import '../../core/gr-notifications-prompt/gr-notifications-prompt';
 import {getAppContext} from '../../../services/app-context';
-import {changeIsOpen} from '../../../utils/change-util';
+import {
+  changeIsOpen,
+  listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
 import {parseDate} from '../../../utils/date-util';
 import {
   AccountDetailInfo,
   ChangeInfo,
   DashboardId,
+  ListChangesOption,
   PreferencesInput,
   RepoName,
   UserId,
@@ -104,6 +109,9 @@
   // private but used in test
   @state() showNotificationsPrompt = false;
 
+  // private but used in test
+  @state() starsLoading = false;
+
   private reporting = getAppContext().reportingService;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -150,6 +158,17 @@
     this.shortcuts.addAbstract(Shortcut.UP_TO_DASHBOARD, () => this.reload());
   }
 
+  override connectedCallback() {
+    super.connectedCallback();
+    if (
+      getAppContext().flagsService.isEnabled(
+        KnownExperimentId.DASHBOARD_LAZY_LOADING
+      )
+    ) {
+      this.starsLoading = true;
+    }
+  }
+
   static override get styles() {
     return [
       a11yStyles,
@@ -266,6 +285,7 @@
           .preferences=${this.preferences}
           .sections=${this.results}
           .usp=${'dashboard'}
+          .starsLoading=${this.starsLoading}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
@@ -366,6 +386,13 @@
       this.reporting.time(Timing.DASHBOARD_DISPLAYED);
     }
     this.firstTimeLoad = false;
+    if (
+      getAppContext().flagsService.isEnabled(
+        KnownExperimentId.DASHBOARD_LAZY_LOADING
+      )
+    ) {
+      this.starsLoading = true;
+    }
 
     const {project, type, dashboard, title, user, sections} = this.viewState;
 
@@ -467,6 +494,50 @@
           changelistSection.name === YOUR_TURN.name &&
           changelistSection.results.length > 0
       ).length !== 0;
+
+    if (
+      getAppContext().flagsService.isEnabled(
+        KnownExperimentId.DASHBOARD_LAZY_LOADING
+      )
+    ) {
+      this.makeSecondRequestForStarredChanges(queries);
+    }
+  }
+
+  private makeSecondRequestForStarredChanges(queries: string[]) {
+    this.starsLoading = true;
+    const secondRequestOptions = listChangesOptionsToHex(
+      ListChangesOption.STAR
+    );
+    this.restApiService
+      .getChangesForDashboard(
+        undefined,
+        queries,
+        undefined,
+        secondRequestOptions
+      )
+      .then(results => {
+        if (!results) {
+          console.error('loading star information failed');
+          this.starsLoading = false;
+          return;
+        }
+        this.results = this.results?.map((section, i) => {
+          return {
+            ...section,
+            results: section.results.map((change, j) => {
+              return {
+                ...change,
+                starred:
+                  results[i] && results[i][j]
+                    ? results[i][j].starred
+                    : change.starred,
+              };
+            }),
+          };
+        });
+        this.starsLoading = false;
+      });
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index 9a027a0..54b0f63 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -9,6 +9,7 @@
 import {GrDashboardView} from './gr-dashboard-view';
 import {GerritView} from '../../../services/router/router-model';
 import {changeIsOpen} from '../../../utils/change-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
 import {ChangeStatus} from '../../../constants/constants';
 import {
   createAccountDetailWithId,
@@ -26,6 +27,7 @@
   waitUntil,
 } from '../../../test/test-utils';
 import {
+  ChangeInfo,
   ChangeInfoId,
   DashboardId,
   EmailAddress,
@@ -159,6 +161,72 @@
     });
   });
 
+  suite('lazy loading stars', () => {
+    setup(async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.DASHBOARD_LAZY_LOADING)
+        .returns(true);
+      element.loggedInUser = createAccountDetailWithId(1);
+    });
+
+    test('makeSecondRequestForStarredChanges is called', async () => {
+      const change1 = {
+        ...createChange(),
+        id: '1' as ChangeInfoId,
+        starred: false,
+      };
+      const change1Starred = {...change1, starred: true};
+      // The second array is for the 'owner:self limit:1' query
+      getChangesStub.onFirstCall().returns(Promise.resolve([[change1], []]));
+      getChangesStub
+        .onSecondCall()
+        .returns(Promise.resolve([[change1Starred], []]));
+
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        type: DashboardType.CUSTOM,
+        user: 'self',
+        sections: [{name: 'test1', query: 'test1', hideIfEmpty: true}],
+      };
+      await element.reload();
+      await element.updateComplete;
+
+      assert.isTrue(getChangesStub.calledTwice);
+      assert.isFalse(element.starsLoading);
+      assert.isDefined(element.results);
+      assert.isTrue(element.results[0].results[0].starred);
+    });
+
+    test('starsLoading is true during second request', async () => {
+      const change1 = {
+        ...createChange(),
+        id: '1' as ChangeInfoId,
+        starred: false,
+      };
+      const firstRequestPromise = Promise.resolve([[change1], []]);
+      const secondRequestPromise = mockPromise<ChangeInfo[][] | undefined>();
+      getChangesStub.onFirstCall().returns(firstRequestPromise);
+      getChangesStub.onSecondCall().returns(secondRequestPromise);
+
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        type: DashboardType.CUSTOM,
+        user: 'self',
+        sections: [{name: 'test1', query: 'test1', hideIfEmpty: true}],
+      };
+      const reloadPromise = element.reload();
+      await firstRequestPromise;
+      // Wait for first request to finish and second request to start
+      await waitUntil(() => getChangesStub.calledTwice);
+
+      assert.isTrue(element.starsLoading);
+
+      secondRequestPromise.resolve([[{...change1, starred: true}], []]);
+      await reloadPromise;
+      assert.isFalse(element.starsLoading);
+    });
+  });
+
   suite('drafts banner functionality', () => {
     setup(async () => {
       element.viewState = {
diff --git a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts
index e4597e6..c89f99a 100644
--- a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts
@@ -9,7 +9,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, query, state} from 'lit/decorators.js';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {getAppContext} from '../../../services/app-context';
 import {fireError} from '../../../utils/event-util';
@@ -81,17 +81,19 @@
 
   @state() loading = false;
 
-  @state() selectedTemplate: PromptTemplateId = 'HELP_REVIEW';
+  @property({type: String}) selectedTemplate: PromptTemplateId = 'HELP_REVIEW';
 
-  @state() private context = 3;
+  @property({type: Number}) private context = 3;
 
   // private but used in tests
-  @state() threads: CommentThread[] = [];
+  @property({type: Array}) threads: CommentThread[] = [];
 
   @state() private promptContent = '';
 
   @state() private promptSize = '';
 
+  @state() private opened = false;
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
@@ -314,6 +316,7 @@
   }
 
   override willUpdate(changedProperties: PropertyValues) {
+    if (!this.opened) return;
     if (
       changedProperties.has('patchContent') ||
       changedProperties.has('selectedTemplate') ||
@@ -327,6 +330,7 @@
   }
 
   open() {
+    this.opened = true;
     if (this.getNumParents() === 1) {
       this.loadPatchContent();
     }
@@ -358,14 +362,43 @@
     this.patchContent = content;
   }
 
+  dataAnonymization(unresolvedThreads: CommentThread[]) {
+    const authorAliasByKey = new Map<string, string>();
+    let nextAliasNumber = 1;
+
+    return unresolvedThreads.map(thread => {
+      return {
+        ...thread,
+        comments: thread.comments.map(comment => {
+          const author = comment.author;
+          const authorKey = String(
+            author?._account_id ??
+              author?.email ??
+              author?.username ??
+              author?.name ??
+              '__unknown_author__'
+          );
+          let alias = authorAliasByKey.get(authorKey);
+          if (!alias) {
+            alias = `User${nextAliasNumber++}`;
+            authorAliasByKey.set(authorKey, alias);
+          }
+          return {...comment, anonymizedAuthor: alias};
+        }),
+      };
+    });
+  }
+
   private getUnresolvedCommentsFormatted(): string {
     const unresolvedThreads = this.threads.filter(isUnresolved);
     if (unresolvedThreads.length === 0) return 'No unresolved comments.';
 
-    return unresolvedThreads
+    const anonymizedThreads = this.dataAnonymization(unresolvedThreads);
+
+    return anonymizedThreads
       .map(thread => {
         const comments = thread.comments.map(
-          c => `${c.author?.name ?? 'Unknown'}:\n${c.message}`
+          c => `${c.anonymizedAuthor}:\n${c.message}`
         );
         let loc = '';
         if (thread.line) {
@@ -398,11 +431,10 @@
 
     this.promptContent = template.prompt.replace(
       '{{patch}}',
-      sanitizedPatchContent
+      () => sanitizedPatchContent
     );
     if (this.selectedTemplate === 'RESOLVE_COMMENTS') {
-      this.promptContent = this.promptContent.replace(
-        '{{comments}}',
+      this.promptContent = this.promptContent.replace('{{comments}}', () =>
         this.getUnresolvedCommentsFormatted()
       );
     }
@@ -438,6 +470,10 @@
   private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
+    this.opened = false;
+    this.patchContent = undefined;
+    this.promptContent = '';
+    this.promptSize = '';
     fire(this, 'close', {});
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts
index 7c5268a..b78189c 100644
--- a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts
@@ -7,11 +7,18 @@
 import './gr-ai-prompt-dialog';
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrAiPromptDialog} from './gr-ai-prompt-dialog';
-import {createParsedChange} from '../../../test/test-data-generators';
-import {CommitId, PatchSetNum} from '../../../api/rest-api';
+import {
+  createParsedChange,
+  createThread,
+} from '../../../test/test-data-generators';
+import {AccountInfo, CommitId, PatchSetNum} from '../../../api/rest-api';
 import {stubRestApi, waitUntil} from '../../../test/test-utils';
 import {testResolver} from '../../../test/common-test-setup';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {
+  ChangeModel,
+  changeModelToken,
+} from '../../../models/change/change-model';
 import {of} from 'rxjs';
 
 suite('gr-ai-prompt-dialog test', () => {
@@ -28,6 +35,7 @@
 
     element = await fixture(html`<gr-ai-prompt-dialog></gr-ai-prompt-dialog>`);
     element.change = createParsedChange();
+    element.change.current_revision = 'abc' as CommitId;
     element.change.revisions['abc'].commit!.parents = [
       {
         commit: 'def' as CommitId,
@@ -35,9 +43,9 @@
       },
     ];
     element.patchNum = 1 as PatchSetNum;
-    element.patchContent = 'test code';
     element.selectedTemplate = 'PATCH_ONLY';
-    await element.updateComplete;
+    element.open();
+    await waitUntil(() => !!element.patchContent);
   });
 
   test('renders', async () => {
@@ -93,27 +101,42 @@
                  label="Context"
                  value="3"
                >
-                 <md-select-option md-menu-item="">
+                 <md-select-option
+                   md-menu-item=""
+                   tabindex="0"
+                 >
                    <div slot="headline">
                      3 lines (default)
                    </div>
                  </md-select-option>
-                 <md-select-option md-menu-item="">
+                 <md-select-option
+                   md-menu-item=""
+                   tabindex="-1"
+                 >
                    <div slot="headline">
                      10 lines
                    </div>
                  </md-select-option>
-                 <md-select-option md-menu-item="">
+                 <md-select-option
+                   md-menu-item=""
+                   tabindex="-1"
+                 >
                    <div slot="headline">
                      25 lines
                    </div>
                  </md-select-option>
-                 <md-select-option md-menu-item="">
+                 <md-select-option
+                   md-menu-item=""
+                   tabindex="-1"
+                 >
                    <div slot="headline">
                      50 lines
                    </div>
                  </md-select-option>
-                 <md-select-option md-menu-item="">
+                 <md-select-option
+                   md-menu-item=""
+                   tabindex="-1"
+                 >
                    <div slot="headline">
                      100 lines
                    </div>
@@ -188,8 +211,7 @@
     element.selectedTemplate = 'HELP_REVIEW';
     await element.updateComplete;
     assert.include(
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      (element as any).promptContent,
+      Reflect.get(element, 'promptContent') as string,
       'You are a highly experienced code reviewer'
     );
   });
@@ -197,33 +219,188 @@
   test('renders resolve comments prompt', async () => {
     element.selectedTemplate = 'RESOLVE_COMMENTS';
     await element.updateComplete;
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    assert.include((element as any).promptContent, 'No unresolved comments.');
+    assert.include(
+      Reflect.get(element, 'promptContent') as string,
+      'No unresolved comments.'
+    );
   });
 
   test('renders resolve comments prompt with comments', async () => {
     element.threads = [
       {
-        comments: [
-          {
-            message: 'test comment',
-            author: {name: 'Tester'},
-            updated: '2025-01-01 10:00:00.000000000',
-            unresolved: true,
-          },
-        ],
+        ...createThread({
+          message: 'test comment',
+          author: {name: 'Tester'} as AccountInfo,
+          unresolved: true,
+        }),
         path: 'test.txt',
         line: 1,
-        rootId: '1',
       },
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    ] as any[];
+    ];
     element.selectedTemplate = 'RESOLVE_COMMENTS';
     await element.updateComplete;
     const expected = `* File: test.txt (Line 1)
-Tester:
+User1:
 test comment`;
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    assert.include((element as any).promptContent, expected);
+    assert.include(Reflect.get(element, 'promptContent') as string, expected);
+  });
+
+  test('preserves dollar signs in patch content', async () => {
+    const expected = '+IMAGE="${SCRIPT_NAME}_$$"';
+    element.patchContent = expected;
+    element.selectedTemplate = 'PATCH_ONLY';
+    await element.updateComplete;
+
+    const promptContent = Reflect.get(element, 'promptContent') as string;
+    assert.include(promptContent, expected);
+  });
+
+  suite('dataAnonymization', () => {
+    test('assigns alias starting from User1', () => {
+      const thread = {
+        ...createThread({
+          message: 'hello',
+          author: {_account_id: 1} as AccountInfo,
+          unresolved: true,
+        }),
+        path: 'foo.ts',
+      };
+      const result = element.dataAnonymization([thread]);
+      assert.equal(result[0].comments[0].anonymizedAuthor, 'User1');
+    });
+
+    test('same author (by _account_id) gets the same alias', () => {
+      const author = {_account_id: 42} as AccountInfo;
+      const thread1 = {
+        ...createThread({message: 'first', author, unresolved: true}),
+        path: 'a.ts',
+      };
+      const thread2 = {
+        ...createThread({message: 'second', author, unresolved: true}),
+        path: 'b.ts',
+      };
+      const result = element.dataAnonymization([thread1, thread2]);
+      assert.equal(result[0].comments[0].anonymizedAuthor, 'User1');
+      assert.equal(result[1].comments[0].anonymizedAuthor, 'User1');
+    });
+
+    test('different authors get different aliases', () => {
+      const thread1 = {
+        ...createThread({
+          message: 'msg1',
+          author: {_account_id: 1} as AccountInfo,
+          unresolved: true,
+        }),
+        path: 'a.ts',
+      };
+      const thread2 = {
+        ...createThread({
+          message: 'msg2',
+          author: {_account_id: 2} as AccountInfo,
+          unresolved: true,
+        }),
+        path: 'b.ts',
+      };
+      const result = element.dataAnonymization([thread1, thread2]);
+      assert.equal(result[0].comments[0].anonymizedAuthor, 'User1');
+      assert.equal(result[1].comments[0].anonymizedAuthor, 'User2');
+    });
+
+    test('falls back to email when _account_id is absent', () => {
+      const thread = {
+        ...createThread({
+          message: 'hi',
+          author: {email: 'alice@example.com'} as AccountInfo,
+          unresolved: true,
+        }),
+        path: 'c.ts',
+      };
+      const result = element.dataAnonymization([thread]);
+      assert.equal(result[0].comments[0].anonymizedAuthor, 'User1');
+    });
+
+    test('returns empty array for empty input', () => {
+      const result = element.dataAnonymization([]);
+      assert.deepEqual(result, []);
+    });
+
+    test('does not expose original author info in anonymized comments', () => {
+      const thread = {
+        ...createThread({
+          message: 'secret',
+          author: {name: 'Bob', _account_id: 7} as AccountInfo,
+          unresolved: true,
+        }),
+        path: 'd.ts',
+      };
+      const result = element.dataAnonymization([thread]);
+      const comment = result[0].comments[0];
+      assert.notEqual(comment.anonymizedAuthor, 'Bob');
+      assert.match(comment.anonymizedAuthor, /^User\d+$/);
+    });
+  });
+
+  suite('eager loading prevention', () => {
+    let changeModel: ChangeModel;
+
+    setup(() => {
+      getPatchContentStub.resetHistory();
+      changeModel = testResolver(changeModelToken);
+    });
+
+    test('does not load patch content on initialization', async () => {
+      const change = createParsedChange();
+      change.revisions['abc'].commit!.parents = [
+        {
+          commit: 'def' as CommitId,
+          subject: 'Parent',
+        },
+      ];
+      Object.defineProperty(changeModel, 'change$', {
+        value: of(change),
+        writable: true,
+      });
+      Object.defineProperty(changeModel, 'patchNum$', {
+        value: of(1 as PatchSetNum),
+        writable: true,
+      });
+
+      const testElement = await fixture<GrAiPromptDialog>(
+        html`<gr-ai-prompt-dialog></gr-ai-prompt-dialog>`
+      );
+      await testElement.updateComplete;
+
+      assert.isFalse(getPatchContentStub.called);
+    });
+
+    test('loads patch content when open is called', async () => {
+      const change = createParsedChange();
+      change.revisions['abc'].commit!.parents = [
+        {
+          commit: 'def' as CommitId,
+          subject: 'Parent',
+        },
+      ];
+      Object.defineProperty(changeModel, 'change$', {
+        value: of(change),
+        writable: true,
+      });
+      Object.defineProperty(changeModel, 'patchNum$', {
+        value: of(1 as PatchSetNum),
+        writable: true,
+      });
+
+      const testElement = await fixture<GrAiPromptDialog>(
+        html`<gr-ai-prompt-dialog></gr-ai-prompt-dialog>`
+      );
+      await testElement.updateComplete;
+
+      assert.isFalse(getPatchContentStub.called);
+
+      testElement.open();
+      await testElement.updateComplete;
+
+      assert.isTrue(getPatchContentStub.called);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 49f27d9..91a22f0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -101,7 +101,7 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
-import {customElement, query, state} from 'lit/decorators.js';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util';
 import {Interaction} from '../../../constants/reporting';
@@ -171,9 +171,10 @@
   payload?: RequestPayload;
 }
 
-const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
+export const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
   __key: 'review',
   __type: ActionType.CHANGE,
+  __primary: true,
   enabled: true,
   key: 'review',
   label: 'Quick approve',
@@ -184,7 +185,7 @@
   __key: 'chat',
   __type: ActionType.CHANGE,
   enabled: true,
-  label: 'Review Agent',
+  label: 'Agent Chat',
 };
 
 function isQuickApproveAction(
@@ -392,7 +393,7 @@
 
   @state() change?: ParsedChangeInfo;
 
-  @state() actions: ActionNameToActionInfoMap = {};
+  @property({type: Object}) actions: ActionNameToActionInfoMap = {};
 
   @state() primaryActionKeys: PrimaryActionKey[] = [
     ChangeActions.READY,
@@ -482,7 +483,7 @@
 
   @state() actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @state() additionalActions: UIActionInfo[] = [];
+  @property({type: Array}) additionalActions: UIActionInfo[] = [];
 
   @state() hiddenActions: string[] = [];
 
@@ -878,6 +879,7 @@
   }
 
   private renderUIAction(action: UIActionInfo) {
+    const disabled = this.calculateDisabled(action);
     return html`
       <gr-tooltip-content
         title=${ifDefined(action.title)}
@@ -885,11 +887,12 @@
         ?position-below=${true}
       >
         <gr-button
-          link
+          ?link=${!action.__primary || disabled}
           class=${action.__key}
           data-action-key=${action.__key}
           data-label=${action.label}
-          ?disabled=${this.calculateDisabled(action)}
+          ?primary=${action.__primary}
+          ?disabled=${disabled}
           @click=${(e: MouseEvent) =>
             this.handleActionTap(e, action.__key, action.__type)}
         >
@@ -1210,6 +1213,15 @@
         continue;
       }
       const status = this.getLabelStatus(labelInfo);
+      if (status === LabelStatus.REJECT || status === LabelStatus.IMPOSSIBLE) {
+        return null;
+      }
+      if (
+        label === StandardLabels.PRESUBMIT_VERIFIED ||
+        label.toLowerCase().startsWith('presubmit')
+      ) {
+        continue;
+      }
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so check if Code Review can be
@@ -1218,11 +1230,6 @@
           break;
         }
         result = label;
-      } else if (
-        status === LabelStatus.REJECT ||
-        status === LabelStatus.IMPOSSIBLE
-      ) {
-        return null;
       }
     }
     // Allow the user to use quick approve to vote the max score on code review
@@ -2062,7 +2069,7 @@
           cherrypickChangeInfo._number,
           cherrypickChangeInfo.project
         );
-        const reachable = this.waitForChangeReachable(
+        const reachable = await this.waitForChangeReachable(
           cherrypickChangeInfo._number
         );
         if (!reachable) return;
@@ -2102,6 +2109,9 @@
         break;
       }
       default:
+        if (isQuickApproveAction(action)) {
+          this.getPluginLoader().jsApiService.handleReplySent();
+        }
         this.getChangeModel().navigateToChangeResetReload();
         break;
     }
@@ -2214,7 +2224,8 @@
     const query = `topic: "${this.change.topic}"`;
     const options = listChangesOptionsToHex(
       ListChangesOption.MESSAGES,
-      ListChangesOption.ALL_REVISIONS
+      ListChangesOption.ALL_REVISIONS,
+      ListChangesOption.SKIP_DIFFSTAT
     );
     return this.restApiService
       .getChanges(0, query, undefined, options)
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 86c755d..a8a31c6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -31,6 +31,7 @@
 import {
   assertUIActionInfo,
   GrChangeActions,
+  QUICK_APPROVE_ACTION,
   REMOVE_DELTE_ACCOUNTS_MESSAGE,
 } from './gr-change-actions';
 import {
@@ -79,6 +80,12 @@
   >;
   let chatModel: ChatModel;
 
+  teardown(() => {
+    for (const el of document.body.querySelectorAll('gr-tooltip')) {
+      el.remove();
+    }
+  });
+
   suite('basic tests', () => {
     setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
@@ -169,7 +176,7 @@
                   class="submit"
                   data-action-key="submit"
                   data-label="Submit"
-                  link=""
+                  primary=""
                   role="button"
                   tabindex="0"
                 >
@@ -2228,10 +2235,10 @@
         assert.isTrue(element._hideQuickApproveAction);
       });
 
-      test('is first in list of secondary actions', () => {
+      test('is first in list of primary actions', () => {
         const approveButton = queryAndAssert<HTMLElement>(
           element,
-          '#secondaryActions'
+          '#primaryActions'
         ).querySelector('gr-button');
         assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
       });
@@ -2330,6 +2337,16 @@
         assert.deepEqual((payload as ReviewInput).labels, {foo: 1});
       });
 
+      test('calls handleReplySent on quick approve success', async () => {
+        const handleReplySentStub = sinon.stub(
+          testResolver(pluginLoaderToken).jsApiService,
+          'handleReplySent'
+        );
+
+        await element.handleResponse(QUICK_APPROVE_ACTION, new Response());
+        assert.isTrue(handleReplySentStub.called);
+      });
+
       test('not added when multiple labels are required without code review', async () => {
         element.change = {
           ...createChangeViewChange(),
@@ -2544,6 +2561,57 @@
         );
         assert.isNotOk(approveButton);
       });
+
+      test('ignore presubmit labels for quick approve', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              approved: createAccountWithId(1),
+              all: [{value: 2}],
+            },
+            'Presubmit-Verified': {
+              values: {'-1': '', ' 0': '', '+1': ''},
+            },
+          },
+          permitted_labels: {
+            'Presubmit-Verified': ['-1', ' 0', '+1'],
+          },
+        };
+        await element.updateComplete;
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('rejected presubmit label blocks quick approve', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              values: {'-2': '', '-1': '', ' 0': '', '+1': '', '+2': ''},
+            },
+            'Presubmit-Verified': {
+              rejected: createAccountWithId(2),
+              values: {'-1': '', ' 0': '', '+1': ''},
+            },
+          },
+          permitted_labels: {
+            'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+            'Presubmit-Verified': ['-1', ' 0', '+1'],
+          },
+        };
+        await element.updateComplete;
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
     });
 
     test('adds download revision action', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index aa0a8ca..4c05e68 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -6,7 +6,6 @@
 import '../../../styles/shared-styles';
 import '../../../styles/gr-font-styles';
 import '../../../styles/gr-change-metadata-shared-styles';
-import '../../../styles/gr-change-view-integration-shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-account-chip/gr-account-chip';
@@ -148,7 +147,7 @@
 
   @state() repoConfig?: ConfigInfo;
 
-  @state() mutable = false;
+  @property({type: Boolean}) mutable = false;
 
   @state() readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 00a5cda..d9de5f4e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -342,6 +342,10 @@
         gr-checks-chip {
           z-index: 2;
         }
+        gr-checks-chip:hover,
+        gr-checks-chip:focus-within {
+          z-index: 10;
+        }
       `,
     ];
   }
@@ -575,6 +579,7 @@
       .statusOrCategory=${statusOrCategory}
       .text=${text}
       .links=${links}
+      .isAi=${!!run.isAiPowered}
       @click=${handler}
       @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts
index 1559a1c..045186e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts
@@ -52,11 +52,13 @@
       createRun({
         status: RunStatus.COMPLETED,
         checkName: 'info-check',
+        isAiPowered: true,
         results: [createCheckResult({category: Category.INFO})],
       }),
       createRun({
         status: RunStatus.COMPLETED,
         checkName: 'warning-check',
+        isAiPowered: true,
         results: [createCheckResult({category: Category.WARNING})],
       }),
       createRun({
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index 5a1e855..9744398 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -264,6 +264,92 @@
     });
   });
 
+  suite('ai chips', () => {
+    test('detailed chip has isAi set based on run.isAiPowered', async () => {
+      element.runs = [
+        createRun({
+          checkName: 'AI Check',
+          status: RunStatus.COMPLETED,
+          isAiPowered: true,
+          results: [createCheckResult({category: Category.ERROR})],
+        }),
+        createRun({
+          checkName: 'Normal Check',
+          status: RunStatus.COMPLETED,
+          isAiPowered: false,
+          results: [createCheckResult({category: Category.ERROR})],
+        }),
+      ];
+      element.showChecksSummary = true;
+      await element.updateComplete;
+
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip');
+      assert.equal(chips.length, 2);
+      assert.isTrue(chips[0].isAi);
+      assert.equal(chips[0].text, 'AI Check');
+      assert.isFalse(chips[1].isAi);
+      assert.equal(chips[1].text, 'Normal Check');
+    });
+
+    test('collapsed chip does not have isAi=true even if run is AI powered', async () => {
+      element.runs = [
+        createRun({
+          status: RunStatus.COMPLETED,
+          isAiPowered: true,
+          results: [createCheckResult({category: Category.SUCCESS})],
+        }),
+        createRun({
+          status: RunStatus.COMPLETED,
+          isAiPowered: false,
+          results: [createCheckResult({category: Category.SUCCESS})],
+        }),
+        createRun({status: RunStatus.RUNNING}),
+      ];
+      element.showChecksSummary = true;
+      await element.updateComplete;
+
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip');
+      const successChip = [...chips].find(
+        c => c.statusOrCategory === Category.SUCCESS
+      );
+      assert.isDefined(successChip);
+      assert.isFalse(successChip.isAi);
+      assert.equal(successChip.text, '2');
+    });
+
+    test('plus-more chip does not have isAi=true even if overflow run is AI powered', async () => {
+      const runs: CheckRun[] = [];
+      for (let i = 0; i < 8; i++) {
+        runs.push(
+          createRun({
+            checkName: `Error ${i}`,
+            status: RunStatus.COMPLETED,
+            isAiPowered: false,
+            results: [createCheckResult({category: Category.ERROR})],
+          })
+        );
+      }
+      runs.push(
+        createRun({
+          checkName: 'Error AI',
+          status: RunStatus.COMPLETED,
+          isAiPowered: true,
+          results: [createCheckResult({category: Category.ERROR})],
+        })
+      );
+
+      element.runs = runs;
+      element.showChecksSummary = true;
+      await element.updateComplete;
+
+      const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip');
+      assert.equal(chips.length, 8);
+      const plusMoreChip = chips[7];
+      assert.equal(plusMoreChip.text, '+ 2 more');
+      assert.isFalse(plusMoreChip.isAi);
+    });
+  });
+
   suite('flows summary', () => {
     test('renders', async () => {
       flowsModel.setState({
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
index 1315385..eedf452 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
@@ -28,6 +28,9 @@
   @property({type: Array})
   links: string[] = [];
 
+  @property({type: Boolean})
+  isAi = false;
+
   private readonly reporting = getAppContext().reportingService;
 
   static override get styles() {
@@ -54,6 +57,10 @@
           position: relative;
           top: 2px;
         }
+        :host(:hover),
+        :host(:focus-within) {
+          z-index: 10;
+        }
         .checksChip.hoverFullLength {
           position: absolute;
           z-index: 1;
@@ -75,6 +82,10 @@
         }
         gr-icon {
           font-size: var(--line-height-small);
+          --gr-icon-size: var(--line-height-small);
+        }
+        gr-icon.ai-sparkle {
+          margin-left: var(--spacing-xs);
         }
         .checksChip a gr-icon.launch {
           color: var(--link-color);
@@ -168,9 +179,14 @@
     // 15 is roughly the number of chars for the chip exceeding its 120px width.
     return html`
       ${this.text.length > 15
-        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, icon)}`
+        ? html` ${this.renderChip(
+            chipClassFullLength,
+            ariaLabel,
+            icon,
+            this.isAi
+          )}`
         : ''}
-      ${this.renderChip(chipClass, ariaLabel, icon)}
+      ${this.renderChip(chipClass, ariaLabel, icon, this.isAi)}
     `;
   }
 
@@ -187,12 +203,18 @@
     return `${label} for check ${this.text}`;
   }
 
-  private renderChip(clazz: string, ariaLabel: string, icon: ChecksIcon) {
+  private renderChip(
+    clazz: string,
+    ariaLabel: string,
+    icon: ChecksIcon,
+    isAi: boolean
+  ) {
     return html`
       <div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
         <gr-icon icon=${icon.name} ?filled=${!!icon.filled}></gr-icon>
         ${this.renderLinks()}
         <div class="text">${this.text}</div>
+        ${isAi ? html`<gr-icon icon="ai" class="ai-sparkle"></gr-icon>` : ''}
       </div>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
index 6816609..c020980 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
@@ -85,4 +85,26 @@
       `
     );
   });
+
+  test('renders AI icon', async () => {
+    element.text = 'AI Check';
+    element.statusOrCategory = Category.ERROR;
+    element.isAi = true;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div
+          aria-label="error for check AI Check"
+          class="checksChip error font-small"
+          role="link"
+          tabindex="0"
+        >
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="text">AI Check</div>
+          <gr-icon icon="ai" class="ai-sparkle"></gr-icon>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 5249676..5b2bdef 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -124,6 +124,7 @@
 import {resolve} from '../../../models/dependency';
 import {checksModelToken} from '../../../models/checks/checks-model';
 import {changeModelToken} from '../../../models/change/change-model';
+import {chatModelToken} from '../../../models/chat/chat-model';
 import {css, html, LitElement, nothing} from 'lit';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {materialStyles} from '../../../styles/gr-material-styles';
@@ -410,6 +411,8 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getChatModel = resolve(this, chatModelToken);
+
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -461,6 +464,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private headerEl?: HTMLElement;
+
   private headerResizeObserver = new ResizeObserver(entries => {
     for (const entry of entries) {
       const height = entry.borderBoxSize[0].blockSize;
@@ -496,6 +501,10 @@
       'close-chat-panel',
       () => (this.showSidebarChat = false)
     );
+    this.addEventListener('explain-code-requested', e => {
+      this.showSidebarChat = true;
+      this.getChatModel().processChatRequest(e.detail);
+    });
   }
 
   private setupShortcuts() {
@@ -768,6 +777,10 @@
     this.firstConnectedCallback();
     this.connected$.next(true);
 
+    if (this.headerEl) {
+      this.headerResizeObserver.observe(this.headerEl);
+    }
+
     // Make sure to reverse everything below this line in disconnectedCallback().
     // Or consider using either firstConnectedCallback() or constructor().
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
@@ -826,6 +839,8 @@
       this.cancelUpdateCheckTimer();
     }
     this.connected$.next(false);
+    this.headerResizeObserver.disconnect();
+    document.documentElement.style.setProperty('--change-header-height', '0px');
     super.disconnectedCallback();
   }
 
@@ -989,7 +1004,7 @@
           flex: 1;
           overflow-x: hidden;
         }
-        .relatedChanges {
+        .commitAside {
           flex: 0 1 auto;
           overflow: hidden;
           padding: var(--spacing-l) 0;
@@ -1013,6 +1028,16 @@
           margin: var(--spacing-l) 0;
           padding: 0 var(--spacing-l);
         }
+        .commitAside gr-endpoint-decorator[name='change-view-commit-aside'] {
+          display: none;
+        }
+        .commitAside
+          gr-endpoint-decorator[name='change-view-commit-aside']:has(
+            :not(gr-endpoint-param):not([hidden])
+          ) {
+          display: block;
+          margin-bottom: var(--spacing-l);
+        }
         .showOnEdit {
           display: none;
         }
@@ -1063,10 +1088,10 @@
           position: relative;
         }
         @media screen and (max-width: 75em) {
-          .relatedChanges {
+          .commitAside {
             padding: 0;
           }
-          .relatedChanges gr-related-changes-list {
+          .commitAside gr-related-changes-list {
             padding-top: var(--spacing-l);
           }
           #commitAndRelated {
@@ -1216,6 +1241,7 @@
   }
 
   private onHeaderCreated(el?: Element) {
+    this.headerEl = el as HTMLElement | undefined;
     if (el) this.headerResizeObserver.observe(el);
   }
 
@@ -1300,6 +1326,8 @@
         class="changeCopyClipboard"
         hideInput=""
         text=${this.computeCopyTextForTitle()}
+        buttonTitle="Copy change subject and URL to clipboard"
+        copyTargetName="Change subject and URL"
       >
       </gr-copy-clipboard>
     </div>`;
@@ -1328,7 +1356,7 @@
       {
         label: 'URL and title',
         shortcut: 'r',
-        value: `${changeURL}: ${this.change?.subject}`,
+        value: `${changeURL} - ${this.change?.subject}`,
       },
       {
         label: 'Markdown',
@@ -1341,6 +1369,13 @@
         value: `${this.change?.change_id}`,
       },
     ];
+    if (this.revision?.ref) {
+      links.push({
+        label: 'Refspec',
+        shortcut: 'f',
+        value: this.revision.ref,
+      });
+    }
     if (
       this.change?.status === ChangeStatus.MERGED &&
       this.change?.current_revision
@@ -1433,7 +1468,13 @@
               </gr-endpoint-param>
             </gr-endpoint-decorator>
           </div>
-          <div class="relatedChanges">
+          <div class="commitAside">
+            <gr-endpoint-decorator name="change-view-commit-aside">
+              <gr-endpoint-param name="change" .value=${this.change}>
+              </gr-endpoint-param>
+              <gr-endpoint-param name="revision" .value=${this.revision}>
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
             <gr-related-changes-list></gr-related-changes-list>
           </div>
           <div class="emptySpace"></div>
@@ -1552,6 +1593,7 @@
             this.requestUpdate();
           }}
           @file-action-tap=${this.handleFileActionTap}
+          @open-download-dialog=${this.handleOpenDownloadDialog}
         >
         </gr-file-list>
       </div>
@@ -1930,7 +1972,7 @@
     const fileIndex = this.fileList.files.findIndex(f => f.__path === path);
     if (fileIndex !== -1) {
       this.fileList.fileCursor.setCursorAtIndex(fileIndex, true);
-      const isExpanded = this.fileList.expandedFiles.some(f => f.path === path);
+      const isExpanded = this.fileList.expandedFiles.has(path);
       if (!isExpanded) {
         this.fileList.toggleFileExpandedByIndex(fileIndex);
         await this.fileList.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts
index 713a1ea..97568af 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts
@@ -323,6 +323,15 @@
       revert_of: 12345 as NumericChangeId,
       submittable: true,
       subject: 'Reland "Add initial jj support to `gclient sync`."',
+      actions: {
+        ...element.change?.actions,
+        submit: {
+          method: HttpMethod.POST,
+          label: 'Submit',
+          title: 'Submit the change',
+          enabled: true,
+        },
+      },
     });
     await element.updateComplete;
 
@@ -348,6 +357,10 @@
       );
 
       await visualDiff(container, 'gr-change-view-wrapped-statuses-801px');
+      await visualDiffDarkTheme(
+        container,
+        'gr-change-view-wrapped-statuses-801px'
+      );
     } finally {
       document.body.removeChild(container);
     }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 83fff44..268fb5e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -48,6 +48,7 @@
   CommentThread,
   CommitId,
   EDIT,
+  GitRef,
   NumericChangeId,
   PARENT,
   RepoName,
@@ -64,6 +65,8 @@
   ChangeModel,
   changeModelToken,
 } from '../../../models/change/change-model';
+import {chatModelToken} from '../../../models/chat/chat-model';
+import {GrContentWithSidebar} from '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
 import {FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
@@ -353,7 +356,12 @@
                           </gr-endpoint-param>
                         </gr-endpoint-decorator>
                       </div>
-                      <div class="relatedChanges">
+                      <div class="commitAside">
+                        <gr-endpoint-decorator name="change-view-commit-aside">
+                          <gr-endpoint-param name="change"> </gr-endpoint-param>
+                          <gr-endpoint-param name="revision">
+                          </gr-endpoint-param>
+                        </gr-endpoint-decorator>
                         <gr-related-changes-list> </gr-related-changes-list>
                       </div>
                       <div class="emptySpace"></div>
@@ -1362,6 +1370,91 @@
     });
   });
 
+  suite('change-view-commit-aside visibility', () => {
+    let decorator: HTMLElement;
+
+    setup(async () => {
+      element.change = {...createChangeViewChange(), labels: {}};
+      element.revision = createRevision();
+      await element.updateComplete;
+      decorator = element.shadowRoot!.querySelector(
+        'gr-endpoint-decorator[name="change-view-commit-aside"]'
+      )!;
+    });
+
+    test('hidden by default', () => {
+      assert.equal(getComputedStyle(decorator).display, 'none');
+    });
+
+    test('hidden if plugin component is hidden', async () => {
+      const promise = mockPromise();
+      window.Gerrit.install(
+        promise.resolve,
+        '0.1',
+        'http://some/plugins/url.js'
+      );
+      const plugin = (await promise) as PluginApi;
+
+      const dummyTagName = 'dummy-aside-component-hidden';
+      if (!customElements.get(dummyTagName)) {
+        customElements.define(
+          dummyTagName,
+          class extends HTMLElement {
+            connectedCallback() {
+              this.setAttribute('hidden', '');
+            }
+          }
+        );
+      }
+
+      plugin.registerCustomComponent('change-view-commit-aside', dummyTagName);
+
+      await new Promise<void>(resolve => {
+        const observer = new MutationObserver(() => {
+          if (decorator.querySelector(dummyTagName)) {
+            observer.disconnect();
+            resolve();
+          }
+        });
+        observer.observe(decorator, {childList: true});
+      });
+
+      await element.updateComplete;
+      assert.equal(getComputedStyle(decorator).display, 'none');
+    });
+
+    test('visible if plugin component is visible', async () => {
+      const promise = mockPromise();
+      window.Gerrit.install(
+        promise.resolve,
+        '0.1',
+        'http://some/plugins/url.js'
+      );
+      const plugin = (await promise) as PluginApi;
+
+      const dummyTagName = 'dummy-aside-component-visible';
+      if (!customElements.get(dummyTagName)) {
+        customElements.define(dummyTagName, class extends HTMLElement {});
+      }
+
+      plugin.registerCustomComponent('change-view-commit-aside', dummyTagName);
+
+      await new Promise<void>(resolve => {
+        const observer = new MutationObserver(() => {
+          if (decorator.querySelector(dummyTagName)) {
+            observer.disconnect();
+            resolve();
+          }
+        });
+        observer.observe(decorator, {childList: true});
+      });
+
+      await element.updateComplete;
+      assert.equal(getComputedStyle(decorator).display, 'block');
+      assert.notEqual(getComputedStyle(decorator).marginBottom, '0px');
+    });
+  });
+
   test('handleToggleStar called when star is tapped', async () => {
     element.change = {
       ...createChangeViewChange(),
@@ -1466,6 +1559,45 @@
     );
   });
 
+  test('renders refspec in copy links', async () => {
+    const refspec = 'refs/changes/1/2/3' as GitRef;
+    element.change = createChangeViewChange();
+    element.revision = {
+      ...createRevision(),
+      ref: refspec,
+    };
+    element.loading = false;
+    await element.updateComplete;
+
+    const copyLinksDialog = queryAndAssert<GrCopyLinks>(
+      element,
+      'gr-copy-links'
+    );
+    assert.deepEqual(
+      copyLinksDialog.copyLinks.find(copyLink => copyLink.label === 'Refspec'),
+      {
+        label: 'Refspec',
+        shortcut: 'f',
+        value: refspec,
+      }
+    );
+  });
+
+  test('does not render refspec in copy links without a revision', async () => {
+    element.change = createChangeViewChange();
+    element.revision = undefined;
+    element.loading = false;
+    await element.updateComplete;
+
+    const copyLinksDialog = queryAndAssert<GrCopyLinks>(
+      element,
+      'gr-copy-links'
+    );
+    assert.isUndefined(
+      copyLinksDialog.copyLinks.find(copyLink => copyLink.label === 'Refspec')
+    );
+  });
+
   test('copy links without a base URL', async () => {
     element.change = createChangeViewChange();
     element.loading = false;
@@ -1499,4 +1631,25 @@
       value: 'http://localhost:9876/review/c/test-project/+/42',
     });
   });
+
+  test('explain-code-requested opens sidebar and starts chat', async () => {
+    const chatModel = testResolver(chatModelToken);
+    const processChatRequestStub = sinon.stub(chatModel, 'processChatRequest');
+    const contentWithSidebar = queryAndAssert<GrContentWithSidebar>(
+      element,
+      'gr-content-with-sidebar'
+    );
+    assert.isTrue(contentWithSidebar.hideSide);
+
+    const event = new CustomEvent('explain-code-requested', {
+      detail: {prompt: 'Explain this code'},
+      bubbles: true,
+      composed: true,
+    });
+    element.dispatchEvent(event);
+    await element.updateComplete;
+
+    assert.isFalse(contentWithSidebar.hideSide);
+    assert.isTrue(processChatRequestStub.calledOnceWithExactly(event.detail));
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index c87b878..8a1070f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -246,7 +246,7 @@
     if (!this.change) return;
     const url = createChangeUrl({
       change: this.change,
-      edit: true,
+      patchNum: EDIT,
       forceReload: true,
     });
     this.getNavigation().setUrl(url);
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
index 72050dc..5ac9762 100644
--- a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
@@ -7,7 +7,7 @@
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {createRef, ref, Ref} from 'lit/directives/ref.js';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, property, query} from 'lit/decorators.js';
 import {strToClassName} from '../../../utils/dom-util';
 import {copyToClipboard, queryAndAssert} from '../../../utils/common-util';
 import {formStyles} from '../../../styles/form-styles';
@@ -43,7 +43,7 @@
   @property({type: Number})
   verticalOffset = 10;
 
-  @state() isDropdownOpen = false;
+  @property({type: Boolean}) isDropdownOpen = false;
 
   // private but used in screenshot tests
   @query('md-menu') dropdown?: MdMenu;
@@ -117,7 +117,8 @@
       tabindex="-1"
       .menuCorner=${this.horizontalAlign === 'left'
         ? 'start-start'
-        : 'end-start'}
+        : 'start-end'}
+      .anchorCorner=${this.horizontalAlign === 'left' ? 'end-start' : 'end-end'}
       ?quick=${true}
       .yOffset=${this.verticalOffset}
       @opened=${() => {
@@ -151,6 +152,8 @@
         id=${`${id}-copy-clipboard`}
         nowrap
         ?multiline=${!!multiline}
+        copyTargetName=${label}
+        buttonTitle=${`Copy ${label} to clipboard`}
         ${index === 0 && ref(this.copyClipboardRef)}
       ></gr-copy-clipboard>
     </div>`;
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_screenshot_test.ts
index 5e36644..2daad8e 100644
--- a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_screenshot_test.ts
@@ -10,7 +10,8 @@
 // @ts-ignore
 import {visualDiff} from '@web/test-runner-visual-regression';
 import {GrCopyLinks} from './gr-copy-links';
-import {waitUntil} from '../../../test/test-utils';
+import {visualDiffDarkTheme, waitUntil} from '../../../test/test-utils';
+import {queryAndAssert} from '../../../utils/common-util';
 
 suite('gr-copy-links screenshot tests', () => {
   let element: GrCopyLinks;
@@ -46,9 +47,8 @@
   });
 
   test('dropdown screenshot', async () => {
-    await visualDiff(
-      element.shadowRoot?.querySelector('.dropdown-content'),
-      'gr-copy-links'
-    );
+    const content = queryAndAssert(element, '.dropdown-content');
+    await visualDiff(content, 'gr-copy-links');
+    await visualDiffDarkTheme(content, 'gr-copy-links');
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
index b589115..82c1c1e 100644
--- a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
@@ -45,6 +45,8 @@
         <div class="dropdown-content">
           <div class="copy-link-row">
             <gr-copy-clipboard
+              buttontitle="Copy Change ID to clipboard"
+              copytargetname="Change ID"
               id="Change_ID-field-copy-clipboard"
               label="Change ID"
               nowrap=""
@@ -77,4 +79,20 @@
     assert.isTrue(clipboardStub.called);
     assert.isTrue(clipboardStub.calledWith('123456'));
   });
+
+  test('horizontalAlign left sets corners correctly', async () => {
+    element.horizontalAlign = 'left';
+    await element.updateComplete;
+    const mdMenu = queryAndAssert<MdMenu>(element, 'md-menu');
+    assert.equal(mdMenu.menuCorner, 'start-start');
+    assert.equal(mdMenu.anchorCorner, 'end-start');
+  });
+
+  test('horizontalAlign right sets corners correctly', async () => {
+    element.horizontalAlign = 'right';
+    await element.updateComplete;
+    const mdMenu = queryAndAssert<MdMenu>(element, 'md-menu');
+    assert.equal(mdMenu.menuCorner, 'start-end');
+    assert.equal(mdMenu.anchorCorner, 'end-end');
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 95df2fac..f808b1c 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -168,6 +168,7 @@
           .commands=${this.computeDownloadCommands()}
           .schemes=${this.schemes}
           .selectedScheme=${this.selectedScheme}
+          .disableAutoSelect=${true}
           show-keyboard-shortcut-tooltips
           @selected-scheme-changed=${(e: BindValueChangeEvent) => {
             this.selectedScheme = e.detail.value;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index c107c20..438e151 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -122,6 +122,7 @@
       </section>
       <section class="hidden">
         <gr-download-commands
+          disable-auto-select=""
           id="downloadCommands"
           show-keyboard-shortcut-tooltips=""
         >
@@ -234,6 +235,14 @@
       await element.updateComplete;
     });
 
+    test('passes disableAutoSelect to gr-download-commands', () => {
+      const commands = queryAndAssert<GrDownloadCommands>(
+        element,
+        '#downloadCommands'
+      );
+      assert.isTrue(commands.disableAutoSelect);
+    });
+
     test('focuses on first copy link', async () => {
       const focusStub = sinon.stub(
         queryAndAssert<GrDownloadCommands>(element, '#downloadCommands'),
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 2ec6539..bca0a468 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -407,7 +407,10 @@
       createChangeUrl({
         change: this.change,
         patchNum,
-        basePatchNum,
+        basePatchNum: this.getChangeModel().urlBasePatchNum(
+          basePatchNum,
+          patchNum
+        ),
         edit: keepEdit,
       })
     );
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index ee3e307..fd79157 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -6,6 +6,8 @@
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer';
+import type {GrDiffMarkdownViewer} from '../../diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
@@ -36,14 +38,14 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   BasePatchSetNum,
-  EDIT,
   FileInfo,
   NumericChangeId,
   PARENT,
   PatchRange,
   RevisionPatchSetNum,
 } from '../../../types/common';
-import {DiffPreferencesInfo} from '../../../types/diff';
+import {isMarkdownDiff} from '../../../utils/diff-util';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
@@ -51,7 +53,6 @@
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Interaction, Timing} from '../../../constants/reporting';
-import {RevisionInfo} from '../../shared/revision-info/revision-info';
 import {select} from '../../../utils/observable-util';
 import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
@@ -269,7 +270,14 @@
 
   // Private but used in tests.
   @state()
-  expandedFiles: PatchSetFile[] = [];
+  expandedFiles: Set<string> = new Set();
+
+  @state()
+  private diffsByPath = new Map<string, DiffInfo>();
+
+  // Private but used in tests.
+  @state()
+  richMarkdownFiles: Set<string> = new Set();
 
   // Private but used in tests.
   @state()
@@ -278,8 +286,9 @@
   // For merge commits vs Auto Merge, an extra file row is shown detailing the
   // files that were merged without conflict. These files are also passed to any
   // plugins.
+  // Private but used in tests.
   @state()
-  private cleanlyMergedPaths: string[] = [];
+  cleanlyMergedPaths: string[] = [];
 
   // Private but used in tests.
   @state()
@@ -310,8 +319,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly restApiService = getAppContext().restApiService;
-
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
@@ -518,6 +525,28 @@
         .show-hide {
           margin-left: var(--spacing-s);
           width: 1.9em;
+          position: relative;
+        }
+        .richMarkdownToggle {
+          align-items: center;
+          display: inline-flex;
+          justify-content: flex-end;
+          position: absolute;
+          right: 2em;
+          white-space: nowrap;
+          opacity: 0;
+        }
+        .row:hover .richMarkdownToggle,
+        .row:focus-within .richMarkdownToggle,
+        .row.expanded .richMarkdownToggle {
+          opacity: 100;
+        }
+        .richMarkdownToggle gr-button {
+          --gr-button-padding: 0 var(--spacing-s);
+        }
+        .richMarkdownToggle gr-icon {
+          font-size: 16px;
+          margin-right: var(--spacing-xs);
         }
         .fileListButton {
           margin: var(--spacing-m);
@@ -642,6 +671,11 @@
           display: inline-block;
           color: var(--deemphasized-text-color);
         }
+        .expensiveDiff {
+          color: var(--warning-foreground);
+          font-weight: var(--font-weight-bold);
+          margin-left: var(--spacing-m);
+        }
 
         @container (max-width: 1200px) {
           gr-endpoint-decorator.extra-col {
@@ -869,6 +903,20 @@
     );
     subscribe(
       this,
+      () => this.getFilesModel().cleanlyMergedPaths$,
+      paths => {
+        this.cleanlyMergedPaths = paths;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFilesModel().cleanlyMergedOldPaths$,
+      paths => {
+        this.cleanlyMergedOldPaths = paths;
+      }
+    );
+    subscribe(
+      this,
       () => this.getBrowserModel().diffViewMode$,
       diffView => {
         this.diffViewMode = diffView;
@@ -1196,17 +1244,38 @@
       ${when(
         this.isFileExpanded(file.__path),
         () => html`
-          <gr-diff-host
-            ?noAutoRender=${true}
-            ?showLoadFailure=${true}
-            .changeNum=${this.changeNum}
-            .change=${this.change}
-            .patchRange=${this.patchRange}
-            .file=${patchSetFile}
-            .path=${file.__path}
-            .projectName=${this.change?.project}
-            ?noRenderOnPrefsChange=${true}
-          ></gr-diff-host>
+          ${when(
+            this.isShowingRichMarkdown(file.__path),
+            () => html`
+              <gr-diff-markdown-viewer
+                .diff=${this.getDiffForPath(file.__path)}
+                .path=${file.__path}
+                .patchRange=${this.patchRange}
+                .loggedIn=${this.loggedIn}
+              ></gr-diff-markdown-viewer>
+            `
+          )}
+          <div ?hidden=${this.isShowingRichMarkdown(file.__path)}>
+            <gr-diff-host
+              ?hidden=${this.isShowingRichMarkdown(file.__path)}
+              ?disabledThreads=${this.isShowingRichMarkdown(file.__path)}
+              ?noAutoRender=${true}
+              ?showLoadFailure=${true}
+              .changeNum=${this.changeNum}
+              .change=${this.change}
+              .patchRange=${this.patchRange}
+              .file=${patchSetFile}
+              .path=${file.__path}
+              .projectName=${this.change?.project}
+              ?noRenderOnPrefsChange=${true}
+              @diff-changed=${(e: CustomEvent<{value?: DiffInfo}>) => {
+                if (e.detail.value) {
+                  this.diffsByPath.set(file.__path, e.detail.value);
+                  this.requestUpdate();
+                }
+              }}
+            ></gr-diff-host>
+          </div>
         `
       )}
     </div>`;
@@ -1326,6 +1395,12 @@
     `;
   }
 
+  private handleDownloadLocally(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    fire(this, 'open-download-dialog', {});
+  }
+
   private renderFilePath(file: NormalizedFileInfo, previousFilePath?: string) {
     return html`
       <span class="path" role="gridcell">
@@ -1343,9 +1418,23 @@
           <gr-copy-clipboard
             ?hideInput=${true}
             .text=${file.__path}
+            buttonTitle="Copy file path to clipboard"
+            copyTargetName="File path"
           ></gr-copy-clipboard>
         </a>
         ${when(
+          file.diffs_too_expensive_to_compute,
+          () => html`
+            <span class="expensiveDiff">
+              <gr-icon icon="warning"></gr-icon>
+              Diff too expensive to compute.
+              <gr-button link @click=${this.handleDownloadLocally}>
+                Please download locally to review
+              </gr-button>
+            </span>
+          `
+        )}
+        ${when(
           file.old_path,
           () => html`
             <div class="oldPath" title=${ifDefined(file.old_path)}>
@@ -1353,6 +1442,8 @@
               <gr-copy-clipboard
                 ?hideInput=${true}
                 .text=${file.old_path}
+                buttonTitle="Copy old file path to clipboard"
+                copyTargetName="Old file path"
               ></gr-copy-clipboard>
             </div>
           `
@@ -1470,29 +1561,42 @@
         "Commit message" row content with incorrect column headers.
         -->
       <div class=${this.computeClass('', file.__path)}>
-        <span
-          class="removed"
-          tabindex="0"
-          aria-label=${`${file.lines_deleted} removed`}
-          ?hidden=${!!file.binary}
-        >
-          -${file.lines_deleted}
-        </span>
-        <span
-          class="added"
-          tabindex="0"
-          aria-label=${`${file.lines_inserted} added`}
-          ?hidden=${!!file.binary}
-        >
-          +${file.lines_inserted}
-        </span>
-        <span
-          class=${ifDefined(this.computeBinaryClass(file.size_delta))}
-          ?hidden=${!file.binary}
-        >
-          ${formatBytes(file.size_delta)}
-          ${this.formatPercentage(file.size, file.size_delta)}
-        </span>
+        ${when(
+          file.diffs_too_expensive_to_compute,
+          () => html`
+            <gr-tooltip-content
+              title="Diff too expensive to compute"
+              has-tooltip
+            >
+              <gr-icon icon="warning" class="warning"></gr-icon>
+            </gr-tooltip-content>
+          `,
+          () => html`
+            <span
+              class="removed"
+              tabindex="0"
+              aria-label=${`${file.lines_deleted} removed`}
+              ?hidden=${!!file.binary}
+            >
+              -${file.lines_deleted}
+            </span>
+            <span
+              class="added"
+              tabindex="0"
+              aria-label=${`${file.lines_inserted} added`}
+              ?hidden=${!!file.binary}
+            >
+              +${file.lines_inserted}
+            </span>
+            <span
+              class=${ifDefined(this.computeBinaryClass(file.size_delta))}
+              ?hidden=${!file.binary}
+            >
+              ${formatBytes(file.size_delta)}
+              ${this.formatPercentage(file.size, file.size_delta)}
+            </span>
+          `
+        )}
       </div>
     </div>`;
   }
@@ -1574,9 +1678,90 @@
     </div>`;
   }
 
+  isShowingRichMarkdown(path?: string): boolean {
+    if (!path || !isMarkdownDiff(path)) return false;
+    return this.richMarkdownFiles.has(path);
+  }
+
+  async toggleRichMarkdown(path: string, e?: Event) {
+    if (e) {
+      e.stopPropagation();
+      e.preventDefault();
+    }
+    const isRich = this.richMarkdownFiles.has(path);
+    if (isRich) {
+      const viewers = Array.from(
+        this.shadowRoot?.querySelectorAll<GrDiffMarkdownViewer>(
+          'gr-diff-markdown-viewer'
+        ) ?? []
+      );
+      const viewer = viewers.find(v => v.path === path);
+      await viewer?.autoSaveDrafts();
+    } else {
+      const diffHosts = Array.from(
+        this.shadowRoot?.querySelectorAll<GrDiffHost>('gr-diff-host') ?? []
+      );
+      const diffHost = this.findDiffByPath(path, diffHosts);
+      await diffHost?.autoSaveDrafts();
+    }
+    const newSet = new Set(this.richMarkdownFiles);
+    if (newSet.has(path)) {
+      newSet.delete(path);
+    } else {
+      newSet.add(path);
+      if (!this.isFileExpanded(path)) {
+        const newExpanded = new Set(this.expandedFiles);
+        newExpanded.add(path);
+        this.expandedFiles = newExpanded;
+      }
+    }
+    this.richMarkdownFiles = newSet;
+  }
+
+  getDiffForPath(path: string): DiffInfo | undefined {
+    const cached = this.diffsByPath.get(path);
+    if (cached) return cached;
+    const diffHosts = Array.from(
+      this.shadowRoot?.querySelectorAll<GrDiffHost>('gr-diff-host') ?? []
+    );
+    const diffHost = this.findDiffByPath(path, diffHosts);
+    if (diffHost?.diff) {
+      this.diffsByPath.set(path, diffHost.diff);
+      return diffHost.diff;
+    }
+    return undefined;
+  }
+
+  private renderRichMarkdownToggle(file: NormalizedFileInfo) {
+    if (!isMarkdownDiff(file.__path)) return nothing;
+    const isRich = this.isShowingRichMarkdown(file.__path);
+    return html`
+      <div class="richMarkdownToggle">
+        <gr-tooltip-content
+          has-tooltip
+          title=${isRich
+            ? 'View source diff'
+            : 'View rich rendered markdown diff'}
+        >
+          <gr-button
+            link
+            class="toggleRichMarkdown"
+            @click=${(e: MouseEvent) => this.toggleRichMarkdown(file.__path, e)}
+          >
+            <gr-icon icon=${isRich ? 'code' : 'preview'} filled></gr-icon>
+            <span class="richToggleLabel"
+              >${isRich ? 'Source diff' : 'Rich diff'}</span
+            >
+          </gr-button>
+        </gr-tooltip-content>
+      </div>
+    `;
+  }
+
   private renderShowHide(file: NormalizedFileInfo) {
     const expanded = this.isFileExpanded(file.__path);
     return html` <div class="show-hide" role="gridcell">
+      ${this.renderRichMarkdownToggle(file)}
       <!-- Do not use input type="checkbox" with hidden input and
             visible label here. Screen readers don't read/interract
             correctly with such input.
@@ -1835,43 +2020,6 @@
     this.reporting.fileListDisplayed();
   }
 
-  // TODO: Move into files-model.
-  // visible for testing
-  async updateCleanlyMergedPaths() {
-    // When viewing Auto Merge base vs a patchset, add an additional row that
-    // knows how many files were cleanly merged. This requires an additional RPC
-    // for the diffs between target parent and the patch set. The cleanly merged
-    // files are all the files in the target RPC that weren't in the Auto Merge
-    // RPC.
-    if (
-      this.change &&
-      this.changeNum &&
-      this.patchNum &&
-      new RevisionInfo(this.change).isMergeCommit(this.patchNum) &&
-      this.basePatchNum === PARENT &&
-      this.patchNum !== EDIT
-    ) {
-      const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
-        this.changeNum,
-        {
-          basePatchNum: -1 as BasePatchSetNum, // -1 is first (target) parent
-          patchNum: this.patchNum,
-        }
-      );
-      if (!allFilesByPath) return;
-      const conflictingPaths = this.files.map(f => f.__path);
-      this.cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
-        path => !conflictingPaths.includes(path)
-      );
-      this.cleanlyMergedOldPaths = this.cleanlyMergedPaths
-        .map(path => allFilesByPath[path].old_path)
-        .filter((oldPath): oldPath is string => !!oldPath);
-    } else {
-      this.cleanlyMergedPaths = [];
-      this.cleanlyMergedOldPaths = [];
-    }
-  }
-
   private detectChromiteButler() {
     const hasButler = !!document.getElementById('butler-suggested-owners');
     if (hasButler) {
@@ -1940,29 +2088,26 @@
   }
 
   // private but used in test
-  toggleFileExpanded(file: PatchSetFile) {
+  toggleFileExpanded(path: string) {
     // Is the path in the list of expanded diffs? If so, remove it, otherwise
     // add it to the list.
-    const indexInExpanded = this.expandedFiles.findIndex(
-      f => f.path === file.path
-    );
-    if (indexInExpanded === -1) {
+    const newExpandedFiles = new Set(this.expandedFiles);
+    if (!newExpandedFiles.has(path)) {
       this.reporting.reportInteraction(Interaction.FILE_LIST_DIFF_EXPANDED);
-      this.expandedFiles = this.expandedFiles.concat([file]);
+      newExpandedFiles.add(path);
     } else {
       this.reporting.reportInteraction(Interaction.FILE_LIST_DIFF_COLLAPSED);
-      this.expandedFiles = this.expandedFiles.filter(
-        (_val, idx) => idx !== indexInExpanded
-      );
+      newExpandedFiles.delete(path);
     }
-    const indexInAll = this.files.findIndex(f => f.__path === file.path);
+    this.expandedFiles = newExpandedFiles;
+    const indexInAll = this.files.findIndex(f => f.__path === path);
     this.shadowRoot!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
       indexInAll
     ].scrollIntoView({block: 'nearest'});
   }
 
   toggleFileExpandedByIndex(index: number) {
-    this.toggleFileExpanded(this.computePatchSetFile(this.files[index]));
+    this.toggleFileExpanded(this.files[index].__path);
   }
 
   // Private but used in tests.
@@ -1971,23 +2116,22 @@
       return;
     }
     // Re-render all expanded diffs sequentially.
-    this.renderInOrder(this.expandedFiles, this.diffs);
+    this.renderInOrder([...this.expandedFiles], this.diffs);
   }
 
   expandAllDiffs() {
-    const newFiles = this.files
-      .slice(0, this.numFilesShown)
-      // TODO(b/419187980): Refactor expandedFiles to use a Set for efficiency.
-      .filter(file => !this.expandedFiles.some(f => f.path === file.__path))
-      .map(file => this.computePatchSetFile(file));
+    const newExpandedFiles = new Set(this.expandedFiles);
+    this.files.slice(0, this.numFilesShown).forEach(file => {
+      newExpandedFiles.add(file.__path);
+    });
 
     this.reporting.reportInteraction(Interaction.FILE_LIST_ALL_DIFFS_EXPANDED);
-    this.expandedFiles = newFiles.concat(this.expandedFiles);
+    this.expandedFiles = newExpandedFiles;
   }
 
   collapseAllDiffs() {
     this.reporting.reportInteraction(Interaction.FILE_LIST_ALL_DIFFS_COLLAPSED);
-    this.expandedFiles = [];
+    this.expandedFiles = new Set();
   }
 
   /**
@@ -2083,8 +2227,8 @@
     this.fileActionClick(e, file => this.reviewFile(file.path));
   }
 
-  private expandedClick(e: MouseEvent | KeyboardEvent) {
-    this.fileActionClick(e, file => this.toggleFileExpanded(file));
+  expandedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.toggleFileExpanded(file.path));
   }
 
   /**
@@ -2116,7 +2260,7 @@
 
     e.preventDefault();
     this.fileCursor.setCursor(fileRow.element);
-    this.toggleFileExpanded(file);
+    this.toggleFileExpanded(path);
   }
 
   private getFileRowFromEvent(e: Event): FileRow | null {
@@ -2205,6 +2349,24 @@
 
   private handleNewComment() {
     this.classList.remove('hideComments');
+    const viewers = Array.from(
+      this.shadowRoot?.querySelectorAll<GrDiffMarkdownViewer>(
+        'gr-diff-markdown-viewer'
+      ) ?? []
+    );
+    const viewerWithSelection = viewers.find(v => v.hasActiveSelection());
+    if (viewerWithSelection) {
+      viewerWithSelection.createCommentFromSelectionOrHover();
+      return;
+    }
+    const currentPath = this.files[this.fileCursor.index]?.__path;
+    if (currentPath && this.isShowingRichMarkdown(currentPath)) {
+      const currentViewer = viewers.find(v => v.path === currentPath);
+      if (currentViewer) {
+        currentViewer.createCommentFromSelectionOrHover();
+        return;
+      }
+    }
     this.diffCursor?.createCommentInPlace();
   }
 
@@ -2268,6 +2430,10 @@
       this.getViewModel().diffUrl({
         diffView: {path: diff.path},
         patchNum: this.patchNum,
+        basePatchNum: this.getChangeModel().urlBasePatchNum(
+          this.basePatchNum,
+          this.patchNum
+        ),
       })
     );
   }
@@ -2287,6 +2453,10 @@
       this.getViewModel().diffUrl({
         diffView: {path: this.files[this.fileCursor.index].__path},
         patchNum: this.patchNum,
+        basePatchNum: this.getChangeModel().urlBasePatchNum(
+          this.basePatchNum,
+          this.patchNum
+        ),
       })
     );
   }
@@ -2319,6 +2489,10 @@
     return this.getViewModel().diffUrl({
       diffView: {path},
       patchNum: this.patchNum,
+      basePatchNum: this.getChangeModel().urlBasePatchNum(
+        this.basePatchNum,
+        this.patchNum
+      ),
     });
   }
 
@@ -2388,8 +2562,7 @@
   }
 
   async filesChanged() {
-    if (this.expandedFiles.length > 0) this.expandedFiles = [];
-    await this.updateCleanlyMergedPaths();
+    if (this.expandedFiles.size > 0) this.expandedFiles = new Set();
     if (!this.files || this.files.length === 0) return;
     await this.updateComplete;
     this.fileCursor.stops = Array.from(
@@ -2454,18 +2627,18 @@
     return val ? 'true' : 'false';
   }
 
-  private isFileExpanded(path: string | undefined) {
-    return this.expandedFiles.some(f => f.path === path);
+  isFileExpanded(path: string | undefined) {
+    return path !== undefined && this.expandedFiles.has(path);
   }
 
-  private isFileExpandedStr(path: string | undefined) {
+  isFileExpandedStr(path: string | undefined) {
     return this.booleanToString(this.isFileExpanded(path));
   }
 
   private computeExpandedFiles(): FilesExpandedState {
-    if (this.expandedFiles.length === 0) {
+    if (this.expandedFiles.size === 0) {
       return FilesExpandedState.NONE;
-    } else if (this.expandedFiles.length === this.files.length) {
+    } else if (this.expandedFiles.size === this.files.length) {
       return FilesExpandedState.ALL;
     }
     return FilesExpandedState.SOME;
@@ -2480,18 +2653,21 @@
    * @param newFiles The new files that have been added.
    * Private but used in tests.
    */
-  async expandedFilesChanged(oldFiles: Array<PatchSetFile>) {
+  async expandedFilesChanged(oldFiles?: Set<string>) {
     this.filesExpanded = this.computeExpandedFiles();
 
-    const newFiles = this.expandedFiles.filter(
-      file => (oldFiles ?? []).findIndex(f => f.path === file.path) === -1
-    );
+    const newPaths: string[] = [];
+    for (const path of this.expandedFiles) {
+      if (!oldFiles || !oldFiles.has(path)) {
+        newPaths.push(path);
+      }
+    }
 
     // Required so that the newly created diff view is included in this.diffs.
     await this.updateComplete;
 
-    if (newFiles.length) {
-      await this.renderInOrder(newFiles, this.diffs);
+    if (newPaths.length) {
+      await this.renderInOrder(newPaths, this.diffs);
     }
     this.updateDiffCursor();
     this.diffCursor?.reInitAndUpdateStops();
@@ -2506,11 +2682,10 @@
    *
    * @param initialCount The total number of paths in the pass.
    */
-  async renderInOrder(files: PatchSetFile[], diffElements: GrDiffHost[]) {
+  async renderInOrder(paths: string[], diffElements: GrDiffHost[]) {
     this.reporting.time(Timing.FILE_EXPAND_ALL);
 
-    for (const file of files) {
-      const path = file.path;
+    for (const path of paths) {
       const diffElem = this.findDiffByPath(path, diffElements);
       if (!diffElem) {
         this.reporting.error(
@@ -2522,8 +2697,7 @@
       diffElem.prefetchDiff();
     }
 
-    await asyncForeach(files, async (file, cancel) => {
-      const path = file.path;
+    await asyncForeach(paths, async (path, cancel) => {
       this.cancelForEachDiff = cancel;
 
       const diffElem = this.findDiffByPath(path, diffElements);
@@ -2546,7 +2720,7 @@
       if (
         this.loggedIn &&
         !this.diffPrefs.manual_review &&
-        files.length === 1
+        paths.length === 1
       ) {
         await this.reviewFile(path, true);
       }
@@ -2555,7 +2729,7 @@
 
     this.cancelForEachDiff = undefined;
     this.reporting.timeEnd(Timing.FILE_EXPAND_ALL, {
-      count: files.length,
+      count: paths.length,
       height: this.clientHeight,
     });
     /*
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 684df6e..ecc8ffa 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -18,10 +18,12 @@
   waitEventLoop,
   waitUntil,
 } from '../../../test/test-utils';
+import type {GrDiffMarkdownViewer} from '../../diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer';
 import {
   BasePatchSetNum,
   CommitId,
   EDIT,
+  FIRST_PARENT,
   NumericChangeId,
   PARENT,
   RepoName,
@@ -206,7 +208,12 @@
               <span class="truncatedFileName" title="path/file0">
                 …/file0
               </span>
-              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+              <gr-copy-clipboard
+                buttontitle="Copy file path to clipboard"
+                copytargetname="File path"
+                hideinput=""
+              >
+              </gr-copy-clipboard>
             </a>
           </span>
           <div role="gridcell">
@@ -326,7 +333,12 @@
               <span class="truncatedFileName" title="path/file0">
                 …/file0
               </span>
-              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+              <gr-copy-clipboard
+                buttontitle="Copy file path to clipboard"
+                copytargetname="File path"
+                hideinput=""
+              >
+              </gr-copy-clipboard>
             </a>
           </span>
         `
@@ -344,7 +356,12 @@
               <span class="truncatedFileName" title="path/file1">
                 …/file1
               </span>
-              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+              <gr-copy-clipboard
+                buttontitle="Copy file path to clipboard"
+                copytargetname="File path"
+                hideinput=""
+              >
+              </gr-copy-clipboard>
             </a>
           </span>
         `
@@ -411,6 +428,37 @@
       assert.notOk(fileMode);
     });
 
+    test('renders warning icon for expensive diffs', async () => {
+      element.files = [
+        {
+          __path: 'expensive.txt',
+          lines_inserted: 0,
+          lines_deleted: 0,
+          diffs_too_expensive_to_compute: true,
+          size: 0,
+          size_delta: 0,
+        },
+      ];
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const statsCol = queryAndAssert(fileRows?.[0], '.stats');
+      assert.dom.equal(
+        statsCol,
+        /* HTML */ `
+          <div class="stats" role="gridcell">
+            <div class="">
+              <gr-tooltip-content
+                has-tooltip=""
+                title="Diff too expensive to compute"
+              >
+                <gr-icon class="warning" icon="warning"> </gr-icon>
+              </gr-tooltip-content>
+            </div>
+          </div>
+        `
+      );
+    });
+
     test('renders file status column header', async () => {
       element.files = createFiles(1, {lines_inserted: 9});
       element.filesLeftBase = createFiles(1, {lines_inserted: 9});
@@ -1036,41 +1084,41 @@
         element.fileCursor.setCursorAtIndex(0);
         await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element.expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.size, 0);
 
         pressKey(element, 'i');
         await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[0]);
-        assert.equal(element.expandedFiles.length, 1);
-        assert.equal(element.expandedFiles[0].path, paths[0]);
+        assert.equal(element.expandedFiles.size, 1);
+        assert.isTrue(element.expandedFiles.has(paths[0]));
 
         pressKey(element, 'i');
         await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element.expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.size, 0);
 
         element.fileCursor.setCursorAtIndex(1);
         pressKey(element, 'i');
         await element.updateComplete;
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[1]);
-        assert.equal(element.expandedFiles.length, 1);
-        assert.equal(element.expandedFiles[0].path, paths[1]);
+        assert.equal(element.expandedFiles.size, 1);
+        assert.isTrue(element.expandedFiles.has(paths[1]));
 
         pressKey(element, 'I');
         await element.updateComplete;
         assert.equal(element.diffs.length, paths.length);
-        assert.equal(element.expandedFiles.length, paths.length);
+        assert.equal(element.expandedFiles.size, paths.length);
         for (const diff of element.diffs) {
-          assert.isTrue(element.expandedFiles.some(f => f.path === diff.path));
+          assert.isTrue(element.expandedFiles.has(diff.path!));
         }
         // since _expandedFilesChanged is stubbed
         element.filesExpanded = FilesExpandedState.ALL;
         pressKey(element, 'I');
         await element.updateComplete;
         assert.equal(element.diffs.length, 0);
-        assert.equal(element.expandedFiles.length, 0);
+        assert.equal(element.expandedFiles.size, 0);
       });
 
       test('r key sets reviewed flag', async () => {
@@ -1309,10 +1357,7 @@
       await element.updateComplete;
 
       assert.equal(showHideCheck!.getAttribute('aria-checked'), 'true');
-      assert.notEqual(
-        element.expandedFiles.findIndex(f => f.path === 'myfile.txt'),
-        -1
-      );
+      assert.isTrue(element.expandedFiles.has('myfile.txt'));
     });
 
     test('diff mode correctly toggles the diffs', async () => {
@@ -1382,8 +1427,8 @@
         queryAndAssert<GrIcon>(element, 'gr-icon').icon,
         'expand_more'
       );
-      assert.equal(element.expandedFiles.length, 0);
-      element.toggleFileExpanded({path});
+      assert.equal(element.expandedFiles.size, 0);
+      element.toggleFileExpanded(path);
       await element.updateComplete;
       // Wait for expandedFilesChanged to finish.
       await waitEventLoop();
@@ -1394,8 +1439,8 @@
       );
 
       assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element.expandedFiles.some(f => f.path === path));
-      element.toggleFileExpanded({path});
+      assert.isTrue(element.expandedFiles.has(path));
+      element.toggleFileExpanded(path);
       await element.updateComplete;
       // Wait for expandedFilesChanged to finish.
       await waitEventLoop();
@@ -1405,7 +1450,7 @@
         'expand_more'
       );
       assert.equal(renderSpy.callCount, 1);
-      assert.isFalse(element.expandedFiles.some(f => f.path === path));
+      assert.isFalse(element.expandedFiles.has(path));
     });
 
     test('expandAllDiffs and collapseAllDiffs', async () => {
@@ -1428,7 +1473,7 @@
       await element.updateComplete;
       // Wait for expandedFilesChanged to finish.
       await waitEventLoop();
-      assert.equal(element.expandedFiles.length, 0);
+      assert.equal(element.expandedFiles.size, 0);
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
     });
 
@@ -1459,7 +1504,7 @@
         },
       ];
       sinon.stub(element, 'diffs').get(() => diffs);
-      element.expandedFiles = element.expandedFiles.concat([{path}]);
+      element.expandedFiles = new Set([path]);
       await element.updateComplete;
       await waitEventLoop();
       await promise;
@@ -1469,12 +1514,12 @@
       element.files = [normalize({}, 'foo.bar'), normalize({}, 'baz.bar')];
       await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.NONE);
-      element.expandedFiles.push({path: 'baz.bar'});
-      element.expandedFilesChanged([{path: 'baz.bar'}]);
+      element.expandedFiles.add('baz.bar');
+      element.expandedFilesChanged(new Set(['baz.bar']));
       await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-      element.expandedFiles.push({path: 'foo.bar'});
-      element.expandedFilesChanged([{path: 'foo.bar'}]);
+      element.expandedFiles.add('foo.bar');
+      element.expandedFilesChanged(new Set(['baz.bar', 'foo.bar']));
       await element.updateComplete;
       assert.equal(element.filesExpanded, FilesExpandedState.ALL);
       element.collapseAllDiffs();
@@ -1521,10 +1566,7 @@
         },
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
       ] as any;
-      await element.renderInOrder(
-        [{path: 'p2'}, {path: 'p1'}, {path: 'p0'}],
-        diffs
-      );
+      await element.renderInOrder(['p2', 'p1', 'p0'], diffs);
       await element.updateComplete;
       assert.isFalse(reviewStub.called);
     });
@@ -1543,7 +1585,7 @@
         },
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
       ] as any;
-      await element.renderInOrder([{path: 'p2'}], diffs);
+      await element.renderInOrder(['p2'], diffs);
       await element.updateComplete;
       assert.equal(reviewStub.callCount, 1);
     });
@@ -1579,11 +1621,11 @@
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
       ] as any;
 
-      await element.renderInOrder([{path: 'p'}], diffs);
+      await element.renderInOrder(['p'], diffs);
       await element.updateComplete;
       assert.isFalse(reviewStub.called);
       delete element.diffPrefs.manual_review;
-      await element.renderInOrder([{path: 'p'}], diffs);
+      await element.renderInOrder(['p'], diffs);
       await element.updateComplete;
       // Wait for renderInOrder to finish
       await waitEventLoop();
@@ -1592,18 +1634,10 @@
     });
 
     suite('for merge commits', () => {
-      let filesStub: sinon.SinonStub;
-
       setup(async () => {
         element.files = [
           normalize({size: 0, size_delta: 0}, 'conflictingFile.js'),
         ];
-        filesStub = stubRestApi('getChangeOrEditFiles')
-          .onFirstCall()
-          .resolves({
-            'conflictingFile.js': {size: 0, size_delta: 0},
-            'cleanlyMergedFile.js': {size: 0, size_delta: 0},
-          });
         stubRestApi('getReviewedFiles').resolves([]);
         stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
         const changeWithMultipleParents = {
@@ -1630,6 +1664,8 @@
       });
 
       test('displays cleanly merged file count', async () => {
+        element.cleanlyMergedPaths = ['cleanlyMergedFile.js'];
+        await element.updateComplete;
         await waitUntil(() => !!query(element, '.cleanlyMergedText'));
 
         const message = queryAndAssert<HTMLSpanElement>(
@@ -1640,15 +1676,10 @@
       });
 
       test('displays plural cleanly merged file count', async () => {
-        filesStub.restore();
-        stubRestApi('getChangeOrEditFiles')
-          .onFirstCall()
-          .resolves({
-            'conflictingFile.js': {size: 0, size_delta: 0},
-            'cleanlyMergedFile.js': {size: 0, size_delta: 0},
-            'anotherCleanlyMergedFile.js': {size: 0, size_delta: 0},
-          });
-        await element.updateCleanlyMergedPaths();
+        element.cleanlyMergedPaths = [
+          'cleanlyMergedFile.js',
+          'anotherCleanlyMergedFile.js',
+        ];
         await element.updateComplete;
         await waitUntil(() => !!query(element, '.cleanlyMergedText'));
 
@@ -1660,34 +1691,17 @@
       });
 
       test('displays button for navigating to parent 1 base', async () => {
+        element.cleanlyMergedPaths = ['cleanlyMergedFile.js'];
+        await element.updateComplete;
         await waitUntil(() => !!query(element, '.showParentButton'));
 
         queryAndAssert(element, '.showParentButton');
       });
 
-      test('computes old paths for cleanly merged files', async () => {
-        filesStub.restore();
-        stubRestApi('getChangeOrEditFiles')
-          .onFirstCall()
-          .resolves({
-            'conflictingFile.js': {size: 0, size_delta: 0},
-            'cleanlyMergedFile.js': {
-              old_path: 'cleanlyMergedFileOldName.js',
-              size: 0,
-              size_delta: 0,
-            },
-          });
-        await element.updateCleanlyMergedPaths();
-
-        assert.deepEqual(element.cleanlyMergedOldPaths, [
-          'cleanlyMergedFileOldName.js',
-        ]);
-      });
-
       test('not shown for non-Auto Merge base parents', async () => {
+        element.cleanlyMergedPaths = [];
         element.basePatchNum = 1 as BasePatchSetNum;
         element.patchNum = 2 as RevisionPatchSetNum;
-        await element.updateCleanlyMergedPaths();
         await element.updateComplete;
 
         assert.notOk(query(element, '.cleanlyMergedText'));
@@ -1695,9 +1709,9 @@
       });
 
       test('not shown in edit mode', async () => {
+        element.cleanlyMergedPaths = [];
         element.basePatchNum = 1 as BasePatchSetNum;
         element.patchNum = EDIT;
-        await element.updateCleanlyMergedPaths();
         await element.updateComplete;
 
         assert.notOk(query(element, '.cleanlyMergedText'));
@@ -1805,6 +1819,17 @@
       );
     });
 
+    test('diff url keeps first parent base', () => {
+      const path = 'index.php';
+      element.basePatchNum = FIRST_PARENT;
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.editMode = false;
+      assert.equal(
+        element.computeDiffURL(path),
+        '/c/gerrit/+/42/-1..1/index.php'
+      );
+    });
+
     test('edit url', () => {
       element.change = {
         ...createParsedChange(),
@@ -2371,4 +2396,130 @@
       assert.equal(element.computeClass('', 'file.java'), '');
     });
   });
+
+  suite('rich markdown diff', () => {
+    setup(async () => {
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+      stubElement('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
+      stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
+
+      element = await fixture(html`<gr-file-list></gr-file-list>`);
+      element.numFilesShown = 5;
+      element.files = [normalize({}, 'README.md'), normalize({}, 'file.ts')];
+      await element.updateComplete;
+    });
+
+    test('toggle button rendered only for markdown files', () => {
+      const rows = queryAll(element, '.file-row');
+      assert.equal(rows.length, 2);
+
+      const mdToggle = rows[0].querySelector('.toggleRichMarkdown');
+      assert.isOk(mdToggle);
+
+      const nonMdToggle = rows[1].querySelector('.toggleRichMarkdown');
+      assert.isNotOk(nonMdToggle);
+    });
+
+    test('clicking toggleRichMarkdown expands file and enables rich mode', async () => {
+      assert.isFalse(element.isFileExpanded('README.md'));
+      assert.isFalse(element.isShowingRichMarkdown('README.md'));
+
+      element.toggleRichMarkdown('README.md');
+      await element.updateComplete;
+
+      assert.isTrue(element.isFileExpanded('README.md'));
+      assert.isTrue(element.isShowingRichMarkdown('README.md'));
+
+      const viewer = query(element, 'gr-diff-markdown-viewer');
+      assert.isOk(viewer);
+
+      // Toggle off
+      element.toggleRichMarkdown('README.md');
+      await element.updateComplete;
+
+      assert.isFalse(element.isShowingRichMarkdown('README.md'));
+      const viewerAfter = query(element, 'gr-diff-markdown-viewer');
+      assert.isNotOk(viewerAfter);
+    });
+
+    test('handleNewComment delegates to viewer when viewer has active selection', async () => {
+      element.loggedIn = true;
+      element.toggleRichMarkdown('README.md');
+      await element.updateComplete;
+
+      const viewer = queryAndAssert<GrDiffMarkdownViewer>(
+        element,
+        'gr-diff-markdown-viewer'
+      );
+      assert.isTrue(viewer.loggedIn);
+
+      sinon.stub(viewer, 'hasActiveSelection').returns(true);
+      const commentSpy = sinon.spy(viewer, 'createCommentFromSelectionOrHover');
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      (element as any).handleNewComment();
+
+      assert.isTrue(commentSpy.calledOnce);
+    });
+
+    test('c and C shortcuts delegate to viewer when cursor is on rich markdown file', async () => {
+      element.loggedIn = true;
+      element.toggleRichMarkdown('README.md');
+      await element.updateComplete;
+
+      const viewer = queryAndAssert<GrDiffMarkdownViewer>(
+        element,
+        'gr-diff-markdown-viewer'
+      );
+      const commentSpy = sinon.spy(viewer, 'createCommentFromSelectionOrHover');
+
+      element.fileCursor.setCursorAtIndex(0);
+      assert.equal(element.files[element.fileCursor.index].__path, 'README.md');
+
+      pressKey(element, 'c');
+      assert.isTrue(commentSpy.calledOnce);
+
+      pressKey(element, 'C');
+      assert.isTrue(commentSpy.calledTwice);
+    });
+
+    test('toggling rich to source flushes drafts for matching file viewer', async () => {
+      element.files = [normalize({}, 'README.md'), normalize({}, 'DOCS.md')];
+      await element.updateComplete;
+
+      element.toggleRichMarkdown('README.md');
+      element.toggleRichMarkdown('DOCS.md');
+      await element.updateComplete;
+
+      const viewers = Array.from(
+        queryAll<GrDiffMarkdownViewer>(element, 'gr-diff-markdown-viewer')
+      );
+      assert.equal(viewers.length, 2);
+
+      const readmeViewer = viewers.find(v => v.path === 'README.md')!;
+      const docsViewer = viewers.find(v => v.path === 'DOCS.md')!;
+      assert.isOk(readmeViewer);
+      assert.isOk(docsViewer);
+
+      const readmeSaveSpy = sinon.spy(readmeViewer, 'autoSaveDrafts');
+      const docsSaveSpy = sinon.spy(docsViewer, 'autoSaveDrafts');
+
+      await element.toggleRichMarkdown('DOCS.md');
+      await element.updateComplete;
+
+      assert.isFalse(readmeSaveSpy.called);
+      assert.isTrue(docsSaveSpy.calledOnce);
+    });
+
+    test('gridcell count parity across markdown and non-markdown rows', () => {
+      const rows = queryAll(element, '.file-row');
+      assert.equal(rows.length, 2);
+
+      const mdCells = queryAll(rows[0], '[role="gridcell"]');
+      const nonMdCells = queryAll(rows[1], '[role="gridcell"]');
+      assert.equal(mdCells.length, nonMdCells.length);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
index ae1b582..b400a9a 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
@@ -15,7 +15,7 @@
   LabelDefinitionInfo,
 } from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
-import {NumericChangeId, ServerInfo} from '../../../types/common';
+import {EmailAddress, NumericChangeId, ServerInfo} from '../../../types/common';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-icon/gr-icon';
@@ -45,18 +45,20 @@
 import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field.js';
 import {
   computeFlowString,
+  EMAIL_PATTERN,
   Stage,
   STAGE_SEPARATOR,
 } from '../../../utils/flows-util';
+import {debounce, DelayedTask} from '../../../utils/async-util';
 import {FlowCustomConditionInfo} from '../../../api/flows';
 import {changeModelToken} from '../../../models/change/change-model';
 import {combineLatest} from 'rxjs';
 import {getUserName} from '../../../utils/display-name-util';
 import {LabelSuggestionsProvider} from '../../../services/label-suggestions-provider';
 import {queryAndAssert, unique} from '../../../utils/common-util';
-import {fireAlert} from '../../../utils/event-util';
 import {MdOutlinedSelect} from '@material/web/select/outlined-select.js';
 import {Interaction} from '../../../constants/reporting';
+import {isDefined} from '../../../types/types';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 
@@ -105,9 +107,11 @@
 
   @state() private loading = false;
 
+  @state() errorMessage?: string;
+
   @state() private serverConfig?: ServerInfo;
 
-  @state() flowString = '';
+  @property({type: String}) flowString = '';
 
   @state()
   // private but used in tests
@@ -146,6 +150,8 @@
 
   private customConditions: FlowCustomConditionInfo[] = [];
 
+  @state() private disabledActions: string[] = [];
+
   private readonly accountSuggestions: SuggestionProvider = (
     predicate,
     expression
@@ -166,18 +172,19 @@
     );
   };
 
-  private readonly reviewerSuggestions: SuggestionProvider = expression => {
-    const accountFetcher = (expr: string) =>
-      this.restApiService.queryAccounts(
-        expr,
-        MAX_AUTOCOMPLETE_RESULTS,
-        undefined,
-        undefined,
-        throwingErrorCallback
-      );
-    const emails = expression.split(',');
-    const emailToAutocomplete = emails.pop() ?? '';
-    return accountFetcher(emailToAutocomplete.trim()).then(accounts => {
+  private readonly reviewerSuggestions: SuggestionProvider =
+    async expression => {
+      const accountFetcher = (expr: string) =>
+        this.restApiService.queryAccounts(
+          expr,
+          MAX_AUTOCOMPLETE_RESULTS,
+          undefined,
+          undefined,
+          throwingErrorCallback
+        );
+      const emails = expression.split(',');
+      const emailToAutocomplete = emails.pop() ?? '';
+      const accounts = await accountFetcher(emailToAutocomplete.trim());
       if (!accounts) {
         return [];
       }
@@ -192,8 +199,7 @@
             name: account.email,
           };
         });
-    });
-  };
+    };
 
   constructor() {
     super();
@@ -242,6 +248,14 @@
         );
         const allConditions = await Promise.all(conditionsPromises);
         this.customConditions = allConditions.flat();
+
+        const disabledActions = providers
+          .map(
+            provider =>
+              provider.getDisabledActions && provider.getDisabledActions()
+          )
+          .flat();
+        this.disabledActions = Array.from(disabledActions).filter(isDefined);
       }
     );
 
@@ -383,6 +397,27 @@
         .info-title {
           font-weight: var(--font-weight-bold);
         }
+        .error-banner {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-s);
+          background-color: var(--error-background, #fce8e6);
+          color: var(--error-foreground, #c5221f);
+          padding: var(--spacing-m);
+          border-radius: var(--border-radius, 4px);
+          margin-bottom: var(--spacing-m);
+          border: 1px solid var(--error-foreground, #c5221f);
+        }
+        .error-banner .error-icon {
+          color: var(--error-foreground, #c5221f);
+        }
+        .error-banner .error-text {
+          flex: 1;
+          font-weight: var(--font-weight-500, 500);
+        }
+        .error-banner .close-error-btn {
+          --gr-button-color: var(--error-foreground, #c5221f);
+        }
       `,
     ];
   }
@@ -404,11 +439,13 @@
   }
 
   private async getFlowActions() {
-    if (!this.changeNum) return;
+    if (!this.changeNum) {
+      return;
+    }
     const actions = await this.restApiService.listFlowActions(this.changeNum);
-    this.flowActions = (actions ?? []).sort((a, b) =>
-      a.name.localeCompare(b.name)
-    );
+    this.flowActions = (actions ?? [])
+      .filter(action => !this.disabledActions.includes(action.name))
+      .sort((a, b) => a.name.localeCompare(b.name));
   }
 
   private renderStages() {
@@ -442,7 +479,21 @@
     );
   }
 
+  // private but used in tests
+  parseTask?: DelayedTask;
+
+  // We debounce parsing to avoid triggering stage re-renders and API calls
+  // on every keystroke while the user types in the textarea
+  private parseStagesDebounced() {
+    this.parseTask = debounce(
+      this.parseTask,
+      () => this.parseStagesFromRawFlow(this.flowString),
+      300
+    );
+  }
+
   private parseStagesFromRawFlow(rawFlow: string) {
+    this.errorMessage = undefined;
     if (!rawFlow) {
       this.stages = [];
       return;
@@ -513,7 +564,9 @@
   }
 
   private renderDocumentationLink(link?: string, slot?: string) {
-    if (!link) return;
+    if (!link) {
+      return;
+    }
     return html` <a
       class="help"
       slot=${ifDefined(slot)}
@@ -541,11 +594,31 @@
           ?disabled=${this.loading}
           @confirm=${this.handleCreateFlow}
           @cancel=${() => {
+            this.errorMessage = undefined;
             this.createModal?.close();
           }}
         >
           <div slot="header">Create new flow</div>
           <div class="main" slot="main">
+            ${when(
+              this.errorMessage,
+              () => html`
+                <div class="error-banner">
+                  <gr-icon icon="warning" class="error-icon" filled></gr-icon>
+                  <span class="error-text">${this.errorMessage}</span>
+                  <gr-button
+                    link
+                    class="close-error-btn"
+                    @click=${() => {
+                      this.errorMessage = undefined;
+                    }}
+                    title="Dismiss error"
+                  >
+                    <gr-icon icon="close"></gr-icon>
+                  </gr-button>
+                </div>
+              `
+            )}
             <div
               class="section-header"
               @click=${(e: Event) => this.toggleGuidedBuilder(e)}
@@ -666,7 +739,7 @@
                     .value=${this.flowString}
                     @input=${(e: InputEvent) => {
                       this.flowString = (e.target as MdOutlinedTextField).value;
-                      this.parseStagesFromRawFlow(this.flowString);
+                      this.parseStagesDebounced();
                     }}
                   ></md-outlined-text-field>
                   <gr-copy-clipboard
@@ -720,7 +793,9 @@
   }
 
   private updateCurrentParameterForVote() {
-    if (this.currentAction !== 'vote') return;
+    if (this.currentAction !== 'vote') {
+      return;
+    }
 
     if (this.selectedLabelForVote && this.selectedValueForVote) {
       let value = this.selectedValueForVote;
@@ -739,55 +814,49 @@
   }
 
   // TODO: Move into the common util file
-  fetchProjects(
+  async fetchProjects(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
-    return this.restApiService
-      .getSuggestedRepos(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS,
-        throwingErrorCallback
-      )
-      .then(projects => {
-        if (!projects) {
-          return [];
-        }
-        const keys = Object.keys(projects);
-        return keys.map(key => {
-          return {text: predicate + ':' + key};
-        });
-      });
+    const projects = await this.restApiService.getSuggestedRepos(
+      expression,
+      MAX_AUTOCOMPLETE_RESULTS,
+      throwingErrorCallback
+    );
+    if (!projects) {
+      return [];
+    }
+    const keys = Object.keys(projects);
+    return keys.map(key => {
+      return {text: `${predicate}:${key}`};
+    });
   }
 
-  fetchGroups(
+  async fetchGroups(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
     if (expression.length === 0) {
-      return Promise.resolve([]);
+      return [];
     }
-    return this.restApiService
-      .getSuggestedGroups(
-        expression,
-        undefined,
-        MAX_AUTOCOMPLETE_RESULTS,
-        throwingErrorCallback
-      )
-      .then(groups => {
-        if (!groups) {
-          return [];
-        }
-        const keys = Object.keys(groups);
-        return keys.map(key => {
-          return {text: predicate + ':' + key};
-        });
-      });
+    const groups = await this.restApiService.getSuggestedGroups(
+      expression,
+      undefined,
+      MAX_AUTOCOMPLETE_RESULTS,
+      throwingErrorCallback
+    );
+    if (!groups) {
+      return [];
+    }
+    const keys = Object.keys(groups);
+    return keys.map(key => {
+      return {text: `${predicate}:${key}`};
+    });
   }
 
   private handleAddStage() {
     if (this.currentCondition.trim() === '') {
-      fireAlert(this, 'Condition string cannot be empty.');
+      this.errorMessage = 'Condition string cannot be empty.';
       return;
     }
     const condition =
@@ -821,7 +890,14 @@
   }
 
   private async handleCreateFlow() {
-    if (!this.changeNum) return;
+    if (!this.changeNum) {
+      return;
+    }
+
+    // Catch edits made within the debounce window.
+    if (this.copyPasteExpanded && this.flowString) {
+      this.parseStagesFromRawFlow(this.flowString);
+    }
 
     const allStages = [...this.stages];
 
@@ -837,8 +913,25 @@
       });
     }
 
+    if (allStages.length === 0) {
+      this.errorMessage = 'Flow must have at least one stage.';
+      return;
+    }
+
     if (allStages.some(s => s.condition.trim() === '')) {
-      fireAlert(this, 'All stages must have a condition.');
+      this.errorMessage = 'All stages must have a condition.';
+      return;
+    }
+
+    const lastStage = allStages[allStages.length - 1];
+    if (!lastStage?.action || lastStage.action.trim() === '') {
+      this.errorMessage = 'The final stage of a flow must have an action.';
+      return;
+    }
+
+    const invalidAccountError = await this.validateAccountEmails(allStages);
+    if (invalidAccountError) {
+      this.errorMessage = invalidAccountError;
       return;
     }
 
@@ -862,7 +955,32 @@
         return {condition: stage.condition};
       }),
     };
-    await this.getFlowsModel().createFlow(flowInput);
+
+    let hasError = false;
+    await this.getFlowsModel().createFlow(
+      flowInput,
+      (response?: Response | null) => {
+        if (response) {
+          hasError = true;
+          this.loading = false;
+          response
+            .text()
+            .then(text => {
+              this.errorMessage = text || response.statusText;
+            })
+            .catch(() => {
+              this.errorMessage = response.statusText;
+            });
+        }
+      }
+    );
+
+    if (hasError) {
+      return;
+    }
+
+    this.errorMessage = undefined;
+
     this.reportingService.reportInteraction(Interaction.FLOW_CREATED);
     this.stages = [];
     this.currentCondition = '';
@@ -872,13 +990,54 @@
     this.createModal?.close();
   }
 
+  private async validateAccountEmails(
+    stages: Stage[]
+  ): Promise<string | undefined> {
+    const checkAccount = async (email: string): Promise<string | undefined> => {
+      try {
+        const account = await this.restApiService.getAccountDetails(
+          email as EmailAddress,
+          // Pass a no-op errFn to suppress PolyGerrit's global 404 error dialogs,
+          // allowing us to handle 404s locally via in-modal error banners.
+          () => {}
+        );
+        return !account ? `Account '${email}' was not found.` : undefined;
+      } catch {
+        return undefined;
+      }
+    };
+
+    const accountPromises: Array<Promise<string | undefined>> = [];
+    for (const stage of stages) {
+      if (!stage.parameterStr) {
+        continue;
+      }
+      const params = stage.parameterStr
+        .split(/[\s,]+/)
+        .filter(p => p.length > 0);
+      for (const p of params) {
+        if (EMAIL_PATTERN.test(p)) {
+          accountPromises.push(checkAccount(p));
+        }
+      }
+    }
+    const results = await Promise.all(accountPromises);
+    return results.find(errorMsg => errorMsg !== undefined);
+  }
+
   // TODO: remove eventually when we fully migrated to fetching placeholders from the backend.
   private getParametersPlaceholder(actionName: string) {
     const action = this.flowActions.find(a => a.name === actionName);
-    if (action?.parameters_placeholder) return action.parameters_placeholder;
+    if (action?.parameters_placeholder) {
+      return action.parameters_placeholder;
+    }
 
-    if (actionName === 'add-reviewer') return 'user@example.com';
-    if (actionName === 'vote') return '<Label>+/-<Value>';
+    if (actionName === 'add-reviewer') {
+      return 'user@example.com';
+    }
+    if (actionName === 'vote') {
+      return '<Label>+/-<Value>';
+    }
     return 'Parameters';
   }
 
@@ -978,7 +1137,9 @@
   }
 
   private renderParameterInputField() {
-    if (this.currentAction === 'submit') return undefined;
+    if (this.currentAction === 'submit') {
+      return undefined;
+    }
     if (
       this.currentAction === 'add-reviewer' ||
       this.currentAction === 'add-to-attention-set' ||
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_screenshot_test.ts
index e2f58c9..5c53f59 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_screenshot_test.ts
@@ -77,4 +77,14 @@
     await visualDiff(header, 'gr-create-flow-dialog-header');
     await visualDiffDarkTheme(header, 'gr-create-flow-dialog-header');
   });
+
+  test('dialog error banner screenshot', async () => {
+    element.errorMessage = "Account 'invalid@example.com' was not found.";
+    await element.updateComplete;
+
+    const dialog = queryAndAssert(element, '#createModal');
+
+    await visualDiff(dialog, 'gr-create-flow-dialog-error');
+    await visualDiffDarkTheme(dialog, 'gr-create-flow-dialog-error');
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
index ba96d2e..02c88e1 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
@@ -7,7 +7,12 @@
 import './gr-create-flow';
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrCreateFlow} from './gr-create-flow';
-import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {
   AccountId,
   EmailAddress,
@@ -20,6 +25,7 @@
 import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {
+  createAccountDetailWithIdNameAndEmail,
   createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
@@ -35,17 +41,17 @@
   let flowsModel: FlowsModel;
 
   setup(async () => {
-    const restApi = getAppContext().restApiService;
-    sinon
-      .stub(restApi, 'listFlowActions')
-      .resolves([
-        {name: 'act-1'},
-        {name: 'act-2'},
-        {name: 'vote'},
-        {name: 'add-reviewer'},
-        {name: 'submit'},
-        {name: 'vote'},
-      ] as FlowActionInfo[]);
+    stubRestApi('getAccountDetails').callsFake(async () =>
+      createAccountDetailWithIdNameAndEmail()
+    );
+    stubRestApi('listFlowActions').resolves([
+      {name: 'act-1'},
+      {name: 'act-2'},
+      {name: 'vote'},
+      {name: 'add-reviewer'},
+      {name: 'submit'},
+      {name: 'vote'},
+    ] as FlowActionInfo[]);
 
     flowsModel = testResolver(flowsModelToken);
     const hostUrl =
@@ -237,6 +243,7 @@
       const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm');
       confirmButton.click();
       await element.updateComplete;
+      await new Promise(r => setTimeout(r, 0));
 
       assert.isTrue(createFlowStub.calledOnce);
       const flowInput = createFlowStub.lastCall.args[0];
@@ -295,6 +302,7 @@
       const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm');
       confirmButton.click();
       await element.updateComplete;
+      await new Promise(r => setTimeout(r, 0));
 
       assert.isTrue(createFlowStub.calledOnce);
       const flowInput = createFlowStub.lastCall.args[0];
@@ -358,6 +366,7 @@
       const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm');
       confirmButton.click();
       await element.updateComplete;
+      await new Promise(r => setTimeout(r, 0));
 
       assert.isTrue(createFlowStub.calledOnce);
       const flowInput = createFlowStub.lastCall.args[0];
@@ -414,6 +423,7 @@
       const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm');
       confirmButton.click();
       await element.updateComplete;
+      await new Promise(r => setTimeout(r, 0));
 
       assert.isTrue(createFlowStub.calledOnce);
       const flowInput = createFlowStub.lastCall.args[0];
@@ -476,6 +486,7 @@
       const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm');
       confirmButton.click();
       await element.updateComplete;
+      await new Promise(r => setTimeout(r, 0));
 
       assert.isTrue(createFlowStub.calledOnce);
       const flowInput = createFlowStub.lastCall.args[0];
@@ -643,17 +654,11 @@
       addButton.click();
       await element.updateComplete;
 
-      assert.isTrue(alertStub.calledOnce);
-      assert.equal(
-        alertStub.lastCall.args[0].detail.message,
-        'Condition string cannot be empty.'
-      );
+      assert.equal(element.errorMessage, 'Condition string cannot be empty.');
       assert.lengthOf(element.stages, 0);
     });
 
     test('creating flow with empty condition fails', async () => {
-      const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
       const createFlowStub = sinon.stub(flowsModel, 'createFlow');
 
       const createButton = queryAndAssert<GrButton>(
@@ -675,6 +680,7 @@
       );
       rawFlowTextarea.value = '-> act-1';
       rawFlowTextarea.dispatchEvent(new InputEvent('input'));
+      element.parseTask?.flush();
       await element.updateComplete;
 
       assert.lengthOf(element.stages, 1);
@@ -684,11 +690,7 @@
       confirmButton.click();
       await element.updateComplete;
 
-      assert.isTrue(alertStub.calledOnce);
-      assert.equal(
-        alertStub.lastCall.args[0].detail.message,
-        'All stages must have a condition.'
-      );
+      assert.equal(element.errorMessage, 'All stages must have a condition.');
       assert.isFalse(createFlowStub.called);
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule.ts
index 34433f5..3b78846 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule.ts
@@ -7,7 +7,7 @@
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {AccountDetailInfo, FlowStageState} from '../../../api/rest-api';
-import {formatActionName} from '../../../utils/flows-util';
+import {EMAIL_PATTERN, formatActionName} from '../../../utils/flows-util';
 import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {ifDefined} from 'lit/directives/if-defined.js';
@@ -98,6 +98,17 @@
         gr-icon.failed {
           color: var(--error-foreground);
         }
+        .invalid-account {
+          color: var(--error-foreground);
+          border: 1px dashed var(--error-foreground);
+          display: inline-flex;
+          align-items: center;
+          gap: var(--spacing-xs);
+        }
+        .warning-icon {
+          color: var(--error-foreground);
+          font-size: 14px;
+        }
         .error {
           color: var(--error-foreground);
         }
@@ -134,18 +145,22 @@
     }
   }
 
-  private updateAccounts() {
+  private async updateAccounts() {
     if (!this.parameters) {
-      if (this.accounts.size > 0) this.accounts = new Map();
+      if (this.accounts.size > 0) {
+        this.accounts = new Map();
+      }
       return;
     }
 
     const promises = this.parameters.map(async p => {
-      // Simple email regex check
-      if (/\S+@\S+\.\S+/.test(p)) {
+      if (EMAIL_PATTERN.test(p)) {
         try {
           const account = await this.restApiService.getAccountDetails(
-            p as UserId
+            p as UserId,
+            // Pass a no-op errFn to suppress PolyGerrit's global 404 error
+            // dialogs when looking up invalid/unrecognized email addresses.
+            () => {}
           );
           return {key: p, value: account ?? null};
         } catch (e) {
@@ -156,17 +171,18 @@
       return {key: p, value: null};
     });
 
-    Promise.all(promises).then(results => {
-      const newAccounts = new Map<string, AccountDetailInfo | null>();
-      for (const result of results) {
-        newAccounts.set(result.key, result.value);
-      }
-      this.accounts = newAccounts;
-    });
+    const results = await Promise.all(promises);
+    const newAccounts = new Map<string, AccountDetailInfo | null>();
+    for (const result of results) {
+      newAccounts.set(result.key, result.value);
+    }
+    this.accounts = newAccounts;
   }
 
   private renderParameters() {
-    if (!this.parameters || this.parameters.length === 0) return nothing;
+    if (!this.parameters || this.parameters.length === 0) {
+      return nothing;
+    }
     return html`
       ${this.parameters.map(p => {
         const account = this.accounts.get(p);
@@ -178,6 +194,14 @@
             </span>
           `;
         }
+        if (EMAIL_PATTERN.test(p) && this.accounts.has(p) && account === null) {
+          return html`
+            <span class="parameter invalid-account" title="Account not found">
+              <gr-icon icon="warning" class="warning-icon"></gr-icon>
+              <code>${p}</code>
+            </span>
+          `;
+        }
         return html`<span class="parameter"><code>${p}</code></span>`;
       })}
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 8c3bd77..304d542 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -5,8 +5,13 @@
  */
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
+
 import {css, html, LitElement, nothing} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+
+import {LabelNameToValuesMap} from '../../../api/rest-api';
+import {ChangeStatus} from '../../../constants/constants';
+import {fontStyles} from '../../../styles/gr-font-styles';
 import {
   AccountInfo,
   ChangeInfo,
@@ -19,11 +24,9 @@
   getApplicableLabels,
   getDefaultValue,
   getTriggerVotes,
+  getVoteForAccount,
   Label,
 } from '../../../utils/label-util';
-import {ChangeStatus} from '../../../constants/constants';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {LabelNameToValuesMap} from '../../../api/rest-api';
 
 @customElement('gr-label-scores')
 export class GrLabelScores extends LitElement {
@@ -136,11 +139,13 @@
   }
 
   private renderErrorMessages() {
+    const mergedMessage =
+      'Because this change has been merged, votes may not be decreased. You can still reply to comments without changing your vote.';
     return html`<div
         class="mergedMessage"
         ?hidden=${this.change?.status !== ChangeStatus.MERGED}
       >
-        Because this change has been merged, votes may not be decreased.
+        ${mergedMessage}
       </div>
       <div
         class="abandonedMessage"
@@ -169,7 +174,13 @@
       if (selectedVal === undefined) continue;
 
       const defValNum = getDefaultValue(this.change?.labels, label);
-      if (includeDefaults || selectedVal !== defValNum) {
+      // The user's previous vote from the change labels.
+      const prevValStr = getVoteForAccount(label, this.account, this.change);
+      const prevValNum = prevValStr !== null ? Number(prevValStr) : defValNum;
+
+      // If includeDefaults is true, include the label.
+      // Otherwise, ONLY include it if the user actually changed their vote.
+      if (includeDefaults || selectedVal !== prevValNum) {
         labels[label] = selectedVal;
       }
     }
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts
index 134ecc0..8b738e6 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts
@@ -82,12 +82,14 @@
     container.appendChild(element);
     document.body.appendChild(container);
 
-    await visualDiff(container, 'gr-label-scores-long-trigger-vote-label');
-    await visualDiffDarkTheme(
-      container,
-      'gr-label-scores-long-trigger-vote-label'
-    );
-
-    document.body.removeChild(container);
+    try {
+      await visualDiff(container, 'gr-label-scores-long-trigger-vote-label');
+      await visualDiffDarkTheme(
+        container,
+        'gr-label-scores-long-trigger-vote-label'
+      );
+    } finally {
+      document.body.removeChild(container);
+    }
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
index 1de151e..0def9d8 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -79,6 +79,8 @@
   });
 
   test('render', () => {
+    const mergedMessage =
+      'Because this change has been merged, votes may not be decreased. You can still reply to comments without changing your vote.';
     assert.shadowDom.equal(
       element,
       /* HTML */ `
@@ -87,9 +89,7 @@
         </div>
         <gr-label-score-row name="Code-Review"> </gr-label-score-row>
         <gr-label-score-row name="Verified"> </gr-label-score-row>
-        <div class="mergedMessage" hidden="">
-          Because this change has been merged, votes may not be decreased.
-        </div>
+        <div class="mergedMessage" hidden="">${mergedMessage}</div>
         <div class="abandonedMessage" hidden="">
           Because this change has been abandoned, you cannot vote.
         </div>
@@ -128,6 +128,27 @@
     assert.deepEqual(element.getLabelValues(false), {});
   });
 
+  test('getLabelValues with previous vote and includeDefaults=false', async () => {
+    // Setup gives account +1 on Code-Review and +1 on Verified.
+    const row = queryAndAssert<GrLabelScoreRow>(
+      element,
+      'gr-label-score-row[name="Code-Review"]'
+    );
+    // User changes their vote to +2
+    row.setSelectedValue('+2');
+    await element.updateComplete;
+
+    // includeDefaults=false should OMIT Verified (since it is unchanged at
+    // +1) but should INCLUDE Code-Review (since it changed to +2).
+    assert.deepEqual(element.getLabelValues(false), {'Code-Review': 2});
+
+    // Changing back to +1 (original vote) makes it unchanged again, so it's
+    // omitted.
+    row.setSelectedValue('+1');
+    await element.updateComplete;
+    assert.deepEqual(element.getLabelValues(false), {});
+  });
+
   test('getVoteForAccount', () => {
     const labelName = 'Code-Review';
     assert.strictEqual(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 5e23cbbd..a6c7ce3 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -51,6 +51,7 @@
 import {ChangeMessageDeletedEventDetail} from '../../../types/events';
 import {configModelToken} from '../../../models/config/config-model';
 import {userModelToken} from '../../../models/user/user-model';
+import {computeMainCodeBrowserWeblink} from '../../../utils/weblink-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {LABEL_TITLE_SCORE_PATTERN} from '../../../utils/message-util';
 
@@ -348,6 +349,7 @@
         .account=${this.author}
         .change=${this.change}
         class="authorLabel"
+        ?is-ai=${this.hasAiComments()}
       ></gr-account-label>
       <gr-message-scores
         .labelExtremes=${this.labelExtremes}
@@ -686,7 +688,62 @@
       }
       return line;
     });
-    return mappedLines.join('\n').trim();
+    let result = mappedLines.join('\n').trim();
+    if (isExpanded) {
+      result = this.linkifyCommitHashes(result);
+    }
+    return result;
+  }
+
+  /**
+   * Converts commit SHAs in "submitted as <sha>" and "cherry-picked as <sha>"
+   * messages into markdown links using the configured code browser weblinks.
+   */
+  private linkifyCommitHashes(text: string): string {
+    return text.replace(
+      /((?:submitted|cherry-picked) as )([0-9a-f]{40}|[0-9a-f]{64})\b/g,
+      (_match, prefix: string, sha: string) => {
+        const url = this.getCommitUrl(sha);
+        if (url) {
+          return `${prefix}[${sha}](${url})`;
+        }
+        return `${prefix}${sha}`;
+      }
+    );
+  }
+
+  private getCommitUrl(sha: string): string | undefined {
+    // Prefer the explicit submitCommitUrl from gerrit.config.
+    const submitCommitUrl = this.config?.gerrit?.submit_commit_url;
+    if (submitCommitUrl) {
+      const urlWithCommit = submitCommitUrl.includes('${commit}')
+        ? submitCommitUrl.replace('${commit}', sha)
+        : `${submitCommitUrl.replace(/\/+$/, '')}/${sha}`;
+      try {
+        const url = new URL(urlWithCommit);
+        if (url.protocol === 'http:' || url.protocol === 'https:') {
+          return url.toString();
+        }
+      } catch {
+        // Fall back to the revision's weblinks.
+      }
+    }
+    // Fall back to deriving a URL from the revision's weblinks.
+    if (this.change?.revisions) {
+      for (const rev of Object.values(this.change.revisions)) {
+        const weblink = computeMainCodeBrowserWeblink(
+          rev.commit?.web_links,
+          this.config
+        );
+        if (weblink?.url) {
+          const revSha = rev.commit?.commit;
+          if (revSha && weblink.url.includes(revSha)) {
+            return weblink.url.replace(revSha, sha);
+          }
+        }
+      }
+    }
+    return undefined;
   }
 
   // private but used in tests
@@ -773,4 +830,11 @@
     if (!this.message) return;
     this.message = {...this.message, expanded: !this.message.expanded};
   }
+
+  private hasAiComments(): boolean {
+    if (!this.commentThreads || this.commentThreads.length === 0) return false;
+    return this.commentThreads.every(thread =>
+      thread.comments.every(comment => !!comment.is_ai)
+    );
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_screenshot_test.ts
new file mode 100644
index 0000000..2cd4d18
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_screenshot_test.ts
@@ -0,0 +1,112 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {visualDiffDarkTheme} from '../../../test/test-utils';
+import {GrMessage} from './gr-message';
+import './gr-message';
+import {ChangeMessage, CommentThread, Timestamp} from '../../../types/common';
+import {
+  createAccountDetailWithId,
+  createChangeMessage,
+  createCommentThread,
+} from '../../../test/test-data-generators';
+
+const author = createAccountDetailWithId(1);
+
+const msg: ChangeMessage = {
+  ...createChangeMessage(),
+  author,
+  message: 'Review comments published',
+  date: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const msg_with_scores: ChangeMessage = {
+  ...createChangeMessage(),
+  author,
+  message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1',
+  date: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const labelExtremes = {
+  Verified: {max: 1, min: -1},
+  'Code-Review': {max: 2, min: -2},
+  'Trybot-Label3': {max: 3, min: 0},
+};
+
+const thread_normal: CommentThread = createCommentThread([
+  {
+    author,
+    message: 'This is a normal comment',
+    updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+  },
+]);
+
+const thread_ai: CommentThread = createCommentThread([
+  {
+    author,
+    message: 'This is an AI comment',
+    updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+    is_ai: true,
+  },
+]);
+
+suite('gr-message screenshot tests', () => {
+  let element: GrMessage;
+
+  setup(async () => {
+    element = await fixture(html`<gr-message></gr-message>`);
+    element.message = msg;
+  });
+
+  test('collapsed normal', async () => {
+    element.commentThreads = [thread_normal];
+    element.message = {...msg, expanded: false};
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-message-collapsed-normal');
+    await visualDiffDarkTheme(element, 'gr-message-collapsed-normal');
+  });
+
+  test('collapsed AI', async () => {
+    element.commentThreads = [thread_ai];
+    element.message = {...msg, expanded: false};
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-message-collapsed-ai');
+    await visualDiffDarkTheme(element, 'gr-message-collapsed-ai');
+  });
+
+  test('expanded AI', async () => {
+    element.commentThreads = [thread_ai];
+    element.message = {...msg, expanded: true};
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-message-expanded-ai');
+    await visualDiffDarkTheme(element, 'gr-message-expanded-ai');
+  });
+
+  test('collapsed with scores', async () => {
+    element.message = {...msg_with_scores, expanded: false};
+    element.labelExtremes = labelExtremes;
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-message-collapsed-scores');
+    await visualDiffDarkTheme(element, 'gr-message-collapsed-scores');
+  });
+
+  test('expanded with scores', async () => {
+    element.message = {...msg_with_scores, expanded: true};
+    element.labelExtremes = labelExtremes;
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-message-expanded-scores');
+    await visualDiffDarkTheme(element, 'gr-message-expanded-scores');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 23d2867..6fa6587 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -34,6 +34,7 @@
   ReviewInputTag,
   RevisionPatchSetNum,
   SavingState,
+  ServerInfo,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
@@ -685,6 +686,86 @@
         );
         assert.equal(actual, expected);
       });
+
+      suite('submitted commit links', () => {
+        const sha = '0123456789abcdef0123456789abcdef01234567';
+        const message = `Change has been successfully rebased and submitted as ${sha}`;
+
+        test('uses configured URL placeholder', () => {
+          element.config = {
+            gerrit: {
+              submit_commit_url: 'https://example.com/commit/${commit}',
+            },
+          } as ServerInfo;
+
+          assert.equal(
+            element.computeMessageContent(true, message),
+            `Change has been successfully rebased and submitted as [${sha}](https://example.com/commit/${sha})`
+          );
+        });
+
+        test('links cherry-picked commit', () => {
+          element.config = {
+            gerrit: {
+              submit_commit_url: 'https://example.com/commit/${commit}',
+            },
+          } as ServerInfo;
+          const cherryPickedMessage = `Change has been successfully cherry-picked as ${sha}`;
+
+          assert.equal(
+            element.computeMessageContent(true, cherryPickedMessage),
+            `Change has been successfully cherry-picked as [${sha}](https://example.com/commit/${sha})`
+          );
+        });
+
+        test('links SHA-256 commit', () => {
+          element.config = {
+            gerrit: {
+              submit_commit_url: 'https://example.com/commit/${commit}',
+            },
+          } as ServerInfo;
+          const sha256 = `${sha}0123456789abcdef01234567`;
+          const sha256Message = `Change has been successfully rebased and submitted as ${sha256}`;
+
+          assert.equal(
+            element.computeMessageContent(true, sha256Message),
+            `Change has been successfully rebased and submitted as [${sha256}](https://example.com/commit/${sha256})`
+          );
+        });
+
+        test('appends commit to configured URL without trailing slash', () => {
+          element.config = {
+            gerrit: {submit_commit_url: 'https://example.com/commit'},
+          } as ServerInfo;
+
+          assert.equal(
+            element.computeMessageContent(true, message),
+            `Change has been successfully rebased and submitted as [${sha}](https://example.com/commit/${sha})`
+          );
+        });
+
+        test('rejects configured non-HTTP URL', () => {
+          element.config = {
+            gerrit: {submit_commit_url: 'javascript:${commit}'},
+          } as ServerInfo;
+
+          assert.equal(element.computeMessageContent(true, message), message);
+        });
+
+        test('only links hashes in submitted commit messages', () => {
+          element.config = {
+            gerrit: {
+              submit_commit_url: 'https://example.com/commit/${commit}',
+            },
+          } as ServerInfo;
+
+          const unrelatedMessage = `Tree ID: ${sha}`;
+          assert.equal(
+            element.computeMessageContent(true, unrelatedMessage),
+            unrelatedMessage
+          );
+        });
+      });
     });
   });
 
@@ -793,4 +874,75 @@
       );
     });
   });
+
+  suite('hasAiComments', () => {
+    setup(async () => {
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
+      element.message = createChangeMessage();
+      await element.updateComplete;
+    });
+
+    test('is false when there are no comment threads', async () => {
+      element.commentThreads = [];
+      await element.updateComplete;
+
+      const accountLabel = queryAndAssert<HTMLElement>(element, '.authorLabel');
+      assert.isFalse(accountLabel.hasAttribute('is-ai'));
+    });
+
+    test('is false when all comments are human', async () => {
+      element.commentThreads = [
+        createCommentThread([
+          createComment({message: 'hello 1'}),
+          createComment({message: 'hello 2'}),
+        ]),
+      ];
+      await element.updateComplete;
+
+      const accountLabel = queryAndAssert<HTMLElement>(element, '.authorLabel');
+      assert.isFalse(accountLabel.hasAttribute('is-ai'));
+    });
+
+    test('is false when some comments are AI but some are human', async () => {
+      element.commentThreads = [
+        createCommentThread([
+          createComment({
+            id: '111' as UrlEncodedCommentId,
+            message: 'hello 1',
+            is_ai: true,
+          }),
+          createComment({
+            in_reply_to: '111' as UrlEncodedCommentId,
+            message: 'hello 2',
+          }),
+        ]),
+      ];
+      await element.updateComplete;
+
+      const accountLabel = queryAndAssert<HTMLElement>(element, '.authorLabel');
+      assert.isFalse(accountLabel.hasAttribute('is-ai'));
+    });
+
+    test('is true when all comments in all threads are AI', async () => {
+      element.commentThreads = [
+        createCommentThread([
+          createComment({
+            id: '111' as UrlEncodedCommentId,
+            message: 'hello 1',
+            is_ai: true,
+          }),
+          createComment({
+            in_reply_to: '111' as UrlEncodedCommentId,
+            message: 'hello 2',
+            is_ai: true,
+          }),
+        ]),
+        createCommentThread([createComment({message: 'hello 3', is_ai: true})]),
+      ];
+      await element.updateComplete;
+
+      const accountLabel = queryAndAssert<HTMLElement>(element, '.authorLabel');
+      assert.isTrue(accountLabel.hasAttribute('is-ai'));
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 03c79a8..65fc242 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -22,6 +22,7 @@
   VotingRangeInfo,
 } from '../../../types/common';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
+import {isServiceUser} from '../../../utils/account-util';
 import {getVotingRange} from '../../../utils/label-util';
 import {
   FormattedReviewerUpdateInfo,
@@ -225,10 +226,28 @@
  * Autogenerated messages are unimportant, if there is a message with the same
  * tag and a higher revision number.
  */
+function isReviewerUpdateMessage(message: CombinedMessage): boolean {
+  return (
+    (message as FormattedReviewerUpdateInfo).type === 'REVIEWER_UPDATE' ||
+    message.tag === MessageTag.TAG_REVIEWER_UPDATE
+  );
+}
+
 function computeIsImportant(
   message: CombinedMessage,
   allMessages: CombinedMessage[]
 ) {
+  const author = message.author;
+  const realAuthor =
+    (message as ChangeMessageInfo).real_author ??
+    (message as FormattedReviewerUpdateInfo).realAuthor;
+  if (
+    (isServiceUser(author) || isServiceUser(realAuthor)) &&
+    isReviewerUpdateMessage(message)
+  ) {
+    return false;
+  }
+
   if (!message.tag) return true;
 
   const hasSameTag = (m: CombinedMessage) => m.tag === message.tag;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index 66a1951..8ec1bde 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -7,7 +7,7 @@
 import '../../../test/common-test-setup';
 import './gr-messages-list';
 import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
-import {MessageTag} from '../../../constants/constants';
+import {AccountTag, MessageTag} from '../../../constants/constants';
 import {
   query,
   queryAll,
@@ -59,7 +59,7 @@
   };
 };
 
-const randomMessage = function (params?: ChangeMessageInfo) {
+const randomMessage = function (params?: Partial<ChangeMessageInfo>) {
   params = params || ({} as ChangeMessageInfo);
   const author1 = {
     _account_id: 1115495 as AccountId,
@@ -488,6 +488,68 @@
       assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
     });
 
+    test('isImportant service user reviewer update vs other messages', () => {
+      const reviewerUpdateFromBot = {
+        ...randomMessage(),
+        author: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        type: 'REVIEWER_UPDATE' as const,
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+      };
+      const reviewerUpdateWithRealAuthorBot = {
+        ...randomMessage(),
+        author: {
+          _account_id: 456 as AccountId,
+        },
+        real_author: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        type: 'REVIEWER_UPDATE' as const,
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+      };
+      const formattedReviewerUpdateWithRealAuthorBot = {
+        author: {
+          _account_id: 456 as AccountId,
+        },
+        realAuthor: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        date: '2020-01-01 00:00:00.000000000' as Timestamp,
+        type: 'REVIEWER_UPDATE' as const,
+        tag: MessageTag.TAG_REVIEWER_UPDATE as const,
+        updates: [],
+      };
+      const commentFromBot = randomMessage({
+        author: {
+          _account_id: 123 as AccountId,
+          tags: [AccountTag.SERVICE_USER],
+        },
+        message: 'Build succeeded: 10 tests passed',
+      });
+      assert.isFalse(
+        TEST_ONLY.computeIsImportant(reviewerUpdateFromBot, [
+          reviewerUpdateFromBot,
+        ])
+      );
+      assert.isFalse(
+        TEST_ONLY.computeIsImportant(reviewerUpdateWithRealAuthorBot, [
+          reviewerUpdateWithRealAuthorBot,
+        ])
+      );
+      assert.isFalse(
+        TEST_ONLY.computeIsImportant(formattedReviewerUpdateWithRealAuthorBot, [
+          formattedReviewerUpdateWithRealAuthorBot,
+        ])
+      );
+      assert.isTrue(
+        TEST_ONLY.computeIsImportant(commentFromBot, [commentFromBot])
+      );
+    });
+
     test('isImportant is evaluated after tag update', async () => {
       const m1 = randomMessage({
         ...randomMessage(),
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 19348be..2a8adb3 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -5,18 +5,24 @@
  */
 import './gr-related-change';
 import './gr-related-collapse';
+import './gr-stack-diff-dialog';
+import {GrStackDiffDialog} from './gr-stack-diff-dialog';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
 import '../../shared/gr-icon/gr-icon';
 import {classMap} from 'lit/directives/class-map.js';
 import {css, html, LitElement, TemplateResult} from 'lit';
-import {customElement, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   ChangeInfo,
   CommitId,
   PatchSetNumber,
+  PreferencesInfo,
   RelatedChangeAndCommitInfo,
   RevisionPatchSetNum,
   SubmittedTogetherInfo,
@@ -29,7 +35,12 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
-import {changeModelToken} from '../../../models/change/change-model';
+import {
+  changeModelToken,
+  urlBaseForCommit,
+} from '../../../models/change/change-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {createDefaultPreferences} from '../../../constants/constants';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 export interface ChangeMarkersInList {
@@ -49,6 +60,11 @@
 
 @customElement('gr-related-changes-list')
 export class GrRelatedChangesList extends LitElement {
+  @query('#stackDiffDialog')
+  private stackDiffDialog?: GrStackDiffDialog;
+
+  private readonly flagsService = getAppContext().flagsService;
+
   @state()
   change?: ParsedChangeInfo;
 
@@ -73,6 +89,9 @@
   @state()
   sameTopicChanges: ChangeInfo[] = [];
 
+  @state()
+  preferences?: PreferencesInfo;
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getRelatedChangesModel = resolve(
@@ -80,6 +99,8 @@
     relatedChangesModelToken
   );
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -117,6 +138,11 @@
       () => this.getRelatedChangesModel().sameTopicChanges$,
       x => (this.sameTopicChanges = x ?? [])
     );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      x => (this.preferences = x)
+    );
   }
 
   static override get styles() {
@@ -252,9 +278,25 @@
       <gr-endpoint-slot name="top"></gr-endpoint-slot>
       ${sections}
       <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+      ${when(
+        this.flagsService.isEnabled(KnownExperimentId.STACK_DIFF),
+        () => html`
+          <gr-stack-diff-dialog
+            id="stackDiffDialog"
+            .repo=${this.change?.project}
+            .relatedChanges=${this.relatedChanges}
+          ></gr-stack-diff-dialog>
+        `
+      )}
     </gr-endpoint-decorator>`;
   }
 
+  protected openStackDiff(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.stackDiffDialog?.open();
+  }
+
   private renderRelationChain(
     isFirst: boolean,
     sectionSize: (section: Section) => number
@@ -283,6 +325,19 @@
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
       >
+        ${when(
+          this.flagsService.isEnabled(KnownExperimentId.STACK_DIFF),
+          () => html`
+            <gr-button
+              id="openStackDiffButton"
+              slot="header-action"
+              link
+              @click=${this.openStackDiff}
+            >
+              Diff
+            </gr-button>
+          `
+        )}
         ${this.relatedChanges.map(
           (change, index) =>
             html`<div
@@ -307,6 +362,7 @@
                       repo: change.project,
                       usp: 'related-change',
                       patchNum: change._revision_number as RevisionPatchSetNum,
+                      basePatchNum: this.computeRelatedChangeBase(change),
                     })
                   : ''}
                 show-change-status
@@ -676,6 +732,21 @@
     return aNum === bNum;
   }
 
+  /**
+   * The base for the link of a change in the relation chain: merge commits are
+   * linked with the base that the `default_base_for_merges` preference picks,
+   * spelled out in the URL, so that the link keeps pointing at the same diff
+   * for whoever it is shared with.
+   */
+  // private but used in tests
+  computeRelatedChangeBase(change: RelatedChangeAndCommitInfo) {
+    const isMergeCommit = (change.commit.parents?.length ?? 0) > 1;
+    return urlBaseForCommit(
+      isMergeCommit,
+      this.preferences ?? createDefaultPreferences()
+    );
+  }
+
   /*
    * A list of commit ids connected to change to understand if other change
    * is direct or indirect ancestor / descendant.
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 8e799cf..e0263be 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -29,6 +29,11 @@
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 import {getChangeNumber} from '../../../utils/change-util';
+import {
+  createDefaultPreferences,
+  DefaultBase,
+} from '../../../constants/constants';
+import {userModelToken} from '../../../models/user/user-model';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import './gr-related-changes-list';
@@ -37,6 +42,7 @@
   GrRelatedChangesList,
   Section,
 } from './gr-related-changes-list';
+import {GrRelatedChange} from './gr-related-change';
 import {GrRelatedCollapse} from './gr-related-collapse';
 
 suite('gr-related-changes-list', () => {
@@ -601,4 +607,67 @@
       assert.strictEqual(hookEl!.change, element.change);
     });
   });
+
+  suite('relation chain base', () => {
+    function relatedChange(numParents: number): RelatedChangeAndCommitInfo {
+      return {
+        ...createRelatedChangeAndCommitInfo(),
+        _change_number: 123 as NumericChangeId,
+        _revision_number: 2,
+        commit: {
+          ...createCommitInfoWithRequiredCommit(),
+          parents: Array.from({length: numParents}, (_, i) => {
+            return {
+              commit: `parent${i}` as CommitId,
+              subject: 'parent',
+            };
+          }),
+        },
+      };
+    }
+
+    async function href(change: RelatedChangeAndCommitInfo) {
+      element.change = createParsedChange();
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.relatedChanges = [change];
+      await element.updateComplete;
+      return queryAndAssert<GrRelatedChange>(
+        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
+        'gr-related-change'
+      ).href;
+    }
+
+    test('single parent commit has no base in the URL', async () => {
+      testResolver(userModelToken).setPreferences({
+        ...createDefaultPreferences(),
+        default_base_for_merges: DefaultBase.FIRST_PARENT,
+      });
+      assert.equal(
+        await href(relatedChange(1)),
+        '/c/test-project/+/123/2?usp=related-change'
+      );
+    });
+
+    test('merge commit is linked with the auto-merge base', async () => {
+      testResolver(userModelToken).setPreferences({
+        ...createDefaultPreferences(),
+        default_base_for_merges: DefaultBase.AUTO_MERGE,
+      });
+      assert.equal(
+        await href(relatedChange(2)),
+        '/c/test-project/+/123/0..2?usp=related-change'
+      );
+    });
+
+    test('merge commit is linked with the first parent base', async () => {
+      testResolver(userModelToken).setPreferences({
+        ...createDefaultPreferences(),
+        default_base_for_merges: DefaultBase.FIRST_PARENT,
+      });
+      assert.equal(
+        await href(relatedChange(2)),
+        '/c/test-project/+/123/-1..2?usp=related-change'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
index 727353d..6202ba4 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
@@ -39,10 +39,14 @@
       sharedStyles,
       fontStyles,
       css`
+        .title-container {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-s);
+        }
         .title {
           color: var(--deemphasized-text-color);
           display: flex;
-          align-self: flex-end;
         }
         gr-button {
           display: flex;
@@ -54,6 +58,7 @@
         .container {
           justify-content: space-between;
           display: flex;
+          align-items: center;
           margin-bottom: var(--spacing-s);
         }
         :host(.first) .container {
@@ -80,7 +85,13 @@
       ></gr-button>`;
     }
 
-    return html`<div class="container">${title}${button}</div>
+    return html`<div class="container">
+        <div class="title-container">
+          ${title}
+          <slot name="header-action"></slot>
+        </div>
+        ${button}
+      </div>
       <div><slot></slot></div>`;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse_test.ts
index 0e9e5c6..84b1334 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse_test.ts
@@ -24,7 +24,10 @@
       element,
       /* HTML */ `
         <div class="container">
-          <h3 class="heading-3 title">Related Changes</h3>
+          <div class="title-container">
+            <h3 class="heading-3 title">Related Changes</h3>
+            <slot name="header-action"> </slot>
+          </div>
         </div>
         <div>
           <slot> </slot>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-stack-diff-dialog.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-stack-diff-dialog.ts
new file mode 100644
index 0000000..e79549d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-stack-diff-dialog.ts
@@ -0,0 +1,543 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing, TemplateResult} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {getAppContext} from '../../../services/app-context';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {
+  CommitId,
+  FileNameToFileInfoMap,
+  RelatedChangeAndCommitInfo,
+  RepoName,
+} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import '../../shared/gr-button/gr-button';
+import '@material/web/select/outlined-select';
+import '@material/web/select/select-option';
+import '../../../embed/diff/gr-diff/gr-diff';
+import {computeTruncatedPath} from '../../../utils/path-list-util';
+
+@customElement('gr-stack-diff-dialog')
+export class GrStackDiffDialog extends LitElement {
+  @query('#dialog')
+  private dialog?: HTMLDialogElement;
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: Array})
+  relatedChanges: RelatedChangeAndCommitInfo[] = [];
+
+  @state()
+  // private but used in tests
+  baseCommitId?: CommitId;
+
+  @state()
+  // private but used in tests
+  targetCommitId?: CommitId;
+
+  @state()
+  // private but used in tests
+  files: FileNameToFileInfoMap = {};
+
+  @state()
+  // private but used in tests
+  selectedFile?: string;
+
+  @state()
+  protected fileDiff?: DiffInfo;
+
+  @state()
+  protected loading = false;
+
+  @state()
+  protected loadingDiff = false;
+
+  @state()
+  protected error?: string;
+
+  @state()
+  protected diffPrefs?: DiffPreferencesInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      prefs => {
+        this.diffPrefs = prefs;
+        this.syntaxLayer.setEnabled(!!prefs?.syntax_highlighting);
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        dialog {
+          width: 95vw;
+          max-width: 1400px;
+          height: 90vh;
+          max-height: 900px;
+        }
+        .dialog-container {
+          display: flex;
+          flex-direction: column;
+          height: 100%;
+        }
+        header {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-m);
+          padding: var(--spacing-l) var(--spacing-xl);
+          border-bottom: 1px solid var(--border-color);
+          background-color: var(--dialog-background-color);
+        }
+        h3 {
+          margin: 0;
+        }
+        .experimental-tag {
+          font-size: var(--font-size-small);
+          background-color: var(--chip-background-color, #e0e0e0);
+          color: var(--primary-text-color);
+          padding: var(--spacing-xxs) var(--spacing-s);
+          border-radius: var(--border-radius);
+          font-weight: var(--font-weight-medium);
+        }
+        .pickers {
+          display: flex;
+          gap: var(--spacing-l);
+          margin-left: auto;
+          align-items: center;
+        }
+        .picker-container {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-s);
+        }
+        md-outlined-select {
+          min-width: 250px;
+        }
+        main {
+          display: flex;
+          flex: 1;
+          min-height: 0;
+          background-color: var(--background-color-secondary);
+        }
+        .file-list-pane {
+          width: 350px;
+          overflow-y: auto;
+          border-right: 1px solid var(--border-color);
+          background-color: var(--background-color-primary);
+        }
+        .file-row {
+          display: flex;
+          align-items: center;
+          padding: var(--spacing-m) var(--spacing-l);
+          cursor: pointer;
+          border-bottom: 1px solid var(--border-color);
+          gap: var(--spacing-m);
+        }
+        .file-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        .file-row.selected {
+          background-color: var(--selection-background-color);
+        }
+        .status {
+          min-width: 1.5em;
+          text-align: center;
+          font-weight: var(--font-weight-bold);
+          font-size: var(--font-size-small);
+          border-radius: 2px;
+          padding: 2px 4px;
+        }
+        .status.A {
+          background-color: var(--light-green-background, #e8f5e9);
+          color: var(--positive-green-text-color);
+        }
+        .status.D {
+          background-color: var(--light-red-background, #ffebee);
+          color: var(--negative-red-text-color);
+        }
+        .status.M {
+          background-color: var(--chip-background-color, #eee);
+          color: var(--deemphasized-text-color);
+        }
+        .path {
+          flex: 1;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          font-family: var(--monospace-font-family);
+        }
+        .lines-changed {
+          display: flex;
+          gap: var(--spacing-s);
+          font-size: var(--font-size-small);
+        }
+        .added {
+          color: var(--positive-green-text-color);
+        }
+        .removed {
+          color: var(--negative-red-text-color);
+        }
+        .diff-pane {
+          flex: 1;
+          overflow-y: auto;
+          display: flex;
+          flex-direction: column;
+          background-color: var(--background-color-primary);
+        }
+        .empty-diff-message {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          height: 100%;
+          color: var(--deemphasized-text-color);
+        }
+        .spinner-container {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          height: 100%;
+          flex-direction: column;
+          gap: var(--spacing-m);
+        }
+        .loadingSpin {
+          width: 28px;
+          height: 28px;
+          border: 3px solid var(--border-color);
+          border-top: 3px solid var(--link-color);
+          border-radius: 50%;
+          animation: spin 1s linear infinite;
+        }
+        @keyframes spin {
+          0% {
+            transform: rotate(0deg);
+          }
+          100% {
+            transform: rotate(360deg);
+          }
+        }
+        .error-message {
+          color: var(--error-text-color);
+          padding: var(--spacing-xl);
+          text-align: center;
+        }
+        footer {
+          display: flex;
+          justify-content: flex-end;
+          padding: var(--spacing-l) var(--spacing-xl);
+          border-top: 1px solid var(--border-color);
+          background-color: var(--dialog-background-color);
+        }
+      `,
+    ];
+  }
+
+  async open() {
+    if (!this.dialog) {
+      return;
+    }
+    this.dialog.showModal();
+    this.error = undefined;
+    this.selectedFile = undefined;
+    this.fileDiff = undefined;
+    this.files = {};
+
+    this.initializeCommitIds();
+    await this.loadDiffFileList();
+  }
+
+  close() {
+    if (!this.dialog) {
+      return;
+    }
+    this.dialog.close();
+  }
+
+  private initializeCommitIds() {
+    if (this.relatedChanges.length === 0) {
+      return;
+    }
+    const oldestChange = this.relatedChanges[this.relatedChanges.length - 1];
+    if (oldestChange.commit.parents && oldestChange.commit.parents.length > 0) {
+      this.baseCommitId = oldestChange.commit.parents[0].commit;
+    }
+    this.targetCommitId = this.relatedChanges[0].commit.commit;
+  }
+
+  private async loadDiffFileList() {
+    const repo = this.repo;
+    if (!repo || !this.baseCommitId || !this.targetCommitId) {
+      return;
+    }
+    this.loading = true;
+    this.error = undefined;
+    try {
+      const files = await this.restApiService.getProjectCommitDiff(
+        repo,
+        this.targetCommitId,
+        this.baseCommitId
+      );
+      const rest = {...(files ?? {})};
+      delete rest['/COMMIT_MSG'];
+      this.files = rest;
+
+      const filePaths = Object.keys(this.files);
+      if (filePaths.length > 0) {
+        await this.selectFile(filePaths[0]);
+      } else {
+        this.selectedFile = undefined;
+        this.fileDiff = undefined;
+      }
+    } catch (e) {
+      this.error =
+        'Failed to load project commit diff: ' + (e as Error).message;
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  private async selectFile(path: string) {
+    this.selectedFile = path;
+    if (!this.repo || !this.baseCommitId || !this.targetCommitId) {
+      return;
+    }
+    this.loadingDiff = true;
+    try {
+      const diff = await this.restApiService.getProjectCommitFileDiff(
+        this.repo,
+        this.targetCommitId,
+        this.baseCommitId,
+        path
+      );
+      if (diff) {
+        this.syntaxLayer.process(diff);
+        this.fileDiff = diff;
+      }
+    } catch (e) {
+      this.error = 'Failed to load file diff: ' + (e as Error).message;
+    } finally {
+      this.loadingDiff = false;
+    }
+  }
+
+  // private but used in template
+  handleBaseChange(e: Event) {
+    const select = e.target as HTMLSelectElement;
+    this.baseCommitId = ((select.value || select.getAttribute('value')) ??
+      '') as CommitId;
+    this.loadDiffFileList();
+  }
+
+  // private but used in template
+  handleTargetChange(e: Event) {
+    const select = e.target as HTMLSelectElement;
+    this.targetCommitId = ((select.value || select.getAttribute('value')) ??
+      '') as CommitId;
+    this.loadDiffFileList();
+  }
+
+  override render() {
+    return html`
+      <dialog id="dialog" tabindex="-1">
+        <div
+          class="dialog-container"
+          role="dialog"
+          aria-labelledby="dialogTitle"
+        >
+          <header>
+            <h3 id="dialogTitle" class="heading-3">Stack diff</h3>
+            <span class="experimental-tag">Experimental</span>
+            <div class="pickers">
+              <div class="picker-container">
+                <label for="baseSelect">Base:</label>
+                <md-outlined-select
+                  id="baseSelect"
+                  .value=${this.baseCommitId}
+                  @change=${this.handleBaseChange}
+                >
+                  ${this.renderBaseOptions()}
+                </md-outlined-select>
+              </div>
+              <div class="picker-container">
+                <label for="targetSelect">Target:</label>
+                <md-outlined-select
+                  id="targetSelect"
+                  .value=${this.targetCommitId}
+                  @change=${this.handleTargetChange}
+                >
+                  ${this.renderTargetOptions()}
+                </md-outlined-select>
+              </div>
+            </div>
+          </header>
+          <main>${this.renderMainContent()}</main>
+          <footer>
+            <gr-button id="closeButton" link @click=${this.close}
+              >Close</gr-button
+            >
+          </footer>
+        </div>
+      </dialog>
+    `;
+  }
+
+  // private but used in template
+  renderBaseOptions() {
+    const options: TemplateResult[] = [];
+    if (this.relatedChanges.length > 0) {
+      const oldestChange = this.relatedChanges[this.relatedChanges.length - 1];
+      if (
+        oldestChange.commit.parents &&
+        oldestChange.commit.parents.length > 0
+      ) {
+        const parentSha = oldestChange.commit.parents[0].commit;
+        options.push(html`
+          <md-select-option .value=${parentSha}>
+            <div slot="headline">
+              Base Parent (${parentSha.substring(0, 7)})
+            </div>
+          </md-select-option>
+        `);
+      }
+    }
+
+    for (const change of this.relatedChanges) {
+      const sha = change.commit.commit;
+      const subject = change.commit.subject;
+      options.push(html`
+        <md-select-option .value=${sha}>
+          <div slot="headline">[${sha.substring(0, 7)}] ${subject}</div>
+        </md-select-option>
+      `);
+    }
+    return options;
+  }
+
+  // private but used in template
+  renderTargetOptions() {
+    return this.relatedChanges.map(change => {
+      const sha = change.commit.commit;
+      const subject = change.commit.subject;
+      return html`
+        <md-select-option .value=${sha}>
+          <div slot="headline">[${sha.substring(0, 7)}] ${subject}</div>
+        </md-select-option>
+      `;
+    });
+  }
+
+  // private but used in template
+  renderMainContent() {
+    if (this.loading) {
+      return html`
+        <div class="spinner-container">
+          <div
+            class="loadingSpin"
+            role="progressbar"
+            aria-label="Loading diff..."
+          ></div>
+          <div>Loading diff...</div>
+        </div>
+      `;
+    }
+    if (this.error) {
+      return html` <div class="error-message">${this.error}</div> `;
+    }
+
+    const filePaths = Object.keys(this.files);
+    return html`
+      <div class="file-list-pane">
+        ${filePaths.map(path => {
+          const fileInfo = this.files[path];
+          return html`
+            <div
+              class="file-row ${this.selectedFile === path ? 'selected' : ''}"
+              @click=${() => this.selectFile(path)}
+            >
+              <span class="status ${fileInfo.status || 'M'}"
+                >${fileInfo.status || 'M'}</span
+              >
+              <span class="path" title=${path}
+                >${computeTruncatedPath(path)}</span
+              >
+              <span class="lines-changed">
+                ${fileInfo.lines_inserted
+                  ? html`<span class="added">+${fileInfo.lines_inserted}</span>`
+                  : nothing}
+                ${fileInfo.lines_deleted
+                  ? html`<span class="removed"
+                      >-${fileInfo.lines_deleted}</span
+                    >`
+                  : nothing}
+              </span>
+            </div>
+          `;
+        })}
+        ${filePaths.length === 0
+          ? html`<div class="empty-diff-message">No files changed</div>`
+          : nothing}
+      </div>
+      <div class="diff-pane">
+        ${this.loadingDiff
+          ? html`
+              <div class="spinner-container">
+                <div
+                  class="loadingSpin"
+                  role="progressbar"
+                  aria-label="Loading file diff..."
+                ></div>
+              </div>
+            `
+          : this.renderDiffView()}
+      </div>
+    `;
+  }
+
+  // private but used in template
+  renderDiffView() {
+    if (!this.selectedFile || !this.fileDiff) {
+      return html`
+        <div class="empty-diff-message">Select a file to see diff</div>
+      `;
+    }
+
+    return html`
+      <gr-diff
+        .prefs=${this.diffPrefs}
+        .path=${this.selectedFile}
+        .diff=${this.fileDiff}
+        .layers=${[this.syntaxLayer]}
+      ></gr-diff>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-stack-diff-dialog': GrStackDiffDialog;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-stack-diff-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-stack-diff-dialog_test.ts
new file mode 100644
index 0000000..d965ba3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-stack-diff-dialog_test.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-stack-diff-dialog';
+import {assert, fixture, html} from '@open-wc/testing';
+import {GrStackDiffDialog} from './gr-stack-diff-dialog';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {CommitId, RepoName} from '../../../types/common';
+import {FileInfoStatus} from '../../../api/rest-api';
+import {DiffInfo} from '../../../types/diff';
+import {
+  createCommitInfoWithRequiredCommit,
+  createRelatedChangeAndCommitInfo,
+} from '../../../test/test-data-generators';
+import * as sinon from 'sinon';
+
+suite('gr-stack-diff-dialog tests', () => {
+  let element: GrStackDiffDialog;
+  let getProjectCommitDiffStub: sinon.SinonStub;
+
+  setup(async () => {
+    getProjectCommitDiffStub = stubRestApi('getProjectCommitDiff').resolves({
+      'foo/bar.ts': {
+        status: FileInfoStatus.ADDED,
+        lines_inserted: 10,
+        lines_deleted: 0,
+        size_delta: 100,
+        size: 100,
+      },
+    });
+
+    stubRestApi('getProjectCommitFileDiff').resolves({
+      meta_a: {
+        name: 'foo/bar.ts',
+        content_type: 'text/typescript',
+        lines: 10,
+      },
+      meta_b: {
+        name: 'foo/bar.ts',
+        content_type: 'text/typescript',
+        lines: 12,
+      },
+      content: [],
+    } as unknown as DiffInfo);
+
+    element = await fixture<GrStackDiffDialog>(
+      html`<gr-stack-diff-dialog></gr-stack-diff-dialog>`
+    );
+    element.repo = 'test-repo' as RepoName;
+    element.relatedChanges = [
+      {
+        ...createRelatedChangeAndCommitInfo(),
+        commit: {
+          ...createCommitInfoWithRequiredCommit(
+            'abcdefabcdef1234567890abcdefabcdef123456' as CommitId
+          ),
+          subject: 'Related change 1',
+          parents: [
+            {
+              commit: 'parentcommit1234567890abcdefabcdef12345' as CommitId,
+              subject: 'Parent subject',
+            },
+          ],
+        },
+      },
+    ];
+    await element.updateComplete;
+  });
+
+  test('open initializes baseCommitId and targetCommitId', async () => {
+    await element.open();
+    await element.updateComplete;
+
+    assert.equal(
+      element.baseCommitId,
+      'parentcommit1234567890abcdefabcdef12345'
+    );
+    assert.equal(
+      element.targetCommitId,
+      'abcdefabcdef1234567890abcdefabcdef123456'
+    );
+    assert.deepEqual(Object.keys(element.files), ['foo/bar.ts']);
+    assert.equal(element.selectedFile, 'foo/bar.ts');
+  });
+
+  test('changing base or target commit reloads file list', async () => {
+    await element.open();
+    await element.updateComplete;
+    getProjectCommitDiffStub.resetHistory();
+
+    const baseSelect = queryAndAssert<HTMLElement & {value: string}>(
+      element,
+      '#baseSelect'
+    );
+    baseSelect.value = 'parent-sha';
+    baseSelect.dispatchEvent(new CustomEvent('change'));
+    await element.updateComplete;
+
+    assert.isTrue(getProjectCommitDiffStub.calledOnce);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index a10e118..cdcd85d 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -202,7 +202,7 @@
   @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @state()
+  @property({type: Array})
   draftCommentThreads: CommentThread[] = [];
 
   @property({type: Object})
@@ -229,7 +229,7 @@
 
   @state() private docsBaseUrl = '';
 
-  @state()
+  @property({type: String})
   patchsetLevelDraftMessage = '';
 
   @state()
@@ -290,13 +290,13 @@
   @state()
   mentionedUsers: AccountInput[] = [];
 
-  @state()
+  @property({type: Array})
   mentionedUsersInUnresolvedDrafts: AccountInfo[] = [];
 
   @state()
   attentionCcsCount = 0;
 
-  @state()
+  @property({type: Object})
   ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @state()
@@ -308,15 +308,15 @@
   @state()
   pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null;
 
-  @state()
+  @property({type: Boolean})
   includeComments = true;
 
   @state()
   autosubmitChecked = false;
 
-  @state() reviewers: AccountInput[] = [];
+  @property({type: Array}) reviewers: AccountInput[] = [];
 
-  @state()
+  @property({type: Object})
   reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @state()
@@ -330,7 +330,7 @@
    * not yet voted on a label) if a selected vote is different from the default
    * vote.
    */
-  @state()
+  @property({type: Boolean})
   labelsChanged = false;
 
   @state()
@@ -882,6 +882,7 @@
           )}
         >
         </gr-account-list>
+        <gr-endpoint-slot name="middle"></gr-endpoint-slot>
         <gr-endpoint-slot name="right"></gr-endpoint-slot>
       </div>
     `;
@@ -1517,7 +1518,9 @@
 
   // visible for testing
   async send(includeComments: boolean, startReview: boolean) {
-    const labels = this.getLabelScores().getLabelValues();
+    const includeDefaults =
+      !this.change || this.change.status !== ChangeStatus.MERGED;
+    const labels = this.getLabelScores().getLabelValues(includeDefaults);
     if (labels[StandardLabels.CODE_REVIEW] === 2) {
       this.reporting.reportInteraction(Interaction.CODE_REVIEW_APPROVAL);
     }
@@ -1614,8 +1617,11 @@
     // timer will be ended.
     this.reporting.time(Timing.SEND_REPLY);
     this.getNavigation().blockNavigation('sending review');
+    const networkTimer = this.reporting.getTimer('SendReply - network');
     return this.saveReview(reviewInput, errFn)
       .then(result => {
+        networkTimer.end();
+
         // change-info is not set only if request resulted in error.
         if (!result?.change_info) {
           return;
@@ -1634,9 +1640,11 @@
           current_revision: this.change?.current_revision,
           current_revision_number: this.change?.current_revision_number,
         };
+        const parseTimer = this.reporting.getTimer('SendReply - parse');
         this.getChangeModel().updateStateChange(
           GrReviewerUpdatesParser.parse(updatedChange as ChangeViewChangeInfo)
         );
+        parseTimer.end();
         if (reloadRequired) {
           fireReload(this);
         } else {
@@ -1649,7 +1657,11 @@
         this.includeComments = true;
         fireNoBubble(this, 'send', {});
         fireIronAnnounce(this, 'Reply sent');
+        const pluginAfterTimer = this.reporting.getTimer(
+          'SendReply - pluginAfter'
+        );
         this.getPluginLoader().jsApiService.handleReplySent();
+        pluginAfterTimer.end();
       })
       .finally(async () => {
         this.getNavigation().releaseNavigation('sending review');
@@ -2251,6 +2263,13 @@
     if (this.commentEditing || this.disabled) {
       return true;
     }
+    // If the user is creating an autosubmit flow then allow sending reply
+    if (
+      this.autosubmitChecked &&
+      !this.getFlowsModel().hasAutosubmitFlowAlready()
+    ) {
+      return false;
+    }
     if (this.canBeStarted === true) {
       return false;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index a8f89d5..dd168b3 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -231,6 +231,7 @@
               <div class="peopleList">
                 <div class="peopleListLabel">Reviewers</div>
                 <gr-account-list id="reviewers"> </gr-account-list>
+                <gr-endpoint-slot name="middle"> </gr-endpoint-slot>
                 <gr-endpoint-slot name="right"> </gr-endpoint-slot>
               </div>
               <gr-endpoint-slot name="below"> </gr-endpoint-slot>
@@ -373,6 +374,7 @@
           <div class="peopleList">
             <div class="peopleListLabel">Reviewers</div>
             <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="middle"> </gr-endpoint-slot>
             <gr-endpoint-slot name="right"> </gr-endpoint-slot>
           </div>
           <gr-endpoint-slot name="below"> </gr-endpoint-slot>
@@ -428,6 +430,7 @@
           <div class="peopleList">
             <div class="peopleListLabel">Reviewers</div>
             <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="middle"> </gr-endpoint-slot>
             <gr-endpoint-slot name="right"> </gr-endpoint-slot>
           </div>
           <gr-endpoint-slot name="below"> </gr-endpoint-slot>
@@ -2347,6 +2350,20 @@
     assert.isTrue(element.isSendDisabled());
   });
 
+  test('isSendDisabled_autosubmit', () => {
+    element.canBeStarted = false;
+    element.draftCommentThreads = [];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+    element.autosubmitChecked = true;
+    assert.isFalse(element.isSendDisabled());
+  });
+
   test('isSendDisabled_draftCommentsSend', () => {
     // Mock nonempty comment draft array; with sending comments.
     element.canBeStarted = false;
@@ -2523,6 +2540,28 @@
       assert.isTrue(element.isSendDisabled());
     });
 
+    test('send sets includeDefaults based on change status', async () => {
+      stubSaveReview(() => {});
+      const getLabelValuesStub = sinon
+        .stub(element.getLabelScores(), 'getLabelValues')
+        .returns({});
+
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+      };
+      await element.send(false, false);
+      assert.isTrue(getLabelValuesStub.calledWith(true));
+
+      getLabelValuesStub.resetHistory();
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.MERGED,
+      };
+      await element.send(false, false);
+      assert.isTrue(getLabelValuesStub.calledWith(false));
+    });
+
     test('sending patchset level comment', async () => {
       const patchsetLevelComment = queryAndAssert<GrComment>(
         element,
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index a39917d..9ed3848 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -109,7 +109,11 @@
         div.section {
           margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
           display: flex;
-          align-items: center;
+          align-items: flex-start;
+        }
+        .sectionContent {
+          flex: 1;
+          min-width: 0;
         }
         div.sectionIcon {
           flex: 0 0 30px;
@@ -120,7 +124,6 @@
         .section.condition > .sectionContent {
           background-color: var(--gray-background);
           padding: var(--spacing-m);
-          flex-grow: 1;
         }
         .button ~ .condition {
           margin-top: var(--spacing-m);
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
index d35855d..8678a8e 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
@@ -47,6 +47,10 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
+        .sectionContent {
+          flex: 1;
+          min-width: 0;
+        }
         div.sectionIcon gr-icon {
           position: relative;
           font-size: 20px;
@@ -66,7 +70,7 @@
       </div>
       <div class="section">
         <div class="sectionIcon">
-          <gr-icon icon="info" class="small"></gr-icon></span>
+          <gr-icon icon="info" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           <div class="row">
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-panel.ts b/polygerrit-ui/app/elements/chat-panel/chat-panel.ts
index 62a8918..eb9db6b 100644
--- a/polygerrit-ui/app/elements/chat-panel/chat-panel.ts
+++ b/polygerrit-ui/app/elements/chat-panel/chat-panel.ts
@@ -12,7 +12,13 @@
 import './user-message';
 
 import {css, html, LitElement} from 'lit';
-import {customElement, query, queryAll, state} from 'lit/decorators.js';
+import {
+  customElement,
+  property,
+  query,
+  queryAll,
+  state,
+} from 'lit/decorators.js';
 
 import {
   chatModelToken,
@@ -37,7 +43,7 @@
 
   @queryAll('gemini-message') private geminiMessages?: NodeListOf<HTMLElement>;
 
-  @state() turns: readonly Turn[] = [];
+  @property({type: Array}) turns: readonly Turn[] = [];
 
   @state() conversationId?: string;
 
diff --git a/polygerrit-ui/app/elements/chat-panel/gemini-message.ts b/polygerrit-ui/app/elements/chat-panel/gemini-message.ts
index 753c6d6..753da3a 100644
--- a/polygerrit-ui/app/elements/chat-panel/gemini-message.ts
+++ b/polygerrit-ui/app/elements/chat-panel/gemini-message.ts
@@ -56,7 +56,7 @@
    */
   @property({type: Boolean}) isBackgroundRequest = false;
 
-  @state() turns: readonly Turn[] = [];
+  @property({type: Array}) turns: readonly Turn[] = [];
 
   @state() fileEntities: {[path: string]: NormalizedFileInfo} = {};
 
@@ -132,6 +132,7 @@
         font-family: var(--monospace-font-family);
         font-size: var(--font-size-small);
         white-space: pre-wrap;
+        overflow-wrap: break-word;
         background-color: var(--background-color-tertiary);
         padding: var(--spacing-s);
         border-radius: var(--border-radius);
@@ -239,9 +240,9 @@
     if (draft.range && draft.range.end_line < draft.range.start_line) {
       draft.range.end_line = draft.range.start_line;
     }
-    await this.getCommentsModel().saveDraft(draft);
+    const savedDraft = await this.getCommentsModel().saveDraft(draft);
     this.getCommentsModel().reloadAllComments();
-    this.reportSuggestionToComment();
+    this.reportSuggestionToComment(part.id, savedDraft.id);
   }
 
   private onRetry() {
@@ -337,88 +338,91 @@
       ${when(
         !message.errorMessage && responseParts.length > 0,
         () => html`
-          ${textParts.map(
-            responsePart => html`
-              <p class="text-content text-response">
-                <gr-formatted-text
-                  .markdown=${true}
-                  .content=${responsePart.content}
-                ></gr-formatted-text>
-              </p>
-            `
-          )}
-          ${when(!this.isBackgroundRequest, () =>
-            this.sortedComments().map(comment => {
-              const displayLine = computeDisplayLine(comment.comment);
-              const lineNum =
-                typeof displayLine === 'string' && displayLine.startsWith('#')
-                  ? Number(displayLine.substring(1))
-                  : typeof displayLine === 'number'
-                  ? displayLine
-                  : undefined;
-              return html`
-                ${when(
-                  comment.comment.path,
-                  () => html`
-                    <button
-                      class="comment-path link-button"
-                      @click=${() =>
-                        this.handleFileClick(
-                          comment.comment.path as string,
-                          lineNum
-                        )}
-                    >
-                      <gr-icon icon="description"></gr-icon>
-                      ${comment.comment.path}
-                    </button>
-                  `
-                )}
-                ${when(
-                  displayLine,
-                  () => html`
-                    <button
-                      class="comment-line link-button"
-                      @click=${() =>
-                        this.handleFileClick(
-                          comment.comment.path as string,
-                          lineNum
-                        )}
-                    >
-                      <gr-icon icon="code"></gr-icon>
-                      ${displayLine}
-                    </button>
-                  `
-                )}
-                <div class="suggested-comment">
-                  <p class="suggested-comment-message">
-                    <gr-formatted-text
-                      .markdown=${true}
-                      .content=${comment.comment.message}
-                    ></gr-formatted-text>
-                  </p>
-                  <gr-button
-                    primary
-                    class="add-as-comment-button"
-                    @click=${() => this.onAddAsComment(comment)}
-                    >Add as Comment
-                  </gr-button>
-                </div>
-              `;
-            })
-          )}
-          ${when(
-            message.responseComplete && !this.isBackgroundRequest,
-            () => html`
-              <citations-box .turnIndex=${this.turnIndex}></citations-box>
-              <references-dropdown
-                .turnIndex=${this.turnIndex}
-              ></references-dropdown>
-              <message-actions
-                .turnId=${this.turnId()}
-                .isLatest=${this.isLatest}
-              ></message-actions>
-            `
-          )}
+          <div @copy=${this.reportContentCopied}>
+            ${textParts.map(
+              responsePart => html`
+                <p class="text-content text-response">
+                  <gr-formatted-text
+                    .markdown=${true}
+                    .content=${responsePart.content}
+                  ></gr-formatted-text>
+                </p>
+              `
+            )}
+            ${when(!this.isBackgroundRequest, () =>
+              this.sortedComments().map(comment => {
+                const displayLine = computeDisplayLine(comment.comment);
+                const lineNum =
+                  typeof displayLine === 'string' && displayLine.startsWith('#')
+                    ? Number(displayLine.substring(1))
+                    : typeof displayLine === 'number'
+                    ? displayLine
+                    : undefined;
+                return html`
+                  ${when(
+                    comment.comment.path,
+                    () => html`
+                      <button
+                        class="comment-path link-button"
+                        @click=${() =>
+                          this.handleFileClick(
+                            comment.comment.path as string,
+                            lineNum
+                          )}
+                      >
+                        <gr-icon icon="description"></gr-icon>
+                        ${comment.comment.path}
+                      </button>
+                    `
+                  )}
+                  ${when(
+                    displayLine,
+                    () => html`
+                      <button
+                        class="comment-line link-button"
+                        @click=${() =>
+                          this.handleFileClick(
+                            comment.comment.path as string,
+                            lineNum
+                          )}
+                      >
+                        <gr-icon icon="code"></gr-icon>
+                        ${displayLine}
+                      </button>
+                    `
+                  )}
+                  <div class="suggested-comment">
+                    <p class="suggested-comment-message">
+                      <gr-formatted-text
+                        .markdown=${true}
+                        .content=${comment.comment.message}
+                      ></gr-formatted-text>
+                    </p>
+                    <gr-button
+                      primary
+                      class="add-as-comment-button"
+                      @click=${() => this.onAddAsComment(comment)}
+                      >Add as Comment
+                    </gr-button>
+                  </div>
+                `;
+              })
+            )}
+            ${when(
+              message.responseComplete && !this.isBackgroundRequest,
+              () => html`
+                <citations-box .turnIndex=${this.turnIndex}></citations-box>
+                <references-dropdown
+                  .turnIndex=${this.turnIndex}
+                ></references-dropdown>
+                <message-actions
+                  .turnId=${this.turnId()}
+                  .isLatest=${this.isLatest}
+                  @item-copied=${this.reportCopyButtonClicked}
+                ></message-actions>
+              `
+            )}
+          </div>
         `
       )}
     `;
@@ -447,12 +451,17 @@
     };
   }
 
-  getAiAgentReportingDetails(): AiAgentEventDetails {
+  getAiAgentReportingDetails(
+    suggestionId?: number,
+    commentId?: string
+  ): AiAgentEventDetails {
     const agentId = this.turns[this.turnIndex]?.userMessage?.actionId ?? '';
     return {
       agentId,
       conversationId: this.conversationId ?? '',
       turnIndex: this.turnIndex,
+      suggestionId,
+      commentId,
     };
   }
 
@@ -469,9 +478,23 @@
     );
   }
 
-  private reportSuggestionToComment() {
+  private reportSuggestionToComment(suggestionId: number, commentId?: string) {
     this.reportingService.reportInteraction(
       Interaction.AI_AGENT_SUGGESTION_TO_COMMENT,
+      this.getAiAgentReportingDetails(suggestionId, commentId)
+    );
+  }
+
+  private reportCopyButtonClicked() {
+    this.reportingService.reportInteraction(
+      Interaction.AI_AGENT_SUGGESTION_COPY_BUTTON_CLICKED,
+      this.getAiAgentReportingDetails()
+    );
+  }
+
+  private reportContentCopied() {
+    this.reportingService.reportInteraction(
+      Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED,
       this.getAiAgentReportingDetails()
     );
   }
diff --git a/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
index a23a199..5618b06 100644
--- a/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
@@ -26,7 +26,7 @@
 import {chatProvider, createChange} from '../../test/test-data-generators';
 import {ParsedChangeInfo} from '../../types/types';
 import {CommentsModel} from '../../models/comments/comments-model';
-import {AiAgentEventDetails, Interaction} from '../../constants/reporting';
+import {AiAgentChatEventDetails, Interaction} from '../../constants/reporting';
 import {getAppContext} from '../../services/app-context';
 
 suite('gemini-message tests', () => {
@@ -260,7 +260,7 @@
       reportStub.firstCall.args[0],
       Interaction.AI_AGENT_SUGGESTIONS_SHOWN
     );
-    const details = reportStub.firstCall.args[1] as AiAgentEventDetails;
+    const details = reportStub.firstCall.args[1] as AiAgentChatEventDetails;
     assert.equal(details.conversationId, 'test-conversation-id');
     assert.equal(details.agentId, 'custom-agent-id');
     assert.equal(details.commentCount, 1);
@@ -295,7 +295,7 @@
     assert.isOk(commentContainer);
 
     sinon.stub(commentsModel, 'reloadAllComments');
-    saveDraftStub.resolves({});
+    saveDraftStub.resolves({id: 'test-comment-id'});
 
     const button = commentContainer?.querySelector('gr-button');
     assert.isOk(button);
@@ -308,9 +308,163 @@
       .find(c => c.args[0] === Interaction.AI_AGENT_SUGGESTION_TO_COMMENT);
     assert.isOk(call, 'Expected AI_AGENT_SUGGESTION_TO_COMMENT to be reported');
 
-    const details = call.args[1] as AiAgentEventDetails;
+    const details = call.args[1] as AiAgentChatEventDetails;
     assert.equal(details.conversationId, 'test-conversation-id');
     assert.equal(details.agentId, 'custom-agent-id');
+    assert.equal(details.commentId, 'test-comment-id');
     assert.isUndefined(details.commentCount);
   });
+
+  test('reports AI_AGENT_SUGGESTION_COPY_BUTTON_CLICKED interaction', async () => {
+    chatModel.updateState({
+      ...chatModel.getState(),
+      id: 'test-conversation-id',
+      selectedModelId: 'gemini-model-id',
+    });
+    const reportStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+    const turn = createTurn({
+      responseComplete: true,
+      responseParts: [RESPONSE_TEXT],
+    });
+    const updatedTurn = {
+      ...turn,
+      userMessage: {...turn.userMessage, actionId: 'custom-agent-id'},
+    };
+    chatModel.updateState({...chatModel.getState(), turns: [updatedTurn]});
+    element.isLatest = true;
+    await element.updateComplete;
+    const messageActions = element.shadowRoot?.querySelector('message-actions');
+    assert.isOk(messageActions);
+    messageActions?.dispatchEvent(
+      new CustomEvent('item-copied', {bubbles: true, composed: true})
+    );
+
+    const call = reportStub
+      .getCalls()
+      .find(
+        c => c.args[0] === Interaction.AI_AGENT_SUGGESTION_COPY_BUTTON_CLICKED
+      );
+    assert.isOk(
+      call,
+      'Expected AI_AGENT_SUGGESTION_COPY_BUTTON_CLICKED to be reported'
+    );
+
+    const details = call.args[1] as AiAgentChatEventDetails;
+    assert.equal(details.conversationId, 'test-conversation-id');
+    assert.equal(details.agentId, 'custom-agent-id');
+  });
+
+  test('reports AI_AGENT_SUGGESTION_CONTENT_COPIED on text response copy', async () => {
+    chatModel.updateState({
+      ...chatModel.getState(),
+      id: 'test-conversation-id',
+      selectedModelId: 'gemini-model-id',
+    });
+    const reportStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+    const turn = createTurn({
+      responseComplete: true,
+      responseParts: [RESPONSE_TEXT],
+    });
+    const updatedTurn = {
+      ...turn,
+      userMessage: {...turn.userMessage, actionId: 'custom-agent-id'},
+    };
+    chatModel.updateState({...chatModel.getState(), turns: [updatedTurn]});
+    await element.updateComplete;
+    const textResponse = element.shadowRoot?.querySelector('.text-response');
+    assert.isOk(textResponse);
+
+    textResponse?.dispatchEvent(
+      new CustomEvent('copy', {bubbles: true, composed: true})
+    );
+
+    const call = reportStub
+      .getCalls()
+      .find(c => c.args[0] === Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED);
+    assert.isOk(
+      call,
+      'Expected AI_AGENT_SUGGESTION_CONTENT_COPIED to be reported'
+    );
+  });
+
+  test('reports AI_AGENT_SUGGESTION_CONTENT_COPIED on suggested comment copy', async () => {
+    chatModel.updateState({
+      ...chatModel.getState(),
+      id: 'test-conversation-id',
+      selectedModelId: 'gemini-model-id',
+    });
+    const reportStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+    const turn = createTurn({
+      responseComplete: true,
+      responseParts: [RESPONSE_CREATE_COMMENT],
+    });
+    const updatedTurn = {
+      ...turn,
+      userMessage: {...turn.userMessage, actionId: 'custom-agent-id'},
+    };
+    chatModel.updateState({...chatModel.getState(), turns: [updatedTurn]});
+    await element.updateComplete;
+    const commentContainer =
+      element.shadowRoot?.querySelector('.suggested-comment');
+    assert.isOk(commentContainer);
+
+    commentContainer?.dispatchEvent(
+      new CustomEvent('copy', {bubbles: true, composed: true})
+    );
+
+    const call = reportStub
+      .getCalls()
+      .find(c => c.args[0] === Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED);
+    assert.isOk(
+      call,
+      'Expected AI_AGENT_SUGGESTION_CONTENT_COPIED to be reported'
+    );
+  });
+
+  test('reports AI_AGENT_SUGGESTION_CONTENT_COPIED on citations copy', async () => {
+    chatModel.updateState({
+      ...chatModel.getState(),
+      id: 'test-conversation-id',
+      selectedModelId: 'gemini-model-id',
+    });
+    const reportStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+    const turn = createTurn({
+      responseComplete: true,
+      responseParts: [RESPONSE_TEXT],
+      citations: ['http://example.com'],
+    });
+    const updatedTurn = {
+      ...turn,
+      userMessage: {...turn.userMessage, actionId: 'custom-agent-id'},
+    };
+    chatModel.updateState({...chatModel.getState(), turns: [updatedTurn]});
+    element.isLatest = true;
+    await element.updateComplete;
+    const citationsBox = element.shadowRoot?.querySelector('citations-box');
+    assert.isOk(citationsBox);
+
+    citationsBox?.dispatchEvent(
+      new CustomEvent('copy', {bubbles: true, composed: true})
+    );
+
+    const call = reportStub
+      .getCalls()
+      .find(c => c.args[0] === Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED);
+    assert.isOk(
+      call,
+      'Expected AI_AGENT_SUGGESTION_CONTENT_COPIED to be reported'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/chat-panel/message-actions.ts b/polygerrit-ui/app/elements/chat-panel/message-actions.ts
index 78f6b66..86f6980 100644
--- a/polygerrit-ui/app/elements/chat-panel/message-actions.ts
+++ b/polygerrit-ui/app/elements/chat-panel/message-actions.ts
@@ -87,6 +87,8 @@
         .text=${this.getGeminiMessageText()}
         hideInput
         .smallIcon=${false}
+        buttonTitle="Copy response to clipboard"
+        copyTargetName="Response"
       ></gr-copy-clipboard>
 
       <md-icon-button
diff --git a/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts b/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts
index 123a76d..c70c976 100644
--- a/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts
@@ -82,7 +82,12 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <gr-copy-clipboard class="copy-button" hideinput="">
+        <gr-copy-clipboard
+          buttontitle="Copy response to clipboard"
+          class="copy-button"
+          copytargetname="Response"
+          hideinput=""
+        >
         </gr-copy-clipboard>
         <md-icon-button
           class="regenerate-button"
@@ -102,7 +107,13 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <gr-copy-clipboard class="copy-button" hidden="" hideinput="">
+        <gr-copy-clipboard
+          buttontitle="Copy response to clipboard"
+          class="copy-button"
+          copytargetname="Response"
+          hidden=""
+          hideinput=""
+        >
         </gr-copy-clipboard>
         <md-icon-button
           class="regenerate-button"
diff --git a/polygerrit-ui/app/elements/chat-panel/prompt-box.ts b/polygerrit-ui/app/elements/chat-panel/prompt-box.ts
index 11e915d..933c1db 100644
--- a/polygerrit-ui/app/elements/chat-panel/prompt-box.ts
+++ b/polygerrit-ui/app/elements/chat-panel/prompt-box.ts
@@ -54,7 +54,7 @@
 
   @state() selectedModel?: ModelInfo;
 
-  @state() userInput = '';
+  @property({type: String}) userInput = '';
 
   @state() previousMessageIndex = -1;
 
@@ -70,7 +70,7 @@
 
   @state() showAllContextItems = false;
 
-  @state() contextItemTypes: readonly ContextItemType[] = [];
+  @property({type: Array}) contextItemTypes: readonly ContextItemType[] = [];
 
   @state() private change?: ParsedChangeInfo;
 
@@ -542,6 +542,8 @@
 
   private onKeyDown(event: KeyboardEvent) {
     if (
+      event.isComposing ||
+      event.keyCode === 229 ||
       event.ctrlKey ||
       event.altKey ||
       event.metaKey ||
diff --git a/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts b/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts
index ccc92c8..ba7a969 100644
--- a/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts
@@ -160,6 +160,41 @@
     assert.equal(turns[turns.length - 1].userMessage.content, 'test input');
   });
 
+  test('does not send message on Enter when IME is composing', async () => {
+    const initialTurns = chatModel.getState().turns.length;
+    const promptInput = element.shadowRoot?.querySelector('#promptInput');
+    assert.isOk(promptInput);
+    element.userInput = 'test input';
+    await element.updateComplete;
+
+    promptInput?.dispatchEvent(
+      new KeyboardEvent('keydown', {key: 'Enter', isComposing: true})
+    );
+    await element.updateComplete;
+
+    const turns = chatModel.getState().turns;
+    assert.equal(turns.length, initialTurns);
+  });
+
+  test('does not send message on Enter when keyCode is 229', async () => {
+    const initialTurns = chatModel.getState().turns.length;
+    const promptInput = element.shadowRoot?.querySelector('#promptInput');
+    assert.isOk(promptInput);
+    element.userInput = 'test input';
+    await element.updateComplete;
+
+    const eventInit: KeyboardEventInit & {keyCode?: number} = {
+      key: 'Enter',
+      keyCode: 229,
+    };
+    const event = new KeyboardEvent('keydown', eventInit);
+    promptInput?.dispatchEvent(event);
+    await element.updateComplete;
+
+    const turns = chatModel.getState().turns;
+    assert.equal(turns.length, initialTurns);
+  });
+
   test('renders context items', async () => {
     chatModel.updateState({
       ...chatModel.getState(),
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page-action.ts b/polygerrit-ui/app/elements/chat-panel/splash-page-action.ts
index ca69b5e..5313f38 100644
--- a/polygerrit-ui/app/elements/chat-panel/splash-page-action.ts
+++ b/polygerrit-ui/app/elements/chat-panel/splash-page-action.ts
@@ -6,10 +6,11 @@
 import '@material/web/iconbutton/icon-button.js';
 import '@material/web/progress/circular-progress.js';
 
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 import '../shared/gr-icon/gr-icon';
 import '../shared/gr-button/gr-button';
@@ -18,6 +19,7 @@
 import {Action, ContextItemType} from '../../api/ai-code-review';
 import {chatModelToken} from '../../models/chat/chat-model';
 import {parseLink} from '../../models/chat/context-item-util';
+
 import {resolve} from '../../models/dependency';
 import {isDefined} from '../../types/types';
 import {fireAlert} from '../../utils/event-util';
@@ -32,6 +34,8 @@
  */
 @customElement('splash-page-action')
 export class SplashPageAction extends LitElement {
+  private static readonly COLLAPSED_HEIGHT = 100;
+
   @property({type: Object}) action?: Action;
 
   @property({type: Boolean}) isFirst = false;
@@ -40,6 +44,12 @@
 
   @state() contextItemTypes: readonly ContextItemType[] = [];
 
+  @state() private isInstructionExpanded = false;
+
+  @state() private isFilesExpanded = false;
+
+  @state() private showExpandButton = false;
+
   @query('#detailsModal') private detailsModal?: HTMLDialogElement;
 
   private readonly getChatModel = resolve(this, chatModelToken);
@@ -177,7 +187,9 @@
         padding: var(--spacing-m) var(--spacing-xl);
         background-color: var(--dialog-background-color);
         flex: 1;
-        overflow: auto;
+        display: flex;
+        flex-direction: column;
+        overflow: hidden;
       }
       .info-button:hover {
         background-color: var(--hover-background-color, rgba(0, 0, 0, 0.08));
@@ -202,8 +214,7 @@
       #detailsModal {
         width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
         max-width: 90vw;
-        height: 300px;
-        max-height: 90vh;
+        max-height: 80vh;
       }
       #detailsModal > div {
         display: flex;
@@ -234,6 +245,51 @@
       .modal-row-text {
         color: var(--primary-text-color);
       }
+      .modal-row-text a {
+        color: var(--info-foreground);
+      }
+      .instruction-row {
+        flex: 1;
+        min-height: 0;
+      }
+      .instruction-row .modal-row-content {
+        flex: 1;
+        display: flex;
+        flex-direction: column;
+        min-height: 0;
+      }
+      .instruction-text {
+        flex: 1;
+      }
+      .instruction-text.collapsed {
+        max-height: ${SplashPageAction.COLLAPSED_HEIGHT}px;
+        overflow: hidden;
+      }
+      .instruction-text.expanded {
+        max-height: none;
+        overflow-y: auto;
+      }
+      .expand-button {
+        color: var(--link-color);
+        cursor: pointer;
+        padding: var(--spacing-xs) 0;
+        font-size: var(--font-size-small);
+        background: none;
+        border: none;
+        text-align: left;
+      }
+      .expand-button:hover {
+        text-decoration: underline;
+      }
+      .file-list {
+        display: flex;
+        flex-direction: column;
+        gap: var(--spacing-xs);
+      }
+      .file-item {
+        word-break: break-all;
+        color: var(--primary-text-color);
+      }
       .link-row {
         display: flex;
         align-items: center;
@@ -309,13 +365,69 @@
             ${when(
               this.action?.initial_user_prompt,
               () => html`
-                <div class="modal-row">
+                <div class="modal-row instruction-row">
                   <gr-icon icon="terminal"></gr-icon>
                   <div class="modal-row-content">
                     <div class="modal-row-title">Instruction:</div>
-                    <div class="modal-row-text">
+                    <div
+                      class="modal-row-text instruction-text ${this
+                        .isInstructionExpanded
+                        ? 'expanded'
+                        : 'collapsed'}"
+                    >
                       ${this.action?.initial_user_prompt}
                     </div>
+                    ${when(
+                      this.showExpandButton,
+                      () => html`
+                        <button
+                          class="expand-button"
+                          aria-expanded=${this.isInstructionExpanded}
+                          @click=${() =>
+                            (this.isInstructionExpanded =
+                              !this.isInstructionExpanded)}
+                        >
+                          ${this.isInstructionExpanded
+                            ? 'Show less'
+                            : 'Show more'}
+                        </button>
+                      `
+                    )}
+                  </div>
+                </div>
+              `
+            )}
+            ${when(
+              this.action?.matched_files &&
+                this.action.matched_files.length > 0,
+              () => html`
+                <div class="modal-row matched-files-row">
+                  <gr-icon icon="folder"></gr-icon>
+                  <div class="modal-row-content">
+                    <div class="modal-row-text">
+                      ${this.renderMatchedFiles()}
+                    </div>
+                  </div>
+                </div>
+              `
+            )}
+            ${when(
+              this.action?.capability_definition_url,
+              () => html`
+                <div class="modal-row">
+                  <gr-icon icon="link"></gr-icon>
+                  <div class="modal-row-content">
+                    <div class="modal-row-text">
+                      <a
+                        href=${ifDefined(
+                          this.action?.capability_definition_url
+                        )}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                      >
+                        Capability Definition
+                      </a>
+                    </div>
                   </div>
                 </div>
               `
@@ -365,9 +477,46 @@
     }
   }
 
-  private displayDetailsCard(event: MouseEvent) {
+  private renderMatchedFiles() {
+    const files = this.action?.matched_files || [];
+    if (files.length === 0) return nothing;
+
+    const showAll = this.isFilesExpanded || files.length <= 4;
+    const filesToDisplay = showAll ? files : files.slice(0, 4);
+
+    return html`
+      <div class="modal-row-title">Matched files:</div>
+      <div class="file-list ${this.isFilesExpanded ? 'expanded' : ''}">
+        ${filesToDisplay.map(
+          file => html`<div class="file-item">${file}</div>`
+        )}
+      </div>
+      ${when(
+        files.length > 4,
+        () => html`
+          <button
+            class="expand-button"
+            aria-expanded=${this.isFilesExpanded}
+            @click=${() => (this.isFilesExpanded = !this.isFilesExpanded)}
+          >
+            ${this.isFilesExpanded ? 'Show less' : 'Show more'}
+          </button>
+        `
+      )}
+    `;
+  }
+
+  private async displayDetailsCard(event: MouseEvent) {
     event.stopPropagation();
+    this.isInstructionExpanded = false;
+    this.isFilesExpanded = false;
     this.detailsModal?.showModal();
+    await this.updateComplete;
+    const textEl = this.shadowRoot?.querySelector('.instruction-text');
+    if (textEl) {
+      this.showExpandButton =
+        textEl.scrollHeight > SplashPageAction.COLLAPSED_HEIGHT;
+    }
   }
 }
 
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page-action_screenshot_test.ts b/polygerrit-ui/app/elements/chat-panel/splash-page-action_screenshot_test.ts
index 5dd2f4f..20f5891 100644
--- a/polygerrit-ui/app/elements/chat-panel/splash-page-action_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/splash-page-action_screenshot_test.ts
@@ -39,6 +39,7 @@
       id: 'test-action',
       display_text: 'Test Action',
       initial_user_prompt: 'Test prompt',
+      capability_definition_url: 'http://cs/depot/google3/some/file.ts',
     };
     element.action = action;
     await element.updateComplete;
@@ -59,4 +60,58 @@
     await visualDiff(modal, 'splash-page-action-details-modal');
     await visualDiffDarkTheme(modal, 'splash-page-action-details-modal');
   });
+
+  test('details modal rendering with long instructions and matched files', async () => {
+    const action: Action = {
+      id: 'test-action',
+      display_text: 'Test Action',
+      initial_user_prompt:
+        'This is a long instruction text. We want to test that it collapses properly and can be expanded. ' +
+        'By forcing the state in the test, we guarantee that the button appears regardless of font rendering differences.',
+      capability_definition_url: 'http://cs/depot/google3/some/file.ts',
+      matched_files: [
+        'file1.txt',
+        'file2.txt',
+        'file3.txt',
+        'file4.txt',
+        'file5.txt',
+      ],
+    };
+    element.action = action;
+    await element.updateComplete;
+
+    // Trigger the modal to open
+    const infoButton = element.shadowRoot?.querySelector(
+      '.info-button'
+    ) as HTMLElement;
+    assert.isOk(infoButton);
+    infoButton.click();
+
+    await element.updateComplete;
+
+    const modal = element.shadowRoot?.querySelector(
+      '#detailsModal'
+    ) as HTMLElement;
+    assert.isOk(modal);
+
+    // Force the showExpandButton state to true to make the test robust
+    (element as unknown as {showExpandButton: boolean}).showExpandButton = true;
+    await element.updateComplete;
+
+    await visualDiff(modal, 'splash-page-action-details-modal-long');
+    await visualDiffDarkTheme(modal, 'splash-page-action-details-modal-long');
+
+    // Force the isInstructionExpanded and isFilesExpanded state to true to test expanded rendering
+    (
+      element as unknown as {isInstructionExpanded: boolean}
+    ).isInstructionExpanded = true;
+    (element as unknown as {isFilesExpanded: boolean}).isFilesExpanded = true;
+    await element.updateComplete;
+
+    await visualDiff(modal, 'splash-page-action-details-modal-expanded');
+    await visualDiffDarkTheme(
+      modal,
+      'splash-page-action-details-modal-expanded'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page-action_test.ts b/polygerrit-ui/app/elements/chat-panel/splash-page-action_test.ts
index 803694a..07bb480 100644
--- a/polygerrit-ui/app/elements/chat-panel/splash-page-action_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/splash-page-action_test.ts
@@ -89,11 +89,13 @@
           <div role="dialog" aria-labelledby="detailsTitle">
             <h3 class="heading-3 modalHeader" id="detailsTitle">Test Action</h3>
             <div class="detailsContent">
-              <div class="modal-row">
+              <div class="modal-row instruction-row">
                 <gr-icon icon="terminal"></gr-icon>
                 <div class="modal-row-content">
                   <div class="modal-row-title">Instruction:</div>
-                  <div class="modal-row-text">Test prompt</div>
+                  <div class="modal-row-text instruction-text collapsed">
+                    Test prompt
+                  </div>
                 </div>
               </div>
             </div>
@@ -166,4 +168,96 @@
     const turns = chatModel.getState().turns;
     assert.lengthOf(turns, 0);
   });
+  test('renders capability definition link when URL is present', async () => {
+    const action: Action = {
+      id: 'test-action',
+      display_text: 'Test Action',
+      capability_definition_url: 'https://example.com',
+    };
+    element.action = action;
+    await element.updateComplete;
+
+    const modal = element.shadowRoot?.querySelector('#detailsModal');
+    assert.isOk(modal);
+
+    const sourceLink = modal?.querySelector(
+      '.modal-row-text a'
+    ) as HTMLAnchorElement;
+    assert.isOk(sourceLink);
+    assert.equal(sourceLink.getAttribute('href'), 'https://example.com');
+    assert.equal(sourceLink.innerText.trim(), 'Capability Definition');
+  });
+
+  test('does not render link when URL is absent', async () => {
+    const action: Action = {
+      id: 'test-action',
+      display_text: 'Test Action',
+    };
+    element.action = action;
+    await element.updateComplete;
+
+    const modal = element.shadowRoot?.querySelector('#detailsModal');
+    assert.isOk(modal);
+
+    const sourceLink = modal?.querySelector('.modal-row-text a');
+    assert.isNull(sourceLink);
+  });
+
+  test('renders with matched_files', async () => {
+    const action: Action = {
+      id: 'test-action',
+      display_text: 'Test Action',
+      matched_files: ['file1.txt', 'file2.txt'],
+    };
+    element.action = action;
+    await element.updateComplete;
+
+    const modal = element.shadowRoot?.querySelector('#detailsModal');
+    assert.isOk(modal);
+
+    const fileItems = modal?.querySelectorAll('.file-item');
+    assert.equal(fileItems?.length, 2);
+    assert.equal(fileItems?.[0].textContent?.trim(), 'file1.txt');
+    assert.equal(fileItems?.[1].textContent?.trim(), 'file2.txt');
+
+    const expandButton = modal?.querySelector(
+      '.matched-files-row .expand-button'
+    );
+    assert.isNotOk(expandButton);
+  });
+
+  test('renders with many matched_files and expands', async () => {
+    const action: Action = {
+      id: 'test-action',
+      display_text: 'Test Action',
+      matched_files: [
+        'file1.txt',
+        'file2.txt',
+        'file3.txt',
+        'file4.txt',
+        'file5.txt',
+      ],
+    };
+    element.action = action;
+    await element.updateComplete;
+
+    const modal = element.shadowRoot?.querySelector('#detailsModal');
+    assert.isOk(modal);
+
+    let fileItems = modal?.querySelectorAll('.file-item');
+    assert.equal(fileItems?.length, 4);
+
+    const expandButton = modal?.querySelector(
+      '.matched-files-row .expand-button'
+    ) as HTMLElement;
+    assert.isOk(expandButton);
+    assert.equal(expandButton.innerText.trim(), 'Show more');
+
+    expandButton.click();
+    await element.updateComplete;
+
+    fileItems = modal?.querySelectorAll('.file-item');
+    assert.equal(fileItems?.length, 5);
+    assert.equal(expandButton.innerText.trim(), 'Show less');
+  });
 });
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page.ts b/polygerrit-ui/app/elements/chat-panel/splash-page.ts
index f44c838..dc69086 100644
--- a/polygerrit-ui/app/elements/chat-panel/splash-page.ts
+++ b/polygerrit-ui/app/elements/chat-panel/splash-page.ts
@@ -10,6 +10,7 @@
 import '@material/web/progress/circular-progress.js';
 import './gemini-message';
 import './splash-page-action';
+import '../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 
 import {css, html, LitElement} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
@@ -20,6 +21,12 @@
 import {chatModelToken, Turn} from '../../models/chat/chat-model';
 import {resolve} from '../../models/dependency';
 import {userModelToken} from '../../models/user/user-model';
+import {
+  CheckRun,
+  checksModelToken,
+  RunResult,
+} from '../../models/checks/checks-model';
+import {changeModelToken} from '../../models/change/change-model';
 import {AccountDetailInfo, ServerInfo} from '../../types/common';
 import {subscribe} from '../lit/subscription-controller';
 import {getDisplayName} from '../../utils/display-name-util';
@@ -46,10 +53,20 @@
 
   @property({type: Boolean}) isChangePrivate = false;
 
+  @state() runs: readonly CheckRun[] = [];
+
+  @state() results: readonly RunResult[] = [];
+
+  @state() changeNum?: number;
+
   private readonly getChatModel = resolve(this, chatModelToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -87,6 +104,21 @@
       () => this.getUserModel().account$,
       x => (this.account = x)
     );
+    subscribe(
+      this,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
+      x => (this.runs = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().allResultsSelected$,
+      x => (this.results = x ?? [])
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
   }
 
   private get currentTurn(): Turn | undefined {
@@ -255,8 +287,19 @@
       `;
     }
     return html`
-      ${this.renderBackgroundRequest()} ${this.renderCustomActions()}
-      ${this.renderActions()}
+      ${this.renderBackgroundRequest()}
+      <gr-endpoint-decorator name="chat-panel-splash-extra">
+        <gr-endpoint-param name="runs" .value=${this.runs}></gr-endpoint-param>
+        <gr-endpoint-param
+          name="results"
+          .value=${this.results}
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="changeNum"
+          .value=${this.changeNum}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      ${this.renderCustomActions()} ${this.renderActions()}
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page_test.ts b/polygerrit-ui/app/elements/chat-panel/splash-page_test.ts
index 825eb01..28099c2 100644
--- a/polygerrit-ui/app/elements/chat-panel/splash-page_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/splash-page_test.ts
@@ -53,6 +53,11 @@
         <div class="splash-container">
           <h1 class="splash-greeting">Hello,</h1>
           <p class="splash-question">How can I help you today?</p>
+          <gr-endpoint-decorator name="chat-panel-splash-extra">
+            <gr-endpoint-param name="runs"></gr-endpoint-param>
+            <gr-endpoint-param name="results"></gr-endpoint-param>
+            <gr-endpoint-param name="changeNum"></gr-endpoint-param>
+          </gr-endpoint-decorator>
           <div class="action-container-title suggested-actions-title">
             Capabilities
           </div>
diff --git a/polygerrit-ui/app/elements/chat-panel/user-message.ts b/polygerrit-ui/app/elements/chat-panel/user-message.ts
index 4dc7aef..bae523f 100644
--- a/polygerrit-ui/app/elements/chat-panel/user-message.ts
+++ b/polygerrit-ui/app/elements/chat-panel/user-message.ts
@@ -98,6 +98,7 @@
 
       .text-content {
         white-space: pre-wrap;
+        overflow-wrap: break-word;
         margin: 0px;
       }
 
diff --git a/polygerrit-ui/app/elements/chat-panel/user-message_screenshot_test.ts b/polygerrit-ui/app/elements/chat-panel/user-message_screenshot_test.ts
new file mode 100644
index 0000000..843197e
--- /dev/null
+++ b/polygerrit-ui/app/elements/chat-panel/user-message_screenshot_test.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+
+import {assert, fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-expect-error
+import {visualDiff} from '@web/test-runner-visual-regression';
+
+import * as chatModel from '../../models/chat/chat-model';
+import {visualDiffDarkTheme} from '../../test/test-utils';
+import {UserMessage} from './user-message';
+
+suite('user-message screenshot tests', () => {
+  let element: UserMessage;
+
+  const message: chatModel.UserMessage = {
+    userType: chatModel.UserType.USER,
+    content:
+      'src/com/android/settings/supervision/EnableSupervisionActivity.kt',
+    contextItems: [],
+  };
+
+  setup(async () => {
+    element = await fixture(
+      html`<user-message
+        style="width: 300px;"
+        .message=${message}
+      ></user-message>`
+    );
+    assert.instanceOf(element, UserMessage);
+    await element.updateComplete;
+  });
+
+  test('renders long unbroken file path with break-word wrapping', async () => {
+    await visualDiff(element, 'user-message-long-path');
+    await visualDiffDarkTheme(element, 'user-message-long-path');
+  });
+});
diff --git a/polygerrit-ui/app/elements/chat-panel/user-message_test.ts b/polygerrit-ui/app/elements/chat-panel/user-message_test.ts
index fb8732f..735cb63 100644
--- a/polygerrit-ui/app/elements/chat-panel/user-message_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/user-message_test.ts
@@ -5,7 +5,6 @@
  */
 import '../../test/common-test-setup';
 import '../shared/gr-avatar/gr-avatar';
-import './user-message';
 import {assert, fixture, html} from '@open-wc/testing';
 import {UserMessage} from './user-message';
 import {
@@ -66,6 +65,7 @@
   });
 
   test('renders', async () => {
+    assert.instanceOf(element, UserMessage);
     assert.shadowDom.equal(
       element,
       /* HTML */ `
@@ -83,6 +83,21 @@
     assert.equal(content?.textContent?.trim(), 'Hello, world!');
   });
 
+  test('renders long unbroken file path with break-word wrapping', async () => {
+    const longPath =
+      'src/com/android/settings/supervision/superduperlongerpaththatdefinitelycausesoverflow/EnableSupervisionActivity.kt';
+    element.message = {...message, content: longPath};
+    await element.updateComplete;
+
+    const content = element.shadowRoot?.querySelector(
+      '.text-content'
+    ) as HTMLElement;
+    assert.isOk(content);
+    assert.equal(content.textContent?.trim(), longPath);
+    const computedStyle = getComputedStyle(content);
+    assert.equal(computedStyle.overflowWrap, 'break-word');
+  });
+
   test('renders with account', async () => {
     const userModel = testResolver(userModelToken);
     userModel.updateState({
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts
index ddf37ab..74af4d8 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label.ts
@@ -75,10 +75,12 @@
     if (runs.length === 1 && runs[0].statusLink) {
       links.push(runs[0].statusLink);
     }
+    const isAi = runs.some(run => run.isAiPowered);
     return html`<gr-checks-chip
       .text=${`${runsCount}`}
       .links=${links}
       .statusOrCategory=${category}
+      .isAi=${isAi}
       @click=${() => {
         fireShowTab(this, Tab.CHECKS, false, {
           checksTab: {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts
index cac448a..641f2f8 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-chip-for-label_test.ts
@@ -58,4 +58,22 @@
       </div>`
     );
   });
+
+  test('sets isAi to true when check run is AI powered', async () => {
+    element.runs = [
+      createRun({
+        labelName: 'Verified',
+        isAiPowered: true,
+        results: [
+          createCheckResult({
+            category: Category.ERROR,
+          }),
+        ],
+      }),
+    ];
+    await element.updateComplete;
+
+    const checksChip = queryAndAssert<GrChecksChip>(element, 'gr-checks-chip');
+    assert.isTrue(checksChip.isAi);
+  });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 020be1b..219d0c0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -18,6 +18,7 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import './gr-checks-action';
 import './gr-hovercard-run';
+import './gr-checks-tag';
 import '../shared/gr-tooltip-content/gr-tooltip-content';
 import {KnownExperimentId} from '../../services/flags/flags';
 import {
@@ -27,7 +28,6 @@
   LinkIcon,
   NOT_USEFUL,
   RunStatus,
-  Tag,
   USEFUL,
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
@@ -49,6 +49,8 @@
   LATEST_ATTEMPT,
   otherPrimaryLinks,
   rectifyFix,
+  reportAiAgentGetAIFix,
+  reportAiAgentSuggestionCopy,
   secondaryLinks,
   sortAttemptChoices,
   stringToAttemptChoice,
@@ -314,33 +316,7 @@
         tr.detailsRow.collapsed {
           display: none;
         }
-        td .summary-cell .tags .tag {
-          color: var(--primary-text-color);
-          display: inline-block;
-          border-radius: 20px;
-          background-color: var(--tag-background);
-          padding: 0 var(--spacing-m);
-          margin-left: var(--spacing-s);
-          cursor: pointer;
-        }
-        td .summary-cell .tag.gray {
-          background-color: var(--tag-gray);
-        }
-        td .summary-cell .tag.yellow {
-          background-color: var(--tag-yellow);
-        }
-        td .summary-cell .tag.pink {
-          background-color: var(--tag-pink);
-        }
-        td .summary-cell .tag.purple {
-          background-color: var(--tag-purple);
-        }
-        td .summary-cell .tag.cyan {
-          background-color: var(--tag-cyan);
-        }
-        td .summary-cell .tag.brown {
-          background-color: var(--tag-brown);
-        }
+
         .actions-shown-on-collapsed gr-checks-action,
         .actions gr-checks-action,
         .actions gr-dropdown {
@@ -410,6 +386,11 @@
     );
   }
 
+  private handleCopy() {
+    if (!this.result) return;
+    reportAiAgentSuggestionCopy(this.reporting, this.result);
+  }
+
   override render() {
     if (!this.result) return '';
     if (!this.shouldRender) {
@@ -427,7 +408,10 @@
       ? html`<gr-icon small icon="ai"></gr-icon>`
       : nothing;
     return html`
-      <tr class=${classMap({container: true, collapsed: !this.isExpanded})}>
+      <tr
+        class=${classMap({container: true, collapsed: !this.isExpanded})}
+        @copy=${this.handleCopy}
+      >
         <td class="nameCol" @click=${this.toggleExpandedClick}>
           <div class="flex">
             <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
@@ -453,7 +437,12 @@
             </div>
             ${this.renderLinks()} ${this.renderActions()}
             <div class="tags">
-              ${(this.result.tags ?? []).map(t => this.renderTag(t))}
+              ${(this.result.tags ?? []).map(
+                t => html`<gr-checks-tag
+                  .tag=${t}
+                  @click=${(e: MouseEvent) => this.tagClick(e, t.name)}
+                ></gr-checks-tag>`
+              )}
             </div>
             ${this.renderLabel()}
           </div>
@@ -476,7 +465,10 @@
           </div>
         </td>
       </tr>
-      <tr class=${classMap({detailsRow: true, collapsed: !this.isExpanded})}>
+      <tr
+        class=${classMap({detailsRow: true, collapsed: !this.isExpanded})}
+        @copy=${this.handleCopy}
+      >
         <td class="expandedCol" colspan="3">${this.renderExpanded()}</td>
       </tr>
     `;
@@ -591,7 +583,7 @@
   renderLink(link?: Link) {
     // The expanded state renders all links in more detail. Hide in summary.
     if (this.isExpanded) return;
-    if (!link) return;
+    if (!link?.url?.trim()) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
     const icon = iconForLink(link.icon);
     return html`<gr-tooltip-content
@@ -701,22 +693,6 @@
     ></gr-checks-action>`;
   }
 
-  renderTag(tag: Tag) {
-    return html`<gr-tooltip-content
-      has-tooltip
-      ?position-below=${true}
-      title=${tag.tooltip ??
-      'A category tag for this check result. Click to filter.'}
-    >
-      <button
-        class="tag ${tag.color}"
-        @click=${(e: MouseEvent) => this.tagClick(e, tag.name)}
-      >
-        <span>${tag.name}</span>
-      </button>
-    </gr-tooltip-content>`;
-  }
-
   private renderSuggestionPreview() {
     if (!this.suggestion) return nothing;
     return html`<gr-checks-fix-preview
@@ -730,6 +706,7 @@
     if (!this.result || !this.result.message || !codePointer) return;
 
     this.suggestionLoading = true;
+    reportAiAgentGetAIFix(this.reporting, this.result);
     let suggestion: FixSuggestionInfo | undefined;
     try {
       suggestion = await this.getSuggestionsService().generateSuggestedFix({
@@ -804,6 +781,9 @@
         .ai-generated {
           font-weight: var(--font-weight-medium);
         }
+        .header-content {
+          margin-bottom: var(--spacing-m);
+        }
       `,
     ];
   }
@@ -811,9 +791,11 @@
   override render() {
     if (!this.result) return '';
     return html`
-      ${this.renderFirstPrimaryLink()} ${this.renderOtherPrimaryLinks()}
-      ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
-      ${this.renderAiLabel()}
+      <div class="header-content">
+        ${this.renderFirstPrimaryLink()} ${this.renderOtherPrimaryLinks()}
+        ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
+        ${this.renderAiLabel()}
+      </div>
       <gr-endpoint-decorator
         name="check-result-expanded"
         .targetPlugin=${this.result.pluginName}
@@ -829,7 +811,14 @@
           .content=${this.result.message ?? ''}
         ></gr-formatted-text>
       </gr-endpoint-decorator>
-      ${this.renderFix()} ${this.renderNotUseful()}
+      ${this.renderFix()}
+      <gr-endpoint-decorator name="check-result-feedback">
+        <gr-endpoint-param
+          name="result"
+          .value=${this.result}
+        ></gr-endpoint-param>
+        ${this.renderNotUseful()}
+      </gr-endpoint-decorator>
     `;
   }
 
@@ -934,7 +923,7 @@
   }
 
   private renderLink(link?: Link, targetBlank = true) {
-    if (!link) return;
+    if (!link?.url?.trim()) return;
     const text = link.tooltip ?? tooltipForLink(link.icon);
     const target = targetBlank ? '_blank' : undefined;
     const icon = iconForLink(link.icon);
@@ -973,7 +962,7 @@
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  @state()
+  @property({type: String})
   filterRegExp = '';
 
   /** All runs. Shown should only the selected/filtered ones. */
@@ -1161,6 +1150,10 @@
         .headerBottomRow a {
           margin-right: var(--spacing-l);
         }
+        #moreActions {
+          --gr-dropdown-position: relative;
+          --gr-dropdown-z-index: 120;
+        }
         #moreActions gr-icon {
           color: var(--link-color);
         }
@@ -1421,7 +1414,7 @@
   }
 
   private renderLink(link?: Link) {
-    if (!link) return;
+    if (!link?.url?.trim()) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
     const icon = iconForLink(link.icon);
     return html`<gr-tooltip-content
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 0c93d1f..9324c74 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -11,19 +11,25 @@
   GrResultRow,
 } from './gr-checks-results';
 import {html} from 'lit';
-import {assert, fixture} from '@open-wc/testing';
+import {assert, fixture, waitUntil} from '@open-wc/testing';
 import {checksModelToken, RunResult} from '../../models/checks/checks-model';
 import {
   checkRun0,
   checkRun1,
+  checkRun6,
   setAllcheckRuns,
 } from '../../test/test-data-generators';
 import {resolve} from '../../models/dependency';
 import {createLabelInfo} from '../../test/test-data-generators';
 import {assertIsDefined, query, queryAndAssert} from '../../utils/common-util';
 import {stubFlags} from '../../test/test-utils';
-import {PatchSetNumber} from '../../api/rest-api';
+import {Interaction} from '../../constants/reporting';
+import {FixId, NumericChangeId, PatchSetNumber} from '../../api/rest-api';
 import {GrDropdownList} from '../shared/gr-dropdown-list/gr-dropdown-list';
+import {getAppContext} from '../../services/app-context';
+import {suggestionsServiceToken} from '../../services/suggestions/suggestions-service';
+import {testResolver} from '../../test/common-test-setup';
+import {Link} from '../../api/checks';
 
 suite('gr-result-row test', () => {
   let element: GrResultRow;
@@ -101,24 +107,8 @@
           </div>
           <div class="message"></div>
           <div class="tags">
-            <gr-tooltip-content
-              has-tooltip=""
-              position-below=""
-              title="A category tag for this check result. Click to filter."
-            >
-              <button class="tag">
-                <span> OBSOLETE </span>
-              </button>
-            </gr-tooltip-content>
-            <gr-tooltip-content
-              has-tooltip=""
-              position-below=""
-              title="A category tag for this check result. Click to filter."
-            >
-              <button class="tag">
-                <span> E2E </span>
-              </button>
-            </gr-tooltip-content>
+            <gr-checks-tag> </gr-checks-tag>
+            <gr-checks-tag> </gr-checks-tag>
           </div>
         </div>
         <div
@@ -147,8 +137,7 @@
     await element.updateComplete;
     assert.isFalse(element.isExpanded);
 
-    const summaryDiv: HTMLElement =
-      element.shadowRoot!.querySelector('.summary')!;
+    const summaryDiv = queryAndAssert<HTMLElement>(element, '.summary');
     summaryDiv.click();
     await element.updateComplete;
     assert.isTrue(element.isExpanded);
@@ -157,6 +146,101 @@
     await element.updateComplete;
     assert.isFalse(element.isExpanded);
   });
+
+  test('reports AI_AGENT_GET_FIX_CLICKED when AI fix clicked', async () => {
+    const reportingStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+    const checksModel = testResolver(checksModelToken);
+    checksModel.changeNum = 123 as NumericChangeId;
+    const suggestionsService = testResolver(suggestionsServiceToken);
+    sinon
+      .stub(suggestionsService, 'isGeneratedSuggestedFixEnabled')
+      .returns(true);
+    sinon.stub(suggestionsService, 'generateSuggestedFix').resolves({
+      description: 'AI suggested fix',
+      replacements: [],
+      fix_id: '1' as FixId,
+    });
+
+    element.isOwner = true;
+    element.result = {
+      ...element.result!,
+      message: 'Test message',
+      externalId: JSON.stringify({
+        agentId: 'test-agent',
+        conversationId: 'test-conv',
+        turnIndex: 1,
+      }),
+      codePointers: [{path: 'test/path', range: {start_line: 1, end_line: 1}}],
+    } as RunResult;
+    // Mock the button or just call handleAIFix
+    // In gr-result-row, handleAIFix is private, but it's triggered by 'get-ai-fix-for-check-result' event
+    element.dispatchEvent(new CustomEvent('get-ai-fix-for-check-result'));
+    await waitUntil(() =>
+      reportingStub.calledWith(
+        Interaction.AI_AGENT_GET_FIX_CLICKED,
+        sinon.match({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 1,
+        })
+      )
+    );
+  });
+
+  test('reports AI_AGENT_SUGGESTION_CONTENT_COPIED when result is copied', async () => {
+    const reportingStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+    element.result = {
+      ...element.result!,
+      externalId: JSON.stringify({
+        agentId: 'test-agent',
+        conversationId: 'test-conv',
+        turnIndex: 1,
+      }),
+    } as RunResult;
+    await element.updateComplete;
+
+    const containerTr = queryAndAssert(element, 'tr.container');
+    containerTr.dispatchEvent(new Event('copy'));
+
+    assert.isTrue(
+      reportingStub.calledWith(
+        Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED,
+        sinon.match({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 1,
+        })
+      )
+    );
+
+    reportingStub.resetHistory();
+
+    const detailsTr = queryAndAssert(element, 'tr.detailsRow');
+    detailsTr.dispatchEvent(new Event('copy'));
+
+    assert.isTrue(
+      reportingStub.calledWith(
+        Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED,
+        sinon.match({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 1,
+        })
+      )
+    );
+  });
+
+  test('renderLink returns undefined when url is empty, whitespace, or missing', () => {
+    assert.isUndefined(element.renderLink({url: ''} as Link));
+    assert.isUndefined(element.renderLink({url: '   '} as Link));
+    assert.isUndefined(element.renderLink(undefined));
+  });
 });
 
 suite('gr-result-expanded test', () => {
@@ -176,97 +260,102 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <div class="links">
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" icon="download"> </gr-icon>
-            <span> Download </span>
-          </a>
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" icon="system_update"> </gr-icon>
-            <span> Download </span>
-          </a>
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" filled="" icon="image"> </gr-icon>
-            <span> Link to image </span>
-          </a>
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" filled="" icon="image"> </gr-icon>
-            <span> Link to image </span>
-          </a>
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" filled="" icon="bug_report"> </gr-icon>
-            <span> Link for reporting a problem </span>
-          </a>
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" icon="help"> </gr-icon>
-            <span> Link to help page </span>
-          </a>
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" icon="history"> </gr-icon>
-            <span> Link to result history </span>
-          </a>
-        </div>
-        <div class="links">
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" icon="open_in_new"> </gr-icon>
-            <span> Link to details </span>
-          </a>
-          <a
-            href="https://google.com"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            <gr-icon class="link" filled="" icon="image"> </gr-icon>
-            <span> Link to image </span>
-          </a>
-        </div>
-        <div>
-          <gr-icon custom="" icon="ai" small=""> </gr-icon>
-          <span class="ai-generated"> AI Generated </span>
-          by FAKE Error Finder Finder Finder Finder Finder Finder Finder
+        <div class="header-content">
+          <div class="links">
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" icon="download"> </gr-icon>
+              <span> Download </span>
+            </a>
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" icon="system_update"> </gr-icon>
+              <span> Download </span>
+            </a>
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" filled="" icon="image"> </gr-icon>
+              <span> Link to image </span>
+            </a>
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" filled="" icon="image"> </gr-icon>
+              <span> Link to image </span>
+            </a>
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" filled="" icon="bug_report"> </gr-icon>
+              <span> Link for reporting a problem </span>
+            </a>
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" icon="help"> </gr-icon>
+              <span> Link to help page </span>
+            </a>
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" icon="history"> </gr-icon>
+              <span> Link to result history </span>
+            </a>
+          </div>
+          <div class="links">
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" icon="open_in_new"> </gr-icon>
+              <span> Link to details </span>
+            </a>
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" filled="" icon="image"> </gr-icon>
+              <span> Link to image </span>
+            </a>
+          </div>
+          <div>
+            <gr-icon custom="" icon="ai" small=""> </gr-icon>
+            <span class="ai-generated"> AI Generated </span>
+            by FAKE Error Finder Finder Finder Finder Finder Finder Finder
+          </div>
         </div>
         <gr-endpoint-decorator name="check-result-expanded">
           <gr-endpoint-param name="run"> </gr-endpoint-param>
           <gr-endpoint-param name="result"> </gr-endpoint-param>
           <gr-formatted-text class="message"> </gr-formatted-text>
         </gr-endpoint-decorator>
-        <div class="useful">
-          <div class="title">Was this helpful?</div>
-          <gr-checks-action icon="thumb_up"> </gr-checks-action>
-          <gr-checks-action icon="thumb_down"> </gr-checks-action>
-        </div>
+        <gr-endpoint-decorator name="check-result-feedback">
+          <gr-endpoint-param name="result"> </gr-endpoint-param>
+          <div class="useful">
+            <div class="title">Was this helpful?</div>
+            <gr-checks-action icon="thumb_up"> </gr-checks-action>
+            <gr-checks-action icon="thumb_down"> </gr-checks-action>
+          </div>
+        </gr-endpoint-decorator>
       `
     );
   });
@@ -278,11 +367,13 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <div class="links"></div>
-        <div>
-          <gr-icon custom="" icon="ai" small=""> </gr-icon>
-          <span class="ai-generated"> AI Generated </span>
-          by FAKE Super Check
+        <div class="header-content">
+          <div class="links"></div>
+          <div>
+            <gr-icon custom="" icon="ai" small=""> </gr-icon>
+            <span class="ai-generated"> AI Generated </span>
+            by FAKE Super Check
+          </div>
         </div>
         <gr-endpoint-decorator name="check-result-expanded">
           <gr-endpoint-param name="run"> </gr-endpoint-param>
@@ -290,11 +381,47 @@
           <gr-formatted-text class="message"> </gr-formatted-text>
         </gr-endpoint-decorator>
         <gr-checks-fix-preview> </gr-checks-fix-preview>
-        <div class="useful">
-          <div class="title">Was this helpful?</div>
-          <gr-checks-action icon="thumb_up"> </gr-checks-action>
-          <gr-checks-action icon="thumb_down"> </gr-checks-action>
+        <gr-endpoint-decorator name="check-result-feedback">
+          <gr-endpoint-param name="result"> </gr-endpoint-param>
+          <div class="useful">
+            <div class="title">Was this helpful?</div>
+            <gr-checks-action icon="thumb_up"> </gr-checks-action>
+            <gr-checks-action icon="thumb_down"> </gr-checks-action>
+          </div>
+        </gr-endpoint-decorator>
+      `
+    );
+  });
+
+  test('renderLink returns undefined when url is empty, whitespace, or missing', async () => {
+    element.result = {...checkRun6, ...checkRun6.results![0]} as RunResult;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="header-content">
+          <div class="links"></div>
+          <div class="links">
+            <a
+              href="https://google.com"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              <gr-icon class="link" icon="download"> </gr-icon>
+              <span> Download </span>
+            </a>
+          </div>
+          <div class="links"></div>
         </div>
+        <gr-endpoint-decorator name="check-result-expanded">
+          <gr-endpoint-param name="run"> </gr-endpoint-param>
+          <gr-endpoint-param name="result"> </gr-endpoint-param>
+          <gr-formatted-text class="message"> </gr-formatted-text>
+        </gr-endpoint-decorator>
+        <gr-endpoint-decorator name="check-result-feedback">
+          <gr-endpoint-param name="result"> </gr-endpoint-param>
+        </gr-endpoint-decorator>
       `
     );
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index a9a4147..15f4bd4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -228,7 +228,20 @@
     );
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    super.willUpdate(changedProperties);
+    if (changedProperties.has('run') && this.run) {
+      if (
+        this.run.status === RunStatus.RUNNING ||
+        this.run.status === RunStatus.SCHEDULED
+      ) {
+        this.shouldRender = true;
+      }
+    }
+  }
+
   override firstUpdated() {
+    if (this.shouldRender) return;
     assertIsDefined(this.chipElement, 'chip element');
     whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
   }
@@ -346,8 +359,10 @@
     if (this.run.status !== RunStatus.RUNNING) return;
     if (!this.run.finishedTimestamp) return;
     const now = new Date();
-    if (this.run.finishedTimestamp.getTime() < now.getTime()) return;
-    const eta = durationString(new Date(), this.run.finishedTimestamp);
+    const finished = new Date(this.run.finishedTimestamp);
+    if (isNaN(finished.getTime())) return;
+    if (finished.getTime() < now.getTime()) return;
+    const eta = durationString(new Date(), finished);
     return html`<span class="eta">ETA: ${eta}</span>`;
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 88a1b0b..7fe60a9 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -199,6 +199,29 @@
     );
   });
 
+  test('renders running check immediately', async () => {
+    element.run = {
+      ...checkRun0,
+      status: RunStatus.RUNNING,
+    };
+    await element.updateComplete;
+    assert.isTrue(element.shouldRender);
+  });
+
+  test('renders running check with number finishedTimestamp without crashing', async () => {
+    element.run = {
+      ...checkRun0,
+      status: RunStatus.RUNNING,
+      // 10 seconds in the future
+      finishedTimestamp: (new Date().getTime() + 10000) as unknown as Date,
+    };
+    await element.updateComplete;
+    assert.isTrue(element.shouldRender);
+    const eta = element.shadowRoot?.querySelector('.eta');
+    assert.isOk(eta);
+    assert.include(eta?.textContent, 'ETA:');
+  });
+
   test('renders checkRun0', async () => {
     element.shouldRender = true;
     element.run = checkRun0;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tag.ts b/polygerrit-ui/app/elements/checks/gr-checks-tag.ts
new file mode 100644
index 0000000..4cce3fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tag.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {Tag} from '../../api/checks';
+import '../shared/gr-tooltip-content/gr-tooltip-content';
+
+@customElement('gr-checks-tag')
+export class GrChecksTag extends LitElement {
+  @property({type: Object})
+  tag?: Tag;
+
+  static override get styles() {
+    return [
+      css`
+        .tag {
+          color: var(--primary-text-color);
+          display: inline-block;
+          border-radius: 20px;
+          background-color: var(--tag-background);
+          padding: 0 var(--spacing-m);
+          margin-left: var(--spacing-s);
+          cursor: pointer;
+          border: none;
+        }
+        .tag.gray {
+          background-color: var(--tag-gray);
+        }
+        .tag.yellow {
+          background-color: var(--tag-yellow);
+        }
+        .tag.pink {
+          background-color: var(--tag-pink);
+        }
+        .tag.purple {
+          background-color: var(--tag-purple);
+        }
+        .tag.cyan {
+          background-color: var(--tag-cyan);
+        }
+        .tag.brown {
+          background-color: var(--tag-brown);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.tag) return nothing;
+    return html`<gr-tooltip-content
+      has-tooltip
+      ?position-below=${true}
+      title=${this.tag.tooltip ??
+      'A category tag for this check result. Click to filter.'}
+    >
+      <button class="tag ${this.tag.color}">
+        <span>${this.tag.name}</span>
+      </button>
+    </gr-tooltip-content>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-tag': GrChecksTag;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index ef9c082..c9d3b1a 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -13,10 +13,14 @@
   createFixAction,
   createPleaseFixComment,
   iconFor,
+  reportAiAgentCommentDraft,
+  reportAiAgentGetAIFix,
+  reportAiAgentSuggestionCopy,
 } from '../../models/checks/checks-util';
 import {modifierPressed} from '../../utils/dom-util';
 import './gr-checks-results';
 import './gr-hovercard-run';
+import './gr-checks-tag';
 import {fontStyles} from '../../styles/gr-font-styles';
 import {Action, Category} from '../../api/checks';
 import {assertIsDefined} from '../../utils/common-util';
@@ -153,9 +157,23 @@
           position: relative;
           top: 2px;
         }
+        div.footer {
+          display: flex;
+          justify-content: space-between;
+          margin-top: var(--spacing-m);
+        }
+        div.tags {
+          display: flex;
+          justify-content: flex-start;
+          align-items: center;
+        }
         div.actions {
           display: flex;
           justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .action {
+          margin-left: var(--spacing-s);
         }
         .ai-icon-wrapper {
           margin-right: var(--spacing-s);
@@ -206,7 +224,7 @@
         </div>`
       : nothing;
     return html`
-      <div class="${cat} container font-normal">
+      <div class="${cat} container font-normal" @copy=${this.handleCopy}>
         <div class="header" @click=${this.toggleExpandedClick}>
           <div class="icon">
             <gr-icon icon=${icon.name} ?filled=${!!icon.filled}></gr-icon>
@@ -231,9 +249,8 @@
           </div>
           ${this.renderToggle()}
         </div>
-        <div class="details">
-          ${this.renderExpanded()}${this.renderActions()}
-        </div>
+        <div class="details">${this.renderExpanded()}</div>
+        <div class="footer">${this.renderTags()} ${this.renderActions()}</div>
       </div>
     `;
   }
@@ -277,6 +294,13 @@
     ></gr-checks-fix-preview>`;
   }
 
+  private renderTags() {
+    const tags = this.result?.tags ?? [];
+    return html`<div class="tags">
+      ${tags.map(tag => html`<gr-checks-tag .tag=${tag}></gr-checks-tag>`)}
+    </div>`;
+  }
+
   private renderActions() {
     return html`<div class="actions">
       ${this.renderAIFixButton()}
@@ -337,10 +361,14 @@
   private renderPleaseFixButton() {
     const action: Action = {
       name: 'Please Fix',
-      callback: () => {
-        assertIsDefined(this.result, 'result');
-        this.getCommentsModel().saveDraft(createPleaseFixComment(this.result));
-        return undefined;
+      callback: async () => {
+        const result = this.result;
+        assertIsDefined(result, 'result');
+        const savedDraft = await this.getCommentsModel().saveDraft(
+          createPleaseFixComment(result)
+        );
+        reportAiAgentCommentDraft(this.reporting, result, savedDraft.id);
+        return {};
       },
     };
     return html`
@@ -395,12 +423,18 @@
     this.isExpanded = !this.isExpanded;
   }
 
+  private handleCopy() {
+    if (!this.result) return;
+    reportAiAgentSuggestionCopy(this.reporting, this.result);
+  }
+
   private async handleAIFix(): Promise<void> {
     const codePointer = this.result?.codePointers?.[0];
     if (!this.result || !this.result.message || !codePointer || !this.isOwner)
       return;
 
     this.suggestionLoading = true;
+    reportAiAgentGetAIFix(this.reporting, this.result);
     let suggestion: FixSuggestionInfo | undefined;
     try {
       suggestion = await this.getSuggestionsService().generateSuggestedFix({
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 6fad64e..71085f2 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -3,7 +3,7 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {assert} from '@open-wc/testing';
+import {assert, waitUntil} from '@open-wc/testing';
 import {checkRun1} from '../../test/test-data-generators';
 import {RunResult} from '../../models/checks/checks-model';
 import '../../test/common-test-setup';
@@ -12,16 +12,32 @@
 import './gr-diff-check-result';
 import {GrDiffCheckResult} from './gr-diff-check-result';
 import {GrButton} from '../shared/gr-button/gr-button';
-import {suggestionsServiceToken} from '../../services/suggestions/suggestions-service';
+import {
+  SuggestionsService,
+  suggestionsServiceToken,
+} from '../../services/suggestions/suggestions-service';
 import {testResolver} from '../../test/common-test-setup';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {commentsModelToken} from '../../models/comments/comments-model';
+import {SavingState} from '../../types/common';
+import {Interaction} from '../../constants/reporting';
+import {getAppContext} from '../../services/app-context';
+import {FixId, NumericChangeId, UrlEncodedCommentId} from '../../api/rest-api';
 
 suite('gr-diff-check-result tests', () => {
   let element: GrDiffCheckResult;
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  let suggestionsService: any;
+
+  let reportingStub: sinon.SinonStub;
+  let suggestionsService: SuggestionsService;
 
   setup(async () => {
     suggestionsService = testResolver(suggestionsServiceToken);
+    reportingStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+    const checksModel = testResolver(checksModelToken);
+    checksModel.changeNum = 123 as NumericChangeId;
 
     sinon
       .stub(suggestionsService, 'isGeneratedSuggestedFixEnabled')
@@ -29,6 +45,7 @@
     stubFlags('isEnabled').returns(true);
     sinon.stub(suggestionsService, 'generateSuggestedFix').resolves({
       description: 'AI suggested fix',
+      fix_id: '1' as FixId,
       replacements: [
         {
           path: 'test/path',
@@ -89,6 +106,12 @@
             </div>
           </div>
           <div class="details">
+          </div>
+          <div class="footer">
+            <div class="tags">
+              <gr-checks-tag></gr-checks-tag>
+              <gr-checks-tag></gr-checks-tag>
+            </div>
             <div class="actions">
               <gr-checks-action
                 id="please-fix"
@@ -112,12 +135,6 @@
       /* HTML */ `
         <div class="details">
           <gr-result-expanded hidecodepointers=""></gr-result-expanded>
-          <div class="actions">
-            <gr-checks-action
-              id="please-fix"
-              context="diff-fix"
-            ></gr-checks-action>
-          </div>
         </div>
       `
     );
@@ -138,6 +155,12 @@
         category: 'ERROR',
         summary: 'Test Summary',
         message: 'Test Message',
+        isAiPowered: true,
+        externalId: JSON.stringify({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 1,
+        }),
         codePointers: [
           {
             path: 'test/path',
@@ -162,10 +185,83 @@
       // Click the AI fix button
       const aiFixButton = queryAndAssert<GrButton>(element, '#aiFixBtn');
       aiFixButton.click();
-      await element.updateComplete;
+      await waitUntil(() => element.isExpanded);
 
-      // Should be expanded after suggestion is found
-      assert.isTrue(element.isExpanded);
+      assert.isTrue(
+        reportingStub.calledWith(
+          Interaction.AI_AGENT_GET_FIX_CLICKED,
+          sinon.match({
+            agentId: 'test-agent',
+            conversationId: 'test-conv',
+            turnIndex: 1,
+          })
+        )
+      );
     });
   });
+  test('reports AI_AGENT_SUGGESTION_TO_COMMENT when please-fix clicked', async () => {
+    element.result = {
+      ...checkRun1,
+      ...checkRun1.results?.[0],
+      externalId: JSON.stringify({
+        agentId: 'test-agent',
+        conversationId: 'test-conv',
+        turnIndex: 1,
+      }),
+    } as RunResult;
+    element.isOwner = true;
+    await element.updateComplete;
+
+    const commentsModel = testResolver(commentsModelToken);
+    sinon.stub(commentsModel, 'saveDraft').resolves({
+      id: 'test-please-fix-draft-id' as UrlEncodedCommentId,
+      savingState: SavingState.OK,
+    });
+
+    const pleaseFixButton = queryAndAssert(element, '#please-fix');
+    const button = queryAndAssert<GrButton>(pleaseFixButton, 'gr-button');
+    button.click();
+
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    assert.isTrue(
+      reportingStub.calledWith(
+        Interaction.AI_AGENT_SUGGESTION_TO_COMMENT,
+        sinon.match({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 1,
+          commentId: 'test-please-fix-draft-id',
+        })
+      )
+    );
+  });
+
+  test('reports AI_AGENT_SUGGESTION_CONTENT_COPIED when container is copied', async () => {
+    element.result = {
+      ...checkRun1,
+      ...checkRun1.results?.[0],
+      externalId: JSON.stringify({
+        agentId: 'test-agent',
+        conversationId: 'test-conv',
+        turnIndex: 1,
+      }),
+    } as RunResult;
+    element.isOwner = true;
+    await element.updateComplete;
+
+    const container = queryAndAssert(element, '.container');
+    container.dispatchEvent(new Event('copy'));
+
+    assert.isTrue(
+      reportingStub.calledWith(
+        Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED,
+        sinon.match({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 1,
+        })
+      )
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 3e80bc3..665273e 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -9,11 +9,12 @@
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
+  convertToBasePatchSetNum,
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
 import {assert, assertIsDefined} from '../../../utils/common-util';
 import {
-  BasePatchSetNum,
+  AUTO_MERGE,
   BranchName,
   GroupId,
   NumericChangeId,
@@ -264,7 +265,7 @@
  *
  * @type {RegExp}
  */
-const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
+const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)(?:-(\d+))?$/;
 
 /**
  * GWT UI would use @\d+ at the end of a path to indicate linenum.
@@ -448,6 +449,9 @@
       params.basePatchNum = PARENT;
       return;
     }
+    // `0` is not a patchset, it encodes an explicitly chosen auto-merge base,
+    // so a lone `0` means "auto merge against the latest patchset".
+    if (params.basePatchNum === AUTO_MERGE) return;
     // Regexes set basePatchNum instead of patchNum when only one is
     // specified.
     if (params.patchNum === undefined) {
@@ -492,6 +496,7 @@
     return {
       leftSide: !!match[1],
       lineNum: Number(match[2]),
+      endLineNum: match[3] ? Number(match[3]) : undefined,
     };
   }
 
@@ -1379,7 +1384,7 @@
     const state: ChangeViewState = {
       repo: ctx.params[0] as RepoName,
       changeNum,
-      basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
+      basePatchNum: convertToBasePatchSetNum(ctx.params[4]),
       patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
       childView: ChangeChildView.OVERVIEW,
@@ -1420,8 +1425,11 @@
 
     this.restApiService.addRepoNameToCache(changeNum, repo);
     const [comments, drafts, change] = await Promise.all([
-      this.restApiService.getDiffComments(changeNum),
-      this.restApiService.getDiffDrafts(changeNum),
+      this.restApiService.getDiffComments(
+        changeNum,
+        /* enableContext= */ false
+      ),
+      this.restApiService.getDiffDrafts(changeNum, /* enableContext= */ false),
       this.restApiService.getChangeDetail(changeNum),
     ]);
 
@@ -1498,7 +1506,7 @@
     const state: ChangeViewState = {
       repo: ctx.params[0] as RepoName,
       changeNum,
-      basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
+      basePatchNum: convertToBasePatchSetNum(ctx.params[4]),
       patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
       childView: ChangeChildView.DIFF,
@@ -1514,6 +1522,9 @@
     if (address) {
       state.diffView!.leftSide = address.leftSide;
       state.diffView!.lineNum = address.lineNum;
+      if (address.endLineNum !== undefined) {
+        state.diffView!.endLineNum = address.endLineNum;
+      }
     }
     this.reporting.setRepoName(state.repo ?? '');
     this.reporting.setChangeId(changeNum);
@@ -1579,7 +1590,7 @@
     const state: ChangeViewState = {
       repo: project,
       changeNum,
-      basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
+      basePatchNum: convertToBasePatchSetNum(ctx.params[4]),
       patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
       childView: ChangeChildView.OVERVIEW,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 95a0b32..da30caf 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -18,6 +18,7 @@
 import {GrRouter, routerToken} from './gr-router';
 import {GerritView} from '../../../services/router/router-model';
 import {
+  AUTO_MERGE,
   BasePatchSetNum,
   NumericChangeId,
   PARENT,
@@ -119,6 +120,18 @@
       assert.isOk(actual);
       assert.equal(actual.lineNum, 77);
       assert.isTrue(actual.leftSide);
+
+      actual = router.parseLineAddress('50-156');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 50);
+      assert.equal(actual.endLineNum, 156);
+      assert.isFalse(actual.leftSide);
+
+      actual = router.parseLineAddress('b50-156');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 50);
+      assert.equal(actual.endLineNum, 156);
+      assert.isTrue(actual.leftSide);
     });
   });
 
@@ -253,6 +266,13 @@
         assert.equal(params.patchNum, 4 as RevisionPatchSetNum);
       });
 
+      test('lone AUTO_MERGE is kept as the base', () => {
+        const params: PatchRangeParams = {basePatchNum: AUTO_MERGE};
+        router.normalizePatchRangeParams(params);
+        assert.equal(params.basePatchNum, AUTO_MERGE);
+        assert.isUndefined(params.patchNum);
+      });
+
       test('range n.. normalizes to n', () => {
         const params: PatchRangeParams = {basePatchNum: 4 as BasePatchSetNum};
         router.normalizePatchRangeParams(params);
@@ -930,6 +950,18 @@
           basePatchNum: 4,
           patchNum: 7,
         });
+        // `0` encodes an explicitly chosen auto-merge base.
+        await checkUrlToState('/c/test-project/+/42/0..7', {
+          ...createChangeViewState(),
+          basePatchNum: AUTO_MERGE,
+          patchNum: 7,
+        });
+        // A lone `0` means auto merge against the latest patchset.
+        await checkUrlToState('/c/test-project/+/42/0', {
+          ...createChangeViewState(),
+          basePatchNum: AUTO_MERGE,
+          patchNum: undefined,
+        });
         await checkUrlToState(
           '/c/test-project/+/42/4..7?tab=checks&filter=fff&attempt=1&checksRunsSelected=asdf,qwer&checksResultsFilter=asdf.*qwer',
           {
@@ -963,6 +995,13 @@
       suite('handleDiffRoute', () => {
         test('DIFF', async () => {
           // DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
+          // `0` encodes an explicitly chosen auto-merge base.
+          await checkUrlToState('/c/test-project/+/42/0..7/foo/bar/baz', {
+            ...createDiffViewState(),
+            basePatchNum: AUTO_MERGE,
+            patchNum: 7 as RevisionPatchSetNum,
+            diffView: {path: 'foo/bar/baz'},
+          });
           await checkUrlToState('/c/test-project/+/42/4..7/foo/bar/baz#b44', {
             ...createDiffViewState(),
             basePatchNum: 4 as BasePatchSetNum,
@@ -1032,6 +1071,29 @@
             `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
+
+        test('COMMENT route passes enableContext=false to getDiffComments and getDiffDrafts', async () => {
+          const change = createParsedChange();
+          const repo = change.project;
+          const changeNum = change._number;
+          const ps = 1 as RevisionPatchSetNum;
+          const line = 23;
+          const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+
+          stubRestApi('getChangeDetail').resolves(change);
+          const diffCommentsStub = stubRestApi('getDiffComments').resolves({
+            filepath: [{...createComment(), id, patch_set: ps, line}],
+          });
+          const diffDraftsStub = stubRestApi('getDiffDrafts').resolves({});
+
+          await checkRedirect(
+            `/c/${repo}/+/${changeNum}/comment/${id}/`,
+            `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
+          );
+
+          assert.isTrue(diffCommentsStub.calledWith(changeNum, false));
+          assert.isTrue(diffDraftsStub.calledWith(changeNum, false));
+        });
       });
 
       test('DIFF_EDIT', async () => {
@@ -1090,6 +1152,17 @@
           patchNum: 3 as RevisionPatchSetNum,
           edit: true,
         });
+        // `0` encodes an explicitly chosen auto-merge base.
+        await checkUrlToState('/c/foo/bar/+/1234/0..3,edit', {
+          ...createChangeViewState(),
+          repo: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.OVERVIEW,
+          basePatchNum: AUTO_MERGE,
+          patchNum: 3 as RevisionPatchSetNum,
+          edit: true,
+        });
         await checkUrlToState('/c/foo/bar/+/1234,edit', {
           ...createChangeViewState(),
           repo: 'foo/bar' as RepoName,
diff --git a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
index 852e549..35eb92c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
@@ -61,6 +61,7 @@
   'has:attention',
   'has:draft',
   'has:edit',
+  'has:hashtag',
   'has:star',
   'has:unresolved',
   'hasfooter:',
@@ -90,6 +91,7 @@
   'message:',
   'onlyexts:',
   'onlyextensions:',
+  'onlypaths:',
   'owner:',
   'ownerin:',
   'parentof:',
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index bfdde07..a9284c5 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -29,7 +29,11 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {assert} from '../../../utils/common-util';
 import {resolve} from '../../../models/dependency';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  createApplyFixUrl,
+} from '../../../models/views/change';
+
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
@@ -114,6 +118,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -481,16 +487,22 @@
       });
     }
     if (res?.ok) {
+      const currentChildView = this.getViewModel().getState()?.childView;
+      const filePath =
+        fixSuggestion.replacements[0]?.path ??
+        this.currentPreviews[0]?.filepath;
       this.getNavigation().setUrl(
-        createChangeUrl({
+        createApplyFixUrl({
           change,
-          patchNum: EDIT,
           basePatchNum: patchNum as BasePatchSetNum,
           forceReload: !this.hasEdit,
+          filePath,
+          currentChildView,
         })
       );
       this.close(true);
     }
+
     this.isApplyFixLoading = false;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 1d35430..21ce8ed 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -14,12 +14,15 @@
 import {GrApplyFixDialog} from './gr-apply-fix-dialog';
 import {PatchSetNum, PatchSetNumber} from '../../../types/common';
 import {
+  createChangeViewState,
+  createDiffViewState,
   createFixSuggestionInfo,
   createParsedChange,
   createRange,
   createRevisions,
   getCurrentRevision,
 } from '../../../test/test-data-generators';
+import {changeViewModelToken} from '../../../models/views/change';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -227,4 +230,70 @@
       '/c/test-project/+/42/2..edit?forceReload=true'
     );
   });
+
+  suite('handleApplyFix navigation', () => {
+    setup(() => {
+      stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response(null, {status: 200}))
+      );
+    });
+
+    test('navigates to createDiffUrl when in Diff View', async () => {
+      testResolver(changeViewModelToken).setState(createDiffViewState());
+      const fixDetail: OpenFixPreviewEventDetail = {
+        patchNum: 2 as PatchSetNum,
+        fixSuggestions: [
+          {
+            ...createFixSuggestionInfo('fix_1'),
+            replacements: [
+              {
+                path: 'file1.txt',
+                replacement: 'new content',
+                range: createRange(),
+              },
+            ],
+          },
+        ],
+        onCloseFixPreviewCallbacks: [],
+      };
+      await open(fixDetail);
+
+      await element.handleApplyFix(new CustomEvent('confirm'));
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/2..edit/file1.txt?forceReload=true'
+      );
+    });
+
+    test('navigates to createChangeUrl when in Change View', async () => {
+      testResolver(changeViewModelToken).setState(createChangeViewState());
+      const fixDetail: OpenFixPreviewEventDetail = {
+        patchNum: 2 as PatchSetNum,
+        fixSuggestions: [
+          {
+            ...createFixSuggestionInfo('fix_1'),
+            replacements: [
+              {
+                path: 'file1.txt',
+                replacement: 'new content',
+                range: createRange(),
+              },
+            ],
+          },
+        ],
+        onCloseFixPreviewCallbacks: [],
+      };
+      await open(fixDetail);
+
+      await element.handleApplyFix(new CustomEvent('confirm'));
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/2..edit?forceReload=true'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 907c764..8dcdc63 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-comment-thread/gr-comment-thread';
+import type {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import '../../checks/gr-diff-check-result';
 import '../../../embed/diff/gr-diff/gr-diff';
 import {
@@ -11,8 +12,12 @@
   isImageDiff,
   isLineUnchanged,
 } from '../../../utils/diff-util';
+import {isMagicPath} from '../../../utils/path-list-util';
 import {getAppContext} from '../../../services/app-context';
 import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  findEdit,
   getParentIndex,
   isAParent,
   isMergeParent,
@@ -35,17 +40,28 @@
   PARENT,
   PatchRange,
   PatchSetNum,
+  PatchSetNumber,
+  PreferencesInfo,
   RepoName,
   RevisionPatchSetNum,
 } from '../../../types/common';
 import {
+  GrDiffGroup,
+  GrDiffGroupType,
+} from '../../../embed/diff/gr-diff/gr-diff-group';
+import {
   DiffInfo,
   DiffPreferencesInfo,
   IgnoreWhitespaceType,
   WebLinkInfo,
 } from '../../../types/diff';
 import {GrDiff} from '../../../embed/diff/gr-diff/gr-diff';
-import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {
+  ChangeStatus,
+  CommentSide,
+  DiffViewMode,
+  Side,
+} from '../../../constants/constants';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
@@ -56,8 +72,9 @@
   waitForEventOnce,
 } from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
 import {
@@ -71,6 +88,7 @@
   RangeSelectedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
+import {DiffDetails} from '../../../api/annotation';
 import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -94,6 +112,12 @@
 } from '../../../utils/async-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {userModelToken} from '../../../models/user/user-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {
+  changeViewModelToken,
+  createApplyFixUrl,
+} from '../../../models/views/change';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {keyed} from 'lit/directives/keyed.js';
 import {repeat} from 'lit/directives/repeat.js';
@@ -101,7 +125,12 @@
 import {Shortcut} from '../../lit/shortcut-controller';
 import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
 import {toComment} from '../../../models/checks/checks-util';
-import {lineNumberToNumber} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {
+  createRevertFixSuggestion,
+  getContentGroups,
+  getRevertedFileContent,
+  lineNumberToNumber,
+} from '../../../embed/diff/gr-diff/gr-diff-utils';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -220,6 +249,9 @@
   @property({type: Boolean})
   showLoadFailure?: boolean;
 
+  @property({type: Boolean})
+  disabledThreads = false;
+
   @state()
   private loggedIn = false;
 
@@ -287,6 +319,10 @@
   @state()
   private layers: DiffLayer[] = [];
 
+  private layersComputedWithPlugins = false;
+
+  private enableTokenHighlight?: boolean;
+
   @state()
   private renderPrefs: RenderPreferences = {
     num_lines_rendered_at_once: 128,
@@ -305,8 +341,22 @@
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getChangeViewModel = resolve(this, changeViewModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
+  @state()
+  editMode = false;
+
+  @state()
+  latestPatchNum?: PatchSetNumber;
+
+  private isReverting = false;
+
   // visible for testing
   readonly reporting = getAppContext().reportingService;
 
@@ -352,6 +402,12 @@
         this.reload(false);
       }
     });
+    this.addEventListener(
+      'revert-delta',
+      (e: CustomEvent<{group: GrDiffGroup; onComplete?: () => void}>) => {
+        this.handleRevertDelta(e.detail.group, e.detail.onComplete);
+      }
+    );
     subscribe(
       this,
       () => this.getBrowserModel().diffViewMode$,
@@ -376,6 +432,53 @@
         this.prefs = diffPreferences;
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      editMode => (this.editMode = editMode)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      latestPatchNum => (this.latestPatchNum = latestPatchNum)
+    );
+    subscribe(
+      this,
+      () => this.getPluginLoader().pluginsModel.pluginsLoaded$,
+      async pluginsLoaded => {
+        if (pluginsLoaded) {
+          await this.computeLayersWithPlugins();
+        }
+      }
+    );
+  }
+
+  // visible for testing
+  isRevertAllowed(): boolean {
+    if (!this.loggedIn) return false;
+    if (
+      this.patchRange?.basePatchNum === undefined ||
+      !isAParent(this.patchRange.basePatchNum)
+    ) {
+      return false;
+    }
+    if (isMagicPath(this.path) || this.path === 'project.config') return false;
+    if (this.change?.branch === 'refs/meta/config') return false;
+    if (this.diff?.binary || isImageDiff(this.diff)) return false;
+    if (
+      this.change?.status === ChangeStatus.MERGED ||
+      this.change?.status === ChangeStatus.ABANDONED
+    ) {
+      return false;
+    }
+    const isEditMode = this.editMode || this.patchRange?.patchNum === EDIT;
+    if (!isEditMode) return false;
+    const patchNum = this.patchRange?.patchNum;
+    if (patchNum === EDIT) return true;
+    const latestPatchNum =
+      this.latestPatchNum ??
+      computeLatestPatchNum(computeAllPatchSets(this.change));
+    return patchNum !== undefined && patchNum === latestPatchNum;
   }
 
   override connectedCallback() {
@@ -407,13 +510,16 @@
     if (
       changedProperties.has('changeComments') ||
       changedProperties.has('patchRange') ||
-      changedProperties.has('file')
+      changedProperties.has('file') ||
+      changedProperties.has('disabledThreads')
     ) {
-      this.threads = this.computeFileThreads(
-        this.changeComments,
-        this.patchRange,
-        this.file
-      );
+      this.threads = this.disabledThreads
+        ? []
+        : this.computeFileThreads(
+            this.changeComments,
+            this.patchRange,
+            this.file
+          );
     }
     if (
       changedProperties.has('noRenderOnPrefsChange') ||
@@ -446,6 +552,18 @@
     }
   }
 
+  override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    const pathChanged = changedProperties.has('path');
+    const diffElementChanged =
+      this._layersComputedForDiffElement !== this.diffElement;
+    const pluginsLoaded =
+      this.getPluginLoader().pluginsModel.getState().pluginsLoaded;
+    if ((pathChanged || diffElementChanged) && pluginsLoaded) {
+      this.computeLayersWithPlugins();
+    }
+  }
+
   async waitForReloadToRender(): Promise<void> {
     await this.updateComplete;
     if (this.reloadPromise) {
@@ -471,6 +589,14 @@
     }
   }
 
+  async autoSaveDrafts(): Promise<void> {
+    const threadElements = Array.from(
+      this.shadowRoot?.querySelectorAll<GrCommentThread>('gr-comment-thread') ??
+        []
+    );
+    await Promise.all(threadElements.map(thread => thread.autoSave()));
+  }
+
   override render() {
     const showNewlineWarningLeft =
       this.hasTrailingNewlines(this.diff, true) === false;
@@ -479,6 +605,10 @@
     const useNewImageDiffUi = this.flags.isEnabled(
       KnownExperimentId.NEW_IMAGE_DIFF_UI
     );
+    const renderPrefs: RenderPreferences = {
+      ...this.renderPrefs,
+      is_edit_mode: this.isRevertAllowed(),
+    };
 
     return keyed(
       this.grDiffKey,
@@ -489,7 +619,7 @@
         .path=${this.path}
         .prefs=${this.prefs}
         .noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
-        .renderPrefs=${this.renderPrefs}
+        .renderPrefs=${renderPrefs}
         .lineWrapping=${this.lineWrapping}
         .viewMode=${this.viewMode}
         .lineOfInterest=${this.lineOfInterest}
@@ -523,13 +653,45 @@
     );
   }
 
+  private _layersComputedForPath?: string;
+
+  private _layersComputedForDiffElement?: GrDiff;
+
+  private calculateEnableTokenHighlight(prefs?: PreferencesInfo): boolean {
+    return !prefs?.disable_token_highlighting;
+  }
+
+  private async computeLayersWithPlugins(): Promise<void> {
+    if (
+      this._layersComputedForPath !== this.path ||
+      this._layersComputedForDiffElement !== this.diffElement
+    ) {
+      this.layersComputedWithPlugins = false;
+      this._layersComputedForPath = this.path;
+      this._layersComputedForDiffElement = this.diffElement;
+    }
+
+    if (!this.path || !this.diffElement || this.layersComputedWithPlugins) {
+      return;
+    }
+    if (this.enableTokenHighlight === undefined) {
+      const prefs = await this.restApiService.getPreferences();
+      this.enableTokenHighlight = this.calculateEnableTokenHighlight(prefs);
+    }
+    this.layers = this.getLayers(this.enableTokenHighlight);
+    this.layersComputedWithPlugins = true;
+  }
+
   async initLayers() {
     const preferencesPromise = this.restApiService.getPreferences();
     const prefs = await preferencesPromise;
-    const enableTokenHighlight = !prefs?.disable_token_highlighting;
+    this.enableTokenHighlight = this.calculateEnableTokenHighlight(prefs);
 
     assertIsDefined(this.path, 'path');
-    this.layers = this.getLayers(enableTokenHighlight);
+    this.layers = this.getLayers(this.enableTokenHighlight);
+    this.layersComputedWithPlugins =
+      !!this.diffElement &&
+      this.getPluginLoader().pluginsModel.getState().pluginsLoaded;
     this.coverageRanges = [];
     // We kick off fetching the data here, but we don't return the promise,
     // so awaiting initLayers() will not wait for coverage data to be
@@ -668,6 +830,26 @@
     };
   }
 
+  private get diffDetails(): DiffDetails | undefined {
+    if (
+      !this.change ||
+      !this.patchRange ||
+      !this.file ||
+      !this.path ||
+      !this.diffElement
+    ) {
+      return undefined;
+    }
+    return {
+      change: this.change,
+      basePatchNum: this.patchRange.basePatchNum,
+      patchNum: this.patchRange.patchNum,
+      fileRange: this.file,
+      path: this.path,
+      diffElement: this.diffElement,
+    };
+  }
+
   private getLayers(enableTokenHighlight: boolean): DiffLayer[] {
     const layers = [];
     if (enableTokenHighlight) {
@@ -690,12 +872,29 @@
         })
       );
     }
+    // Add layers from plugins
+    const details = this.diffDetails;
+    if (details) {
+      for (const plugin of this.getPluginLoader().pluginsModel.getState()
+        .diffLayerPlugins) {
+        try {
+          layers.push(plugin.factory(details));
+        } catch (e) {
+          console.error(
+            `Error creating diff layer from plugin ${plugin.pluginName}:`,
+            e
+          );
+        }
+      }
+    }
     layers.push(this.syntaxLayer);
     return layers;
   }
 
   clear() {
     this.layers = [];
+    this.layersComputedWithPlugins = false;
+    this.enableTokenHighlight = undefined;
   }
 
   /**
@@ -1268,6 +1467,135 @@
     if (!lines) return null;
     return lines[lines.length - 1] === '';
   }
+
+  async handleRevertDelta(group: GrDiffGroup, onComplete?: () => void) {
+    if (!this.changeNum || !this.patchRange || !this.path) {
+      onComplete?.();
+      return;
+    }
+    if (!this.isRevertAllowed()) {
+      onComplete?.();
+      return;
+    }
+    if (this.isReverting) {
+      onComplete?.();
+      return;
+    }
+
+    this.reporting.reportInteraction(Interaction.REVERT_DELTA_CLICKED, {
+      path: this.path,
+    });
+    this.reporting.time(Timing.REVERT_DELTA_LOAD);
+
+    const allGroups = this.diffElement?.groups ?? [];
+    const fixSuggestion = createRevertFixSuggestion(
+      this.path,
+      group,
+      allGroups
+    );
+    const revertedContent = getRevertedFileContent(group, allGroups);
+    const contentGroups = getContentGroups(allGroups);
+    const hasOtherDeltas = contentGroups.some(
+      g =>
+        g !== group &&
+        g.type === GrDiffGroupType.DELTA &&
+        !g.ignoredWhitespaceOnly
+    );
+    const isEditPatchset = this.patchRange?.patchNum === EDIT;
+    const hasEdit =
+      !!findEdit(Object.values(this.change?.revisions ?? {})) || isEditPatchset;
+
+    let patchNum: RevisionPatchSetNum | undefined = this.patchRange.patchNum;
+    if (patchNum === EDIT) {
+      const editRev = findEdit(Object.values(this.change?.revisions ?? {}));
+      patchNum =
+        (editRev?.basePatchNum as PatchSetNumber | undefined) ??
+        this.latestPatchNum ??
+        computeLatestPatchNum(computeAllPatchSets(this.change));
+    }
+
+    const canDirectSave = isEditPatchset && revertedContent !== undefined;
+    if (!canDirectSave && (!fixSuggestion || patchNum === undefined)) {
+      this.reporting.timeEnd(Timing.REVERT_DELTA_LOAD, {
+        success: false,
+        reason: !fixSuggestion ? 'no-fix-suggestion' : 'no-patch-num',
+      });
+      onComplete?.();
+      return;
+    }
+
+    let strategy = 'apply-fix';
+    const saveRevertedEdit = (isFallback = false) => {
+      if (this.diff?.change_type === 'ADDED' && !hasOtherDeltas) {
+        strategy = 'delete-file';
+        return this.restApiService.deleteFileInChangeEdit(
+          this.changeNum!,
+          this.path!,
+          throwingErrorCallback
+        );
+      }
+      strategy = isFallback ? 'apply-fix-fallback' : 'edit-save';
+      return this.restApiService.saveChangeEdit(
+        this.changeNum!,
+        this.path!,
+        revertedContent!,
+        throwingErrorCallback
+      );
+    };
+
+    this.isReverting = true;
+    fireAlert(this, 'Reverting change...');
+    let res: Response | undefined;
+    try {
+      try {
+        if (canDirectSave) {
+          res = await saveRevertedEdit();
+        } else if (fixSuggestion && patchNum !== undefined) {
+          try {
+            res = await this.restApiService.applyFixSuggestion(
+              this.changeNum,
+              patchNum,
+              fixSuggestion.replacements,
+              undefined,
+              throwingErrorCallback
+            );
+          } catch (applyError) {
+            if (revertedContent === undefined) throw applyError;
+            res = await saveRevertedEdit(true);
+          }
+        }
+      } catch (error) {
+        const errorText = error instanceof Error ? error.message : '';
+        fireAlert(this, `Reverting change failed: ${errorText}`);
+      }
+      // Must run before setUrl(): navigation calls
+      // reporting.beforeLocationChanged(), which drops pending timers.
+      this.reporting.timeEnd(Timing.REVERT_DELTA_LOAD, {
+        success: res?.ok ?? false,
+        status: res?.status,
+        strategy,
+      });
+      if (!res?.ok) return;
+      fireAlert(this, 'Change reverted.');
+      const currentChildView = this.getChangeViewModel().getState()?.childView;
+      this.getNavigation().setUrl(
+        createApplyFixUrl({
+          change: this.change,
+          changeNum: this.changeNum,
+          repo: this.change?.project ?? this.projectName ?? ('' as RepoName),
+          basePatchNum: PARENT,
+          patchNum: EDIT,
+          forceReload: !hasEdit,
+          filePath: this.path,
+          currentChildView,
+        })
+      );
+      await this.reload(true);
+    } finally {
+      this.isReverting = false;
+      onComplete?.();
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_screenshot_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_screenshot_test.ts
new file mode 100644
index 0000000..81484e8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_screenshot_test.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-host';
+import {GrDiffHost} from './gr-diff-host';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-expect-error
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {stubRestApi, visualDiffDarkTheme} from '../../../test/test-utils';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {
+  createChange,
+  createPatchRange,
+} from '../../../test/test-data-generators';
+import {EDIT, NumericChangeId} from '../../../types/common';
+import {DiffInfo, DiffViewMode} from '../../../api/diff';
+
+suite('gr-diff-host screenshot tests', () => {
+  let element: GrDiffHost;
+
+  setup(async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'sample.ts',
+        content_type: 'application/typescript',
+        lines: 10,
+      },
+      meta_b: {
+        name: 'sample.ts',
+        content_type: 'application/typescript',
+        lines: 10,
+      },
+      change_type: 'MODIFIED',
+      intraline_status: 'OK',
+      content: [
+        {
+          ab: [
+            '// Copyright 2026 Google LLC',
+            'import {LitElement, html} from "lit";',
+            '',
+          ],
+        },
+        {
+          a: [
+            'export function calculateSum(a: number, b: number): number {',
+            '  return a + b;',
+            '}',
+          ],
+          b: [
+            'export function calculateSum(x: number, y: number): number {',
+            '  // Updated implementation',
+            '  return x + y;',
+            '}',
+          ],
+        },
+        {
+          ab: [
+            '',
+            'export function helper(): void {',
+            '  console.log("ready");',
+          ],
+        },
+        {
+          a: ['  console.log("old debug line");'],
+        },
+        {
+          ab: ['}'],
+        },
+        {
+          b: ['', '// Added at end of file', 'export const VERSION = 2;'],
+        },
+      ],
+    };
+
+    stubRestApi('getDiff').resolves(diff);
+    element = await fixture<GrDiffHost>(html`<gr-diff-host
+      .changeNum=${42 as NumericChangeId}
+      .path=${'sample.ts'}
+      .change=${createChange()}
+      .patchRange=${{
+        ...createPatchRange(),
+        patchNum: EDIT,
+      }}
+      .prefs=${createDefaultDiffPrefs()}
+    ></gr-diff-host>`);
+    await element.reload(true);
+    await element.updateComplete;
+  });
+
+  test('edit mode diff with revert buttons', async () => {
+    await visualDiff(element, 'gr-diff-host-edit-mode-revert');
+    await visualDiffDarkTheme(element, 'gr-diff-host-edit-mode-revert');
+  });
+
+  test('unified edit mode diff with revert buttons', async () => {
+    element.viewMode = DiffViewMode.UNIFIED;
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-diff-host-edit-mode-revert-unified');
+    await visualDiffDarkTheme(element, 'gr-diff-host-edit-mode-revert-unified');
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 00a627b..0022d8c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -7,6 +7,7 @@
 import '../../../test/common-test-setup';
 import './gr-diff-host';
 import {
+  ChangeStatus,
   CommentSide,
   createDefaultDiffPrefs,
   Side,
@@ -18,7 +19,9 @@
   createComment,
   createCommentThread,
   createDiff,
+  createEditRevision,
   createPatchRange,
+  createRevision,
   createRunResult,
 } from '../../../test/test-data-generators';
 import {
@@ -34,6 +37,7 @@
   Base64ImageFile,
   BasePatchSetNum,
   BlameInfo,
+  BranchName,
   CommentRange,
   CommentThread,
   DraftInfo,
@@ -41,17 +45,31 @@
   NumericChangeId,
   PARENT,
   PatchSetNum,
+  PatchSetNumber,
+  RevisionInfo,
   RevisionPatchSetNum,
 } from '../../../types/common';
 import {CoverageType} from '../../../types/types';
 import {GrDiffHost} from './gr-diff-host';
-import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
+import {
+  DiffInfo,
+  DiffViewMode,
+  GrDiffLineType,
+  IgnoreWhitespaceType,
+} from '../../../api/diff';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+} from '../../../embed/diff/gr-diff/gr-diff-group';
+import {GrDiffLine} from '../../../embed/diff/gr-diff/gr-diff-line';
+import {Interaction, Timing} from '../../../constants/reporting';
 import {ErrorCallback} from '../../../api/rest';
 import {SinonStub, SinonStubbedMember} from 'sinon';
 import {RunResult} from '../../../models/checks/checks-model';
 import {assertIsDefined} from '../../../utils/common-util';
 import {assert, fixture, html} from '@open-wc/testing';
 import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -60,6 +78,7 @@
   CommentsModel,
   commentsModelToken,
 } from '../../../models/comments/comments-model';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 suite('gr-diff-host tests', () => {
   let element: GrDiffHost;
@@ -850,6 +869,43 @@
         `
       );
     });
+
+    test('threads are cleared when disabledThreads is true and restored when false', async () => {
+      const thread: CommentThread = {
+        ...createCommentThread([createComment()]),
+      };
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      sinon.stub(element as any, 'computeFileThreads').returns([thread]);
+
+      element.disabledThreads = false;
+      element.threads = [thread];
+      await element.updateComplete;
+      assert.equal(element.threads.length, 1);
+
+      element.disabledThreads = true;
+      await element.updateComplete;
+      assert.equal(element.threads.length, 0);
+
+      element.disabledThreads = false;
+      await element.updateComplete;
+      assert.equal(element.threads.length, 1);
+    });
+
+    test('autoSaveDrafts calls autoSave on all comment threads', async () => {
+      const thread: CommentThread = {
+        ...createCommentThread([createComment()]),
+      };
+      element.threads = [thread];
+      await element.updateComplete;
+
+      const threadEl = element.shadowRoot!.querySelector('gr-comment-thread');
+      assert.isNotNull(threadEl);
+      const autoSaveStub = sinon.stub(threadEl, 'autoSave').resolves();
+
+      await element.autoSaveDrafts();
+
+      assert.isTrue(autoSaveStub.calledOnce);
+    });
   });
 
   suite('render check elements', () => {
@@ -1474,4 +1530,725 @@
       });
     });
   });
+
+  suite('computeLayersWithPlugins and token highlighting', () => {
+    let getPreferencesStub: sinon.SinonStub;
+    setup(() => {
+      getPreferencesStub = stubRestApi('getPreferences');
+    });
+    test('initLayers sets enableTokenHighlight correctly', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      await element.initLayers();
+      assert.isFalse(element['enableTokenHighlight']);
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: false})
+      );
+      await element.initLayers();
+      assert.isTrue(element['enableTokenHighlight']);
+      getPreferencesStub.returns(Promise.resolve(undefined));
+      await element.initLayers();
+      assert.isTrue(element['enableTokenHighlight']);
+    });
+    test('clear resets enableTokenHighlight', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      await element.initLayers();
+      assert.isFalse(element['enableTokenHighlight']);
+      element.clear();
+      assert.isUndefined(element['enableTokenHighlight']);
+    });
+    test('computeLayersWithPlugins sets enableTokenHighlight if undefined', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      element['enableTokenHighlight'] = undefined;
+      element['layersComputedWithPlugins'] = false;
+
+      await element['computeLayersWithPlugins']();
+
+      assert.isFalse(element['enableTokenHighlight']);
+      assert.isTrue(element['layersComputedWithPlugins']);
+      assert.isTrue(getPreferencesStub.calledOnce);
+    });
+    test('computeLayersWithPlugins does not fetch preferences if enableTokenHighlight is already set', async () => {
+      getPreferencesStub.returns(
+        Promise.resolve({disable_token_highlighting: true})
+      );
+      element['enableTokenHighlight'] = true;
+      element['layersComputedWithPlugins'] = false;
+
+      await element['computeLayersWithPlugins']();
+
+      assert.isTrue(element['enableTokenHighlight']);
+      assert.isTrue(element['layersComputedWithPlugins']);
+      assert.isFalse(getPreferencesStub.called);
+    });
+    test('pluginsLoaded triggers computeLayersWithPlugins', async () => {
+      // @ts-expect-error
+      const computeSpy = sinon.spy(element, 'computeLayersWithPlugins');
+
+      const pluginsModel = testResolver(pluginLoaderToken).pluginsModel;
+      pluginsModel.updateState({pluginsLoaded: false});
+      await element.updateComplete;
+
+      pluginsModel.updateState({pluginsLoaded: true});
+
+      await new Promise(resolve => setTimeout(resolve, 0));
+
+      assert.isTrue(computeSpy.called);
+    });
+  });
+
+  suite('revert change in edit mode', () => {
+    setup(() => {
+      userModel.setAccount(account);
+    });
+
+    test('is_edit_mode is passed in renderPrefs only in edit mode', async () => {
+      element.patchRange = createPatchRange();
+      await element.updateComplete;
+      const grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      await element.updateComplete;
+      assert.isTrue(grDiff?.renderPrefs?.is_edit_mode);
+    });
+
+    test('handleRevertDelta applies fix suggestion and reloads diff', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response('', {status: 200}))
+      );
+      const reloadStub = sinon.stub(element, 'reload').resolves();
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      const timeEndStub = sinon.stub(element.reporting, 'timeEnd');
+
+      await element.handleRevertDelta(group);
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.include(setUrlStub.firstCall.args[0], '/+/42/edit');
+      assert.notInclude(setUrlStub.firstCall.args[0], '..edit');
+      assert.notInclude(setUrlStub.firstCall.args[0], 'forceReload=true');
+      assert.isTrue(
+        reportStub.calledWith(Interaction.REVERT_DELTA_CLICKED, {
+          path: 'foo.ts',
+        })
+      );
+      assert.isTrue(
+        timeEndStub.calledWith(
+          Timing.REVERT_DELTA_LOAD,
+          sinon.match({success: true})
+        )
+      );
+      assert.isTrue(applyFixStub.calledOnce);
+      assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+      assert.equal(applyFixStub.firstCall.args[1], 1 as RevisionPatchSetNum);
+      assert.isUndefined(applyFixStub.firstCall.args[3]);
+      assert.equal(applyFixStub.firstCall.args[4], throwingErrorCallback);
+      assert.deepEqual(applyFixStub.firstCall.args[2], [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 10,
+            start_character: 0,
+            end_line: 10,
+            end_character: 8,
+          },
+          replacement: 'old code',
+        },
+      ]);
+      assert.isTrue(reloadStub.lastCall.calledWith(true));
+    });
+
+    test('is_edit_mode is passed in renderPrefs when editMode is true', async () => {
+      element.patchRange = createPatchRange();
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = false;
+      await element.updateComplete;
+      let grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+
+      element.editMode = true;
+      await element.updateComplete;
+      grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isTrue(grDiff?.renderPrefs?.is_edit_mode);
+    });
+
+    test('handleRevertDelta applies fix suggestion when in editMode with numeric patchset', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response('', {status: 200}))
+      );
+      sinon.stub(element, 'reload').resolves();
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      element.patchRange = createPatchRange(); // numeric patchNum: 1
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      await element.handleRevertDelta(group);
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.include(setUrlStub.firstCall.args[0], '/+/42/edit');
+      assert.notInclude(setUrlStub.firstCall.args[0], '..edit');
+      assert.include(setUrlStub.firstCall.args[0], 'forceReload=true');
+      assert.isTrue(applyFixStub.calledOnce);
+      assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+      assert.equal(applyFixStub.firstCall.args[1], 1 as RevisionPatchSetNum);
+    });
+
+    test('handleRevertDelta applies fix suggestion when patchNum is EDIT', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response('', {status: 200}))
+      );
+      sinon.stub(element, 'reload').resolves();
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.latestPatchNum = 3 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      await element.handleRevertDelta(group);
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.include(setUrlStub.firstCall.args[0], '/+/42/edit');
+      assert.notInclude(setUrlStub.firstCall.args[0], 'forceReload=true');
+      assert.isTrue(applyFixStub.calledOnce);
+      assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+      assert.equal(applyFixStub.firstCall.args[1], 3 as RevisionPatchSetNum);
+      assert.isUndefined(applyFixStub.firstCall.args[3]);
+    });
+
+    test('handleRevertDelta resolves base patchset from edit revision', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response('', {status: 200}))
+      );
+      sinon.stub(element, 'reload').resolves();
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.change = {
+        ...createChange(),
+        revisions: {
+          r1: createRevision(1),
+          r2: createRevision(2),
+          rEdit: createEditRevision(2) as unknown as RevisionInfo,
+        },
+      };
+      element.latestPatchNum = 3 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      await element.handleRevertDelta(group);
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.notInclude(setUrlStub.firstCall.args[0], 'forceReload=true');
+      assert.isTrue(applyFixStub.calledOnce);
+      assert.equal(applyFixStub.firstCall.args[0], 42 as NumericChangeId);
+      assert.equal(applyFixStub.firstCall.args[1], 2 as RevisionPatchSetNum);
+    });
+
+    test('handleRevertDelta calls onComplete callback on success', async () => {
+      stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response('', {status: 200}))
+      );
+      sinon.stub(element, 'reload').resolves();
+      sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      const onCompleteSpy = sinon.spy();
+      await element.handleRevertDelta(group, onCompleteSpy);
+
+      assert.isTrue(onCompleteSpy.calledOnce);
+    });
+
+    test('handleRevertDelta calls onComplete callback on failure', async () => {
+      stubRestApi('applyFixSuggestion').returns(
+        Promise.reject(new Error('Network error'))
+      );
+      sinon.stub(element, 'reload').resolves();
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      const onCompleteSpy = sinon.spy();
+      await element.handleRevertDelta(group, onCompleteSpy);
+
+      assert.isTrue(onCompleteSpy.calledOnce);
+    });
+
+    test('handleRevertDelta does not apply fix if not in edit mode', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion');
+      element.patchRange = createPatchRange();
+      element.editMode = false;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      await element.handleRevertDelta(group);
+
+      assert.isFalse(applyFixStub.called);
+    });
+
+    test('handleRevertDelta ignores concurrent revert calls when already reverting', async () => {
+      let resolveApplyFix: (res: Response) => void;
+      const applyFixPromise = new Promise<Response>(resolve => {
+        resolveApplyFix = resolve;
+      });
+      const applyFixStub =
+        stubRestApi('applyFixSuggestion').returns(applyFixPromise);
+      sinon.stub(element, 'reload').resolves();
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      const firstCall = element.handleRevertDelta(group);
+      const secondCall = element.handleRevertDelta(group);
+
+      resolveApplyFix!(new Response('', {status: 200}));
+      await Promise.all([firstCall, secondCall]);
+
+      assert.isTrue(applyFixStub.calledOnce);
+    });
+
+    test('is_edit_mode is false when in editMode but viewing older patchset', async () => {
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.latestPatchNum = 2 as PatchSetNumber;
+      element.editMode = true;
+      await element.updateComplete;
+
+      const grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+      assert.isFalse(element.isRevertAllowed());
+    });
+
+    test('is_edit_mode is false for commit message, merge list, and project.config', async () => {
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.editMode = true;
+
+      element.path = '/COMMIT_MSG';
+      await element.updateComplete;
+      let grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+      assert.isFalse(element.isRevertAllowed());
+
+      element.path = '/MERGE_LIST';
+      await element.updateComplete;
+      grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+      assert.isFalse(element.isRevertAllowed());
+
+      element.path = 'project.config';
+      await element.updateComplete;
+      grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+      assert.isFalse(element.isRevertAllowed());
+    });
+
+    test('is_edit_mode is false for refs/meta/config branch', async () => {
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.editMode = true;
+      element.path = 'groups';
+      element.change = {
+        ...createChange(),
+        branch: 'refs/meta/config' as BranchName,
+      };
+      await element.updateComplete;
+      const grDiff = element.shadowRoot?.querySelector('gr-diff');
+      assert.isFalse(grDiff?.renderPrefs?.is_edit_mode);
+      assert.isFalse(element.isRevertAllowed());
+    });
+
+    test('is_edit_mode is false for binary and image diffs', async () => {
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.editMode = true;
+      element.path = 'image.png';
+      element.diff = {
+        ...createDiff(),
+        binary: true,
+      };
+      await element.updateComplete;
+      assert.isFalse(element.isRevertAllowed());
+    });
+
+    test('is_edit_mode is false for merged or abandoned changes', async () => {
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.MERGED,
+      };
+      await element.updateComplete;
+      assert.isFalse(element.isRevertAllowed());
+
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.ABANDONED,
+      };
+      await element.updateComplete;
+      assert.isFalse(element.isRevertAllowed());
+    });
+
+    test('isRevertAllowed is false when logged out', async () => {
+      userModel.setAccount(undefined);
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.editMode = true;
+      element.path = 'foo.ts';
+      await element.updateComplete;
+      assert.isFalse(element.isRevertAllowed());
+    });
+
+    test('isRevertAllowed is false when basePatchNum is not a parent', async () => {
+      element.patchRange = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: EDIT,
+      };
+      element.editMode = true;
+      element.path = 'foo.ts';
+      await element.updateComplete;
+      assert.isFalse(element.isRevertAllowed());
+    });
+
+    test('handleRevertDelta uses saveChangeEdit when in EDIT mode with groups loaded', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion');
+      const saveEditStub = stubRestApi('saveChangeEdit').returns(
+        Promise.resolve(new Response(null, {status: 204}))
+      );
+      sinon.stub(element, 'reload').resolves();
+      sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+      await element.updateComplete;
+
+      const hunk1Remove = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+      hunk1Remove.text = 'const a = 1;';
+      const hunk1Add = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      hunk1Add.text = 'const a = 2;';
+      const hunk1Group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [hunk1Remove, hunk1Add],
+      });
+
+      const hunk2Remove = new GrDiffLine(GrDiffLineType.REMOVE, 2, 0);
+      hunk2Remove.text = 'const b = 1;';
+      const hunk2Add = new GrDiffLine(GrDiffLineType.ADD, 0, 2);
+      hunk2Add.text = 'const b = 2;';
+      const hunk2Group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [hunk2Remove, hunk2Add],
+      });
+
+      assertIsDefined(element.diffElement);
+      element.diffElement.groups = [hunk1Group, hunk2Group];
+
+      await element.handleRevertDelta(hunk2Group);
+
+      assert.isFalse(applyFixStub.called);
+      assert.isTrue(saveEditStub.calledOnce);
+      assert.equal(saveEditStub.firstCall.args[0], 42 as NumericChangeId);
+      assert.equal(saveEditStub.firstCall.args[1], 'foo.ts');
+      assert.equal(
+        saveEditStub.firstCall.args[2],
+        'const a = 2;\nconst b = 1;'
+      );
+    });
+
+    test('handleRevertDelta uses deleteFileInChangeEdit when reverting only delta of ADDED file', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion');
+      const deleteFileStub = stubRestApi('deleteFileInChangeEdit').returns(
+        Promise.resolve(new Response(null, {status: 204}))
+      );
+      sinon.stub(element, 'reload').resolves();
+      sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      element.patchRange = {
+        ...createPatchRange(),
+        patchNum: EDIT,
+      };
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'added.ts';
+      element.changeNum = 42 as NumericChangeId;
+      element.diff = {
+        ...createDiff(),
+        change_type: 'ADDED',
+      };
+      await element.updateComplete;
+
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = 'new file content';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+
+      assertIsDefined(element.diffElement);
+      element.diffElement.groups = [group];
+
+      await element.handleRevertDelta(group);
+
+      assert.isFalse(applyFixStub.called);
+      assert.isTrue(deleteFileStub.calledOnce);
+      assert.equal(deleteFileStub.firstCall.args[0], 42 as NumericChangeId);
+      assert.equal(deleteFileStub.firstCall.args[1], 'added.ts');
+    });
+
+    test('handleRevertDelta falls back to saveChangeEdit when applyFixSuggestion fails', async () => {
+      const applyFixStub = stubRestApi('applyFixSuggestion').callsFake(() =>
+        Promise.reject(
+          new Error('Error 409: Cannot calculate fix replacement for range')
+        )
+      );
+      const saveEditStub = stubRestApi('saveChangeEdit').returns(
+        Promise.resolve(new Response(null, {status: 204}))
+      );
+      sinon.stub(element, 'reload').resolves();
+      sinon.stub(testResolver(navigationToken), 'setUrl');
+
+      element.patchRange = createPatchRange(undefined, 1);
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+      await element.updateComplete;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+      removeLine.text = 'old code';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = 'new code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      assertIsDefined(element.diffElement);
+      element.diffElement.groups = [group];
+
+      await element.handleRevertDelta(group);
+
+      assert.isTrue(applyFixStub.calledOnce);
+      assert.isTrue(saveEditStub.calledOnce);
+      assert.equal(saveEditStub.firstCall.args[0], 42 as NumericChangeId);
+      assert.equal(saveEditStub.firstCall.args[1], 'foo.ts');
+      assert.equal(saveEditStub.firstCall.args[2], 'old code');
+    });
+
+    test('handleRevertDelta logs telemetry when fix suggestion cannot be computed', async () => {
+      const reportInteractionStub = sinon.stub(
+        element.reporting,
+        'reportInteraction'
+      );
+      const timeStub = sinon.stub(element.reporting, 'time');
+      const timeEndStub = sinon.stub(element.reporting, 'timeEnd');
+
+      element.patchRange = createPatchRange(undefined, 1);
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+      await element.updateComplete;
+
+      const invalidGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)],
+      });
+      let completed = false;
+      await element.handleRevertDelta(invalidGroup, () => {
+        completed = true;
+      });
+
+      assert.isTrue(completed);
+      assert.isTrue(reportInteractionStub.calledOnce);
+      assert.isTrue(timeStub.calledOnce);
+      assert.isTrue(
+        timeEndStub.calledWithExactly(Timing.REVERT_DELTA_LOAD, {
+          success: false,
+          reason: 'no-fix-suggestion',
+        })
+      );
+    });
+
+    test('handleRevertDelta reports REVERT_DELTA_LOAD before navigation resets timers', async () => {
+      stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response(null, {status: 200}))
+      );
+      const reloadStub = sinon.stub(element, 'reload').resolves();
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      const timeEndStub = sinon.stub(element.reporting, 'timeEnd');
+      const onCompleteSpy = sinon.spy();
+
+      element.patchRange = createPatchRange(undefined, 1);
+      element.latestPatchNum = 1 as PatchSetNumber;
+      element.editMode = true;
+      element.path = 'foo.ts';
+      element.changeNum = 42 as NumericChangeId;
+      await element.updateComplete;
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+      removeLine.text = 'old code';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine],
+      });
+
+      assertIsDefined(element.diffElement);
+      element.diffElement.groups = [group];
+
+      await element.handleRevertDelta(group, onCompleteSpy);
+
+      assert.isTrue(
+        timeEndStub.calledOnceWithExactly(Timing.REVERT_DELTA_LOAD, {
+          success: true,
+          status: 200,
+          strategy: 'apply-fix',
+        })
+      );
+      assert.isTrue(timeEndStub.calledBefore(setUrlStub));
+      assert.isTrue(timeEndStub.calledBefore(reloadStub));
+      assert.isTrue(reloadStub.calledBefore(onCompleteSpy));
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer.ts
new file mode 100644
index 0000000..be9d15b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer.ts
@@ -0,0 +1,1142 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-comment-thread/gr-comment-thread';
+import type {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {
+  CommentThread,
+  DraftInfo,
+  EDIT,
+  PARENT,
+  PatchRange,
+  PatchSetNum,
+  RevisionPatchSetNum,
+} from '../../../types/common';
+import {sanitizeHtmlToFragment} from '../../../utils/inner-html-util';
+import {resolve} from '../../../models/dependency';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {subscribe} from '../../lit/subscription-controller';
+import {createNew} from '../../../utils/comment-util';
+import {
+  getParentIndex,
+  isAParent,
+  isMergeParent,
+} from '../../../utils/patch-set-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire, fireAlert} from '../../../utils/event-util';
+import {
+  AlignedDiffRow,
+  AlignedDiffRowWithThreads,
+  alignMarkdownTokens,
+  attachThreadsToRows,
+  getThreadDiffSide,
+  parseMarkdownBlocks,
+  reconstructFileContent,
+} from './markdown-diff-util';
+
+@customElement('gr-diff-markdown-viewer')
+export class GrDiffMarkdownViewer extends LitElement {
+  @property({type: Object}) diff?: DiffInfo;
+
+  @property({type: String}) path?: string;
+
+  @property({type: Object}) patchRange?: PatchRange;
+
+  @property({type: Array}) threads?: CommentThread[];
+
+  @property({type: String}) viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  @state() alignedRows: AlignedDiffRow[] = [];
+
+  @state() private internalThreads: CommentThread[] = [];
+
+  @state() private changeComments?: ChangeComments;
+
+  @property({type: Boolean}) loggedIn = false;
+
+  @query('.selection-action-box')
+  private selectionActionBox?: HTMLElement;
+
+  private selectionActionBoxVisible = false;
+
+  private selectionBoxPositionBelow = false;
+
+  private selectionBoxTop = 0;
+
+  private selectionBoxLeft = 0;
+
+  private selectedSide?: Side;
+
+  private selectedLine?: number;
+
+  private hoveredSide?: Side;
+
+  private hoveredLine?: number;
+
+  private readonly getBrowserModel = resolve(this, browserModelToken);
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      mode => {
+        if (mode) this.viewMode = mode;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+        this.updateInternalThreads();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      loggedIn => {
+        this.loggedIn = loggedIn;
+      }
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    document.addEventListener('selectionchange', this.handleSelectionChange);
+    window.addEventListener('keydown', this.handleKeyDown);
+  }
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    document.removeEventListener('selectionchange', this.handleSelectionChange);
+    window.removeEventListener('keydown', this.handleKeyDown);
+  }
+
+  get effectiveThreads(): CommentThread[] {
+    return this.threads ?? this.internalThreads;
+  }
+
+  get filePath(): string | undefined {
+    return this.path ?? this.diff?.meta_b?.name ?? this.diff?.meta_a?.name;
+  }
+
+  private updateInternalThreads() {
+    if (!this.changeComments || !this.patchRange || !this.filePath) return;
+    this.internalThreads = this.changeComments.getThreadsBySideForFile(
+      {path: this.filePath},
+      this.patchRange
+    );
+  }
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          position: relative;
+          background-color: var(--view-background-color, #ffffff);
+          color: var(--primary-text-color, #202124);
+          font-family: var(--font-family, Roboto, sans-serif);
+          font-size: var(--font-size-normal, 14px);
+          line-height: var(--line-height-normal, 1.5);
+        }
+        .file-level-threads {
+          padding: var(--spacing-m, 12px) var(--spacing-l, 16px);
+          background-color: var(--background-color-secondary, #f8f9fa);
+          border-bottom: 1px solid var(--border-color, #e0e0e0);
+          display: flex;
+          flex-direction: column;
+          gap: var(--spacing-s, 8px);
+        }
+        .file-level-title {
+          font-size: var(--font-size-small, 12px);
+          font-weight: var(--font-weight-bold, 600);
+          color: var(--deemphasized-text-color, #5f6368);
+          text-transform: uppercase;
+        }
+        .column-headers {
+          display: grid;
+          grid-template-columns: 1fr 1fr;
+          column-gap: var(--spacing-l, 16px);
+          padding: var(--spacing-xs, 4px) var(--spacing-m, 12px);
+          border-bottom: 1px solid var(--border-color, #e0e0e0);
+          background-color: var(--background-color-secondary, #f8f9fa);
+          color: var(--deemphasized-text-color, #5f6368);
+          font-size: var(--font-size-small, 12px);
+          font-weight: var(--font-weight-bold, 600);
+          text-transform: uppercase;
+          letter-spacing: 0.5px;
+        }
+        .diff-grid {
+          display: grid;
+          grid-template-columns: 1fr 1fr;
+          column-gap: var(--spacing-l, 16px);
+          row-gap: var(--spacing-m, 12px);
+          padding: var(--spacing-m, 12px);
+        }
+        .diff-cell {
+          position: relative;
+          min-width: 0;
+          overflow-wrap: break-word;
+          padding: var(--spacing-xxs, 2px) var(--spacing-s, 8px);
+          padding-right: 90px;
+          border-left: 3px solid transparent;
+          box-sizing: border-box;
+          display: flex;
+          flex-direction: column;
+        }
+        .diff-cell.empty {
+          background-color: var(--diff-blank-background-color, transparent);
+          min-height: 24px;
+        }
+        .diff-cell.added,
+        .diff-cell.modified-right {
+          border-left-color: var(--positive-green-text-color, #2da44e);
+          background-color: var(--light-add-highlight-color, #d8fed8);
+          border-radius: 0 4px 4px 0;
+        }
+        .diff-cell.deleted,
+        .diff-cell.modified-left {
+          border-left-color: var(--negative-red-text-color, #cf222e);
+          background-color: var(--light-remove-highlight-color, #ffebee);
+          border-radius: 0 4px 4px 0;
+        }
+        .unified-container {
+          box-sizing: border-box;
+          max-width: 100%;
+          padding: var(--spacing-m, 12px) var(--spacing-l, 16px);
+        }
+        .unified-block {
+          position: relative;
+          border-left: 3px solid transparent;
+          box-sizing: border-box;
+          margin-bottom: var(--spacing-s, 8px);
+          overflow-wrap: break-word;
+          padding: var(--spacing-xxs, 2px) var(--spacing-s, 8px);
+          padding-right: 90px;
+          display: flex;
+          flex-direction: column;
+        }
+        .unified-block.added {
+          border-left-color: var(--positive-green-text-color, #2da44e);
+          background-color: var(--light-add-highlight-color, #d8fed8);
+          border-radius: 0 4px 4px 0;
+        }
+        .unified-block.deleted {
+          border-left-color: var(--negative-red-text-color, #cf222e);
+          background-color: var(--light-remove-highlight-color, #ffebee);
+          border-radius: 0 4px 4px 0;
+        }
+        .unified-block.unchanged {
+          border-left-color: transparent;
+        }
+        .cell-action-bar {
+          position: absolute;
+          top: 4px;
+          right: 8px;
+          z-index: 10;
+          pointer-events: none;
+        }
+        .add-comment-btn {
+          pointer-events: auto;
+          opacity: 0;
+          visibility: hidden;
+          transition: opacity 0.15s ease-in-out, background-color 0.15s;
+          background-color: var(--background-color-primary, #ffffff);
+          color: var(--primary-text-color, #202124);
+          border: 1px solid var(--border-color, #dadce0);
+          border-radius: 16px;
+          padding: 2px 8px;
+          font-size: var(--font-size-small, 12px);
+          font-weight: var(--font-weight-medium, 500);
+          box-shadow: var(--elevation-level-1, 0 1px 3px rgba(60, 64, 67, 0.3));
+          display: inline-flex;
+          align-items: center;
+          gap: 4px;
+          cursor: pointer;
+          user-select: none;
+          line-height: 18px;
+        }
+        .add-comment-btn gr-icon {
+          --gr-icon-size: 16px;
+          color: var(--primary-text-color, #202124);
+        }
+        .diff-cell:hover .add-comment-btn,
+        .unified-block:hover .add-comment-btn {
+          opacity: 1;
+          visibility: visible;
+        }
+        .add-comment-btn:hover {
+          background: linear-gradient(
+              var(--hover-background-color, rgba(161, 194, 250, 0.2)),
+              var(--hover-background-color, rgba(161, 194, 250, 0.2))
+            ),
+            var(--background-color-primary, #ffffff);
+          box-shadow: var(--elevation-level-2, 0 2px 6px rgba(60, 64, 67, 0.3));
+        }
+        .add-comment-btn:active {
+          background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+            var(--background-color-primary, #ffffff);
+        }
+        .selection-action-box {
+          position: absolute;
+          z-index: 500;
+          transform: translate(-50%, -100%);
+          margin-top: -6px;
+        }
+        .selection-action-box.below {
+          transform: translate(-50%, 0);
+          margin-top: 6px;
+        }
+        .selection-comment-btn {
+          background-color: var(--background-color-primary, #ffffff);
+          color: var(--primary-text-color, #202124);
+          border: 1px solid var(--border-color, #dadce0);
+          border-radius: 16px;
+          padding: 4px 12px;
+          font-size: var(--font-size-small, 12px);
+          font-weight: var(--font-weight-medium, 500);
+          box-shadow: var(--elevation-level-2, 0 2px 6px rgba(60, 64, 67, 0.3));
+          display: inline-flex;
+          align-items: center;
+          gap: 6px;
+          cursor: pointer;
+          white-space: nowrap;
+          user-select: none;
+        }
+        .selection-comment-btn:hover {
+          background: linear-gradient(
+              var(--hover-background-color, rgba(161, 194, 250, 0.2)),
+              var(--hover-background-color, rgba(161, 194, 250, 0.2))
+            ),
+            var(--background-color-primary, #ffffff);
+          box-shadow: var(--elevation-level-3, 0 4px 8px rgba(60, 64, 67, 0.3));
+        }
+        .selection-comment-btn:active {
+          background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+            var(--background-color-primary, #ffffff);
+        }
+        .selection-comment-btn gr-icon {
+          --gr-icon-size: 16px;
+          color: var(--primary-text-color, #202124);
+        }
+        .comment-thread {
+          display: block;
+          max-width: 100%;
+        }
+        .cell-content {
+          min-width: 0;
+        }
+        .threads-container {
+          margin-top: var(--spacing-s, 8px);
+          padding-top: var(--spacing-xs, 4px);
+          display: flex;
+          flex-direction: column;
+          gap: var(--spacing-s, 8px);
+        }
+        .diff-highlight-add,
+        ins {
+          background-color: var(--dark-add-highlight-color, #aaf2aa);
+          text-decoration: none;
+          border-radius: 2px;
+          padding: 1px 2px;
+        }
+        .diff-highlight-del,
+        del {
+          background-color: var(--dark-remove-highlight-color, #ffcdd2);
+          text-decoration: line-through;
+          border-radius: 2px;
+          padding: 1px 2px;
+        }
+        .diff-cell.deleted code,
+        .diff-cell.modified-left code,
+        .unified-block.deleted code,
+        del code {
+          background-color: rgba(0, 0, 0, 0.05);
+        }
+        .diff-cell.added code,
+        .diff-cell.modified-right code,
+        .unified-block.added code,
+        ins code {
+          background-color: rgba(0, 0, 0, 0.05);
+        }
+        .diff-cell.deleted pre,
+        .diff-cell.modified-left pre,
+        .unified-block.deleted pre {
+          background-color: rgba(0, 0, 0, 0.03);
+          border-color: rgba(207, 34, 46, 0.2);
+        }
+        .diff-cell.added pre,
+        .diff-cell.modified-right pre,
+        .unified-block.added pre {
+          background-color: rgba(0, 0, 0, 0.03);
+          border-color: rgba(46, 160, 67, 0.2);
+        }
+        pre .diff-highlight-del {
+          display: inline-block;
+          width: 100%;
+          box-sizing: border-box;
+        }
+        pre .diff-highlight-add {
+          display: inline-block;
+          width: 100%;
+          box-sizing: border-box;
+        }
+        .diff-cell.deleted th,
+        .diff-cell.modified-left th,
+        .unified-block.deleted th {
+          background-color: rgba(0, 0, 0, 0.04);
+        }
+        h1,
+        h2,
+        h3,
+        h4,
+        h5,
+        h6 {
+          margin-top: 0;
+          margin-bottom: var(--spacing-xs, 4px);
+          color: var(--primary-text-color, #202124);
+        }
+        h1 {
+          font-size: 1.6em;
+          border-bottom: 1px solid var(--border-color, #e0e0e0);
+          padding-bottom: 4px;
+        }
+        h2 {
+          font-size: 1.3em;
+          border-bottom: 1px solid var(--border-color, #e0e0e0);
+          padding-bottom: 4px;
+        }
+        h3 {
+          font-size: 1.15em;
+        }
+        p {
+          margin-top: 0;
+          margin-bottom: var(--spacing-xs, 4px);
+        }
+        code {
+          font-family: var(--monospace-font-family, 'Roboto Mono', monospace);
+          font-size: var(--font-size-code, 12px);
+          background-color: var(--background-color-secondary, #f1f3f4);
+          padding: 2px 4px;
+          border-radius: 3px;
+        }
+        pre {
+          background-color: var(--background-color-secondary, #f8f9fa);
+          border: 1px solid var(--border-color, #e0e0e0);
+          border-radius: 4px;
+          padding: var(--spacing-s, 8px);
+          overflow-x: auto;
+          margin: 0;
+        }
+        pre code {
+          background-color: transparent;
+          padding: 0;
+          font-size: var(--font-size-code, 12px);
+          display: block;
+          white-space: pre;
+        }
+        blockquote {
+          border-left: 4px solid var(--border-color, #d0d7de);
+          margin: 0 0 var(--spacing-s, 8px) 0;
+          padding: 0 var(--spacing-m, 12px);
+          color: var(--deemphasized-text-color, #5f6368);
+        }
+        ul,
+        ol {
+          margin-top: 0;
+          margin-bottom: var(--spacing-xs, 4px);
+          padding-left: var(--spacing-xl, 24px);
+        }
+        table {
+          border-collapse: collapse;
+          width: 100%;
+          margin-bottom: var(--spacing-s, 8px);
+        }
+        th,
+        td {
+          border: 1px solid var(--border-color, #e0e0e0);
+          padding: 6px 12px;
+          text-align: left;
+        }
+        th {
+          background-color: var(--background-color-secondary, #f8f9fa);
+          font-weight: var(--font-weight-bold, 600);
+        }
+      `,
+    ];
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('diff')) {
+      this.recomputeAlignedRows();
+    }
+    if (
+      changedProperties.has('changeComments') ||
+      changedProperties.has('patchRange') ||
+      changedProperties.has('path')
+    ) {
+      this.updateInternalThreads();
+    }
+  }
+
+  private recomputeAlignedRows() {
+    if (!this.diff) {
+      this.alignedRows = [];
+      return;
+    }
+    const textA = reconstructFileContent(this.diff, Side.LEFT);
+    const textB = reconstructFileContent(this.diff, Side.RIGHT);
+    const tokensA = parseMarkdownBlocks(textA);
+    const tokensB = parseMarkdownBlocks(textB);
+    this.alignedRows = alignMarkdownTokens(tokensA, tokensB);
+  }
+
+  private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
+    if (!this.loggedIn) {
+      fire(this, 'show-auth-required', {});
+      return false;
+    }
+    if (!this.patchRange) {
+      fireAlert(this, 'Cannot create comment. patchRange undefined.');
+      return false;
+    }
+
+    const isEdit = patchNum === EDIT;
+    const isEditBase = patchNum === PARENT && this.patchRange.patchNum === EDIT;
+
+    if (isEdit) {
+      fireAlert(this, 'You cannot comment on an edit.');
+      return false;
+    }
+    if (isEditBase) {
+      fireAlert(this, 'You cannot comment on the base patchset of an edit.');
+      return false;
+    }
+    return true;
+  }
+
+  private computeParentIndex() {
+    if (!this.patchRange) return null;
+    return isMergeParent(this.patchRange.basePatchNum)
+      ? getParentIndex(this.patchRange.basePatchNum)
+      : null;
+  }
+
+  createComment(side: Side, lineNum?: number) {
+    if (!this.patchRange) {
+      fireAlert(this, 'Cannot create comment. patchRange undefined.');
+      return;
+    }
+
+    const patchNum =
+      side === Side.LEFT && !isAParent(this.patchRange.basePatchNum)
+        ? this.patchRange.basePatchNum
+        : this.patchRange.patchNum;
+    const commentSide =
+      side === Side.LEFT && isAParent(this.patchRange.basePatchNum)
+        ? CommentSide.PARENT
+        : CommentSide.REVISION;
+
+    if (!this.canCommentOnPatchSetNum(patchNum)) return;
+    const path = this.filePath;
+    assertIsDefined(path, 'path');
+
+    const basePath = this.diff?.meta_a?.name;
+    const effectivePath =
+      basePath && side === Side.LEFT && commentSide === CommentSide.REVISION
+        ? basePath
+        : path;
+
+    let effectiveLine = lineNum;
+    if (effectiveLine === undefined) {
+      const fallbackRow = this.alignedRows.find(r =>
+        side === Side.LEFT
+          ? r.leftStartLine !== undefined
+          : r.rightStartLine !== undefined
+      );
+      effectiveLine =
+        side === Side.LEFT
+          ? fallbackRow?.leftStartLine
+          : fallbackRow?.rightStartLine ?? 1;
+    }
+
+    const parentIndex = this.computeParentIndex();
+    const draft: DraftInfo = {
+      ...createNew('', true),
+      patch_set: patchNum as RevisionPatchSetNum,
+      side: commentSide,
+      parent: parentIndex ?? undefined,
+      path: effectivePath,
+      line: typeof effectiveLine === 'number' ? effectiveLine : undefined,
+    };
+    this.getCommentsModel().addNewDraft(draft);
+  }
+
+  private handleCellMouseEnter(side: Side, lineNum?: number) {
+    this.hoveredSide = side;
+    this.hoveredLine = lineNum;
+  }
+
+  private handleCellMouseLeave() {
+    this.hoveredSide = undefined;
+    this.hoveredLine = undefined;
+  }
+
+  private clearSelection() {
+    (this.renderRoot as ShadowRoot)?.getSelection?.()?.removeAllRanges();
+    window.getSelection()?.removeAllRanges();
+    this.selectedSide = undefined;
+    this.selectedLine = undefined;
+  }
+
+  hasActiveSelection(): boolean {
+    const selection = this.getActiveSelection();
+    if (!selection || selection.isCollapsed || !selection.rangeCount) {
+      return false;
+    }
+    const range = selection.getRangeAt(0);
+    const container = range.commonAncestorContainer;
+    const elementNode =
+      container.nodeType === Node.TEXT_NODE
+        ? container.parentElement
+        : (container as Element);
+    return !!(
+      elementNode &&
+      this.renderRoot.contains(elementNode) &&
+      elementNode.closest('.diff-cell, .unified-block')
+    );
+  }
+
+  private getActiveSelection(): Selection | null {
+    const shadowSelection = (this.renderRoot as ShadowRoot)?.getSelection?.();
+    if (
+      shadowSelection &&
+      !shadowSelection.isCollapsed &&
+      shadowSelection.rangeCount > 0
+    ) {
+      return shadowSelection;
+    }
+    const docSelection = window.getSelection();
+    if (
+      docSelection &&
+      !docSelection.isCollapsed &&
+      docSelection.rangeCount > 0
+    ) {
+      return docSelection;
+    }
+    return null;
+  }
+
+  private handleSelectionChange = () => {
+    const selection = this.getActiveSelection();
+    if (!selection || selection.isCollapsed || !selection.rangeCount) {
+      if (this.selectionActionBoxVisible) {
+        this.selectionActionBoxVisible = false;
+        if (this.selectionActionBox) {
+          this.selectionActionBox.style.display = 'none';
+        }
+      }
+      return;
+    }
+    const range = selection.getRangeAt(0);
+    const container = range.commonAncestorContainer;
+    const elementNode =
+      container.nodeType === Node.TEXT_NODE
+        ? container.parentElement
+        : (container as Element);
+    if (!elementNode || !this.renderRoot.contains(elementNode)) {
+      if (this.selectionActionBoxVisible) {
+        this.selectionActionBoxVisible = false;
+        if (this.selectionActionBox) {
+          this.selectionActionBox.style.display = 'none';
+        }
+      }
+      return;
+    }
+    const cell = elementNode.closest('.diff-cell, .unified-block');
+    if (!cell) {
+      if (this.selectionActionBoxVisible) {
+        this.selectionActionBoxVisible = false;
+        if (this.selectionActionBox) {
+          this.selectionActionBox.style.display = 'none';
+        }
+      }
+      return;
+    }
+
+    const rangeRect = range.getBoundingClientRect();
+    const hostRect = this.getBoundingClientRect();
+
+    const spaceAbove = rangeRect.top - hostRect.top;
+    if (spaceAbove < 40) {
+      this.selectionBoxTop = rangeRect.bottom - hostRect.top + this.scrollTop;
+      this.selectionBoxPositionBelow = true;
+    } else {
+      this.selectionBoxTop = rangeRect.top - hostRect.top + this.scrollTop;
+      this.selectionBoxPositionBelow = false;
+    }
+    this.selectionBoxLeft =
+      rangeRect.left - hostRect.left + rangeRect.width / 2 + this.scrollLeft;
+    const sideStr = cell.getAttribute('data-side');
+    this.selectedSide = sideStr === 'left' ? Side.LEFT : Side.RIGHT;
+    const lineStr = cell.getAttribute('data-line');
+    this.selectedLine = lineStr ? Number(lineStr) : undefined;
+    this.selectionActionBoxVisible = true;
+    if (this.selectionActionBox) {
+      this.selectionActionBox.style.display = 'block';
+      this.selectionActionBox.style.top = `${this.selectionBoxTop}px`;
+      this.selectionActionBox.style.left = `${this.selectionBoxLeft}px`;
+      this.selectionActionBox.classList.toggle(
+        'below',
+        this.selectionBoxPositionBelow
+      );
+    }
+  };
+
+  private handleSelectionCommentClick(e: Event) {
+    e.stopPropagation();
+    if (this.selectedSide !== undefined) {
+      this.createComment(this.selectedSide, this.selectedLine);
+      this.selectionActionBoxVisible = false;
+      if (this.selectionActionBox) {
+        this.selectionActionBox.style.display = 'none';
+      }
+      this.clearSelection();
+    }
+  }
+
+  private handleKeyDown = (e: KeyboardEvent) => {
+    if (e.key === 'c' || e.key === 'C') {
+      if (e.ctrlKey || e.metaKey || e.altKey) {
+        return;
+      }
+      const target = e.composedPath()[0] as HTMLElement;
+      if (
+        target?.tagName === 'INPUT' ||
+        target?.tagName === 'TEXTAREA' ||
+        target?.tagName === 'GR-TEXTAREA' ||
+        target?.isContentEditable
+      ) {
+        return;
+      }
+      if (this.hasActiveSelection() || this.hoveredSide !== undefined) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.createCommentFromSelectionOrHover();
+      }
+    }
+  };
+
+  createCommentFromSelectionOrHover() {
+    const selection = this.getActiveSelection();
+    if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
+      const range = selection.getRangeAt(0);
+      const container = range.commonAncestorContainer;
+      const elementNode =
+        container.nodeType === Node.TEXT_NODE
+          ? container.parentElement
+          : (container as Element);
+      if (elementNode && this.renderRoot.contains(elementNode)) {
+        const cell = elementNode.closest('.diff-cell, .unified-block');
+        if (cell) {
+          const sideStr = cell.getAttribute('data-side');
+          const side = sideStr === 'left' ? Side.LEFT : Side.RIGHT;
+          const lineStr = cell.getAttribute('data-line');
+          const line = lineStr ? Number(lineStr) : undefined;
+          this.createComment(side, line);
+          this.selectionActionBoxVisible = false;
+          if (this.selectionActionBox) {
+            this.selectionActionBox.style.display = 'none';
+          }
+          this.clearSelection();
+          return;
+        }
+      }
+    }
+    if (this.selectedSide !== undefined) {
+      this.createComment(this.selectedSide, this.selectedLine);
+      this.selectionActionBoxVisible = false;
+      if (this.selectionActionBox) {
+        this.selectionActionBox.style.display = 'none';
+      }
+      this.clearSelection();
+      return;
+    }
+    if (this.hoveredSide !== undefined) {
+      this.createComment(this.hoveredSide, this.hoveredLine);
+      return;
+    }
+    const firstRow = this.alignedRows.find(r => r.rightStartLine !== undefined);
+    if (firstRow) {
+      this.createComment(Side.RIGHT, firstRow.rightStartLine);
+    } else {
+      this.createComment(Side.RIGHT, 1);
+    }
+  }
+
+  async autoSaveDrafts(): Promise<void> {
+    const threadElements = Array.from(
+      this.shadowRoot?.querySelectorAll<GrCommentThread>('gr-comment-thread') ??
+        []
+    );
+    await Promise.all(threadElements.map(thread => thread.autoSave()));
+  }
+
+  override render() {
+    if (this.viewMode === DiffViewMode.UNIFIED) {
+      return this.renderUnifiedView();
+    }
+    return this.renderSideBySideView();
+  }
+
+  private renderThread(thread: CommentThread, side?: Side) {
+    const diffSide = side ?? getThreadDiffSide(thread, this.patchRange);
+    return html`
+      <gr-comment-thread
+        class="comment-thread"
+        .rootId=${thread.rootId}
+        .thread=${thread}
+        .showPatchset=${false}
+        .showPortedComment=${!!thread.ported}
+        diff-side=${diffSide}
+        line-num=${thread.line ?? 'FILE'}
+      >
+      </gr-comment-thread>
+    `;
+  }
+
+  private renderCommentButton(side: Side, lineNum?: number) {
+    if (lineNum === undefined) return nothing;
+    return html`
+      <div class="cell-action-bar">
+        <button
+          type="button"
+          class="add-comment-btn"
+          title="Add comment (line ${lineNum})"
+          aria-label="Add comment (line ${lineNum})"
+          @click=${(e: Event) => {
+            e.stopPropagation();
+            this.createComment(side, lineNum);
+          }}
+        >
+          <gr-icon icon="add_comment" filled></gr-icon>
+          <span>Comment</span>
+        </button>
+      </div>
+    `;
+  }
+
+  private renderSelectionActionBox() {
+    return html`
+      <div
+        class="selection-action-box ${this.selectionBoxPositionBelow
+          ? 'below'
+          : ''}"
+        style="display: ${this.selectionActionBoxVisible
+          ? 'block'
+          : 'none'}; top: ${this.selectionBoxTop}px; left: ${this
+          .selectionBoxLeft}px;"
+      >
+        <button
+          type="button"
+          class="selection-comment-btn"
+          @mousedown=${(e: MouseEvent) => {
+            e.preventDefault();
+          }}
+          @click=${this.handleSelectionCommentClick}
+        >
+          <gr-icon icon="add_comment" filled></gr-icon>
+          <span>Comment (c)</span>
+        </button>
+      </div>
+    `;
+  }
+
+  private renderSideBySideView() {
+    const {rowsWithThreads, fileLevelThreads} = attachThreadsToRows(
+      this.alignedRows,
+      this.effectiveThreads,
+      this.patchRange
+    );
+
+    return html`
+      ${when(
+        fileLevelThreads.length > 0,
+        () => html`
+          <div class="file-level-threads">
+            <div class="file-level-title">File Comments</div>
+            ${fileLevelThreads.map(t => this.renderThread(t))}
+          </div>
+        `
+      )}
+      <div class="column-headers">
+        <div class="column-header left">Base</div>
+        <div class="column-header right">Revision</div>
+      </div>
+      <div class="diff-grid" role="region" aria-label="Rich Markdown Diff">
+        ${rowsWithThreads.map(row => this.renderRow(row))}
+      </div>
+      ${this.renderSelectionActionBox()}
+    `;
+  }
+
+  private renderUnifiedView() {
+    const {rowsWithThreads, fileLevelThreads} = attachThreadsToRows(
+      this.alignedRows,
+      this.effectiveThreads,
+      this.patchRange
+    );
+
+    return html`
+      ${when(
+        fileLevelThreads.length > 0,
+        () => html`
+          <div class="file-level-threads">
+            <div class="file-level-title">File Comments</div>
+            ${fileLevelThreads.map(t => this.renderThread(t))}
+          </div>
+        `
+      )}
+      <div
+        class="unified-container"
+        role="region"
+        aria-label="Rich Markdown Diff"
+      >
+        ${rowsWithThreads.map(row => this.renderUnifiedRow(row))}
+      </div>
+      ${this.renderSelectionActionBox()}
+    `;
+  }
+
+  private renderUnifiedRow(row: AlignedDiffRowWithThreads) {
+    if (row.status === 'unchanged') {
+      const allThreads = [...row.leftThreads, ...row.rightThreads];
+      return html`
+        <div
+          class="unified-block unchanged"
+          data-side="right"
+          data-line=${ifDefined(row.rightStartLine)}
+          @mouseenter=${() =>
+            this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+          @mouseleave=${() => this.handleCellMouseLeave()}
+        >
+          ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)}
+          <div class="cell-content">
+            ${sanitizeHtmlToFragment(row.leftHtml ?? row.rightHtml ?? '')}
+          </div>
+          ${when(
+            allThreads.length > 0,
+            () => html`
+              <div class="threads-container">
+                ${allThreads.map(t => this.renderThread(t))}
+              </div>
+            `
+          )}
+        </div>
+      `;
+    }
+    if (row.status === 'deleted') {
+      return html`
+        <div
+          class="unified-block deleted"
+          data-side="left"
+          data-line=${ifDefined(row.leftStartLine)}
+          @mouseenter=${() =>
+            this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)}
+          @mouseleave=${() => this.handleCellMouseLeave()}
+        >
+          ${this.renderCommentButton(Side.LEFT, row.leftStartLine)}
+          <div class="cell-content">
+            ${sanitizeHtmlToFragment(row.leftHtml!)}
+          </div>
+          ${when(
+            row.leftThreads.length > 0,
+            () => html`
+              <div class="threads-container">
+                ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))}
+              </div>
+            `
+          )}
+        </div>
+      `;
+    }
+    if (row.status === 'added') {
+      return html`
+        <div
+          class="unified-block added"
+          data-side="right"
+          data-line=${ifDefined(row.rightStartLine)}
+          @mouseenter=${() =>
+            this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+          @mouseleave=${() => this.handleCellMouseLeave()}
+        >
+          ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)}
+          <div class="cell-content">
+            ${sanitizeHtmlToFragment(row.rightHtml!)}
+          </div>
+          ${when(
+            row.rightThreads.length > 0,
+            () => html`
+              <div class="threads-container">
+                ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))}
+              </div>
+            `
+          )}
+        </div>
+      `;
+    }
+    // modified: render deleted (base) then added (revision)
+    return html`
+      <div
+        class="unified-block deleted"
+        data-side="left"
+        data-line=${ifDefined(row.leftStartLine)}
+        @mouseenter=${() =>
+          this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)}
+        @mouseleave=${() => this.handleCellMouseLeave()}
+      >
+        ${this.renderCommentButton(Side.LEFT, row.leftStartLine)}
+        <div class="cell-content">${sanitizeHtmlToFragment(row.leftHtml!)}</div>
+        ${when(
+          row.leftThreads.length > 0,
+          () => html`
+            <div class="threads-container">
+              ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))}
+            </div>
+          `
+        )}
+      </div>
+      <div
+        class="unified-block added"
+        data-side="right"
+        data-line=${ifDefined(row.rightStartLine)}
+        @mouseenter=${() =>
+          this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+        @mouseleave=${() => this.handleCellMouseLeave()}
+      >
+        ${this.renderCommentButton(Side.RIGHT, row.rightStartLine)}
+        <div class="cell-content">
+          ${sanitizeHtmlToFragment(row.rightHtml!)}
+        </div>
+        ${when(
+          row.rightThreads.length > 0,
+          () => html`
+            <div class="threads-container">
+              ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))}
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  private renderRow(row: AlignedDiffRowWithThreads) {
+    return html` ${this.renderLeftCell(row)} ${this.renderRightCell(row)} `;
+  }
+
+  private renderLeftCell(row: AlignedDiffRowWithThreads) {
+    const isDeleted = row.status === 'deleted';
+    const isModified = row.status === 'modified';
+    const isEmpty = row.status === 'added' || !row.leftHtml;
+
+    const classes = {
+      'diff-cell': true,
+      left: true,
+      deleted: isDeleted,
+      'modified-left': isModified,
+      empty: isEmpty,
+    };
+
+    return html`
+      <div
+        class=${classMap(classes)}
+        data-side="left"
+        data-line=${ifDefined(row.leftStartLine)}
+        @mouseenter=${() =>
+          !isEmpty && this.handleCellMouseEnter(Side.LEFT, row.leftStartLine)}
+        @mouseleave=${() => this.handleCellMouseLeave()}
+      >
+        ${when(!isEmpty, () =>
+          this.renderCommentButton(Side.LEFT, row.leftStartLine)
+        )}
+        <div class="cell-content">
+          ${when(!isEmpty, () => sanitizeHtmlToFragment(row.leftHtml!))}
+        </div>
+        ${when(
+          row.leftThreads.length > 0,
+          () => html`
+            <div class="threads-container">
+              ${row.leftThreads.map(t => this.renderThread(t, Side.LEFT))}
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  private renderRightCell(row: AlignedDiffRowWithThreads) {
+    const isAdded = row.status === 'added';
+    const isModified = row.status === 'modified';
+    const isEmpty = row.status === 'deleted' || !row.rightHtml;
+
+    const classes = {
+      'diff-cell': true,
+      right: true,
+      added: isAdded,
+      'modified-right': isModified,
+      empty: isEmpty,
+    };
+
+    return html`
+      <div
+        class=${classMap(classes)}
+        data-side="right"
+        data-line=${ifDefined(row.rightStartLine)}
+        @mouseenter=${() =>
+          !isEmpty && this.handleCellMouseEnter(Side.RIGHT, row.rightStartLine)}
+        @mouseleave=${() => this.handleCellMouseLeave()}
+      >
+        ${when(!isEmpty, () =>
+          this.renderCommentButton(Side.RIGHT, row.rightStartLine)
+        )}
+        <div class="cell-content">
+          ${when(!isEmpty, () => sanitizeHtmlToFragment(row.rightHtml!))}
+        </div>
+        ${when(
+          row.rightThreads.length > 0,
+          () => html`
+            <div class="threads-container">
+              ${row.rightThreads.map(t => this.renderThread(t, Side.RIGHT))}
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-markdown-viewer': GrDiffMarkdownViewer;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer_test.ts
new file mode 100644
index 0000000..e164913
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/gr-diff-markdown-viewer_test.ts
@@ -0,0 +1,610 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert, fixture, html} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import './gr-diff-markdown-viewer';
+import {GrDiffMarkdownViewer} from './gr-diff-markdown-viewer';
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, DiffViewMode} from '../../../constants/constants';
+import {
+  createAccountDetailWithId,
+  createComment,
+  createCommentThread,
+  createDiff,
+  createPatchRange,
+} from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  CommentThread,
+  DraftInfo,
+  RevisionPatchSetNum,
+} from '../../../types/common';
+import sinon from 'sinon';
+
+suite('gr-diff-markdown-viewer tests', () => {
+  let element: GrDiffMarkdownViewer;
+  let commentsModel: CommentsModel;
+  let userModel: UserModel;
+
+  setup(async () => {
+    commentsModel = testResolver(commentsModelToken);
+    userModel = testResolver(userModelToken);
+    userModel.setAccount(createAccountDetailWithId(1));
+
+    element = await fixture<GrDiffMarkdownViewer>(
+      html`<gr-diff-markdown-viewer></gr-diff-markdown-viewer>`
+    );
+  });
+
+  test('renders empty when no diff is provided', () => {
+    const grid = element.shadowRoot!.querySelector('.diff-grid');
+    assert.isNotNull(grid);
+    assert.equal(grid.children.length, 0);
+  });
+
+  test('renders aligned markdown rows', async () => {
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {ab: ['# Title', '']},
+        {a: ['Old paragraph.'], b: ['New paragraph.']},
+        {b: ['', '- Added bullet']},
+      ],
+    };
+
+    element.diff = diff;
+    await element.updateComplete;
+
+    const grid = element.shadowRoot!.querySelector('.diff-grid');
+    assert.isNotNull(grid);
+    // At least 3 rows = 6 cells (left and right)
+    const cells = grid.querySelectorAll('.diff-cell');
+    assert.isAtLeast(cells.length, 4);
+
+    // Verify left and right column headers
+    const leftHeader = element.shadowRoot!.querySelector('.column-header.left');
+    const rightHeader = element.shadowRoot!.querySelector(
+      '.column-header.right'
+    );
+    assert.equal(leftHeader!.textContent!.trim(), 'Base');
+    assert.equal(rightHeader!.textContent!.trim(), 'Revision');
+  });
+
+  test('highlights modified and added cells correctly', async () => {
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [{a: ['Deleted text.'], b: ['Added text.']}],
+    };
+
+    element.diff = diff;
+    await element.updateComplete;
+
+    const modifiedLeft = element.shadowRoot!.querySelector<HTMLElement>(
+      '.diff-cell.modified-left'
+    );
+    const modifiedRight = element.shadowRoot!.querySelector<HTMLElement>(
+      '.diff-cell.modified-right'
+    );
+    assert.isNotNull(modifiedLeft);
+    assert.isNotNull(modifiedRight);
+    const styleLeft = window.getComputedStyle(modifiedLeft);
+    const styleRight = window.getComputedStyle(modifiedRight);
+    assert.isOk(styleLeft.backgroundColor);
+    assert.notEqual(styleLeft.backgroundColor, 'rgba(0, 0, 0, 0)');
+    assert.notEqual(styleLeft.backgroundColor, 'transparent');
+    assert.isOk(styleRight.backgroundColor);
+    assert.notEqual(styleRight.backgroundColor, 'rgba(0, 0, 0, 0)');
+    assert.notEqual(styleRight.backgroundColor, 'transparent');
+  });
+
+  test('renders deleted block with red background in side-by-side mode', async () => {
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [{a: ['Only deleted paragraph.']}],
+    };
+
+    element.diff = diff;
+    await element.updateComplete;
+
+    const deletedCell =
+      element.shadowRoot!.querySelector<HTMLElement>('.diff-cell.deleted');
+    assert.isNotNull(deletedCell);
+    const style = window.getComputedStyle(deletedCell);
+    assert.isOk(style.backgroundColor);
+    assert.notEqual(style.backgroundColor, 'rgba(0, 0, 0, 0)');
+    assert.notEqual(style.backgroundColor, 'transparent');
+  });
+
+  test('renders unified diff mode correctly', async () => {
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {ab: ['# Unchanged title', '']},
+        {a: ['Deleted block.'], b: ['Added block.']},
+      ],
+    };
+
+    element.diff = diff;
+    element.viewMode = DiffViewMode.UNIFIED;
+    await element.updateComplete;
+
+    const unifiedContainer =
+      element.shadowRoot!.querySelector('.unified-container');
+    assert.isNotNull(unifiedContainer);
+
+    const headers = element.shadowRoot!.querySelector('.column-headers');
+    assert.isNull(headers);
+
+    const unchangedBlock = element.shadowRoot!.querySelector(
+      '.unified-block.unchanged'
+    );
+    assert.isNotNull(unchangedBlock);
+
+    const deletedBlock = element.shadowRoot!.querySelector(
+      '.unified-block.deleted'
+    );
+    assert.isNotNull(deletedBlock);
+
+    const addedBlock = element.shadowRoot!.querySelector(
+      '.unified-block.added'
+    );
+    assert.isNotNull(addedBlock);
+
+    // Switch back to side-by-side (split) mode
+    element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+    await element.updateComplete;
+
+    assert.isNull(element.shadowRoot!.querySelector('.unified-container'));
+    assert.isNotNull(element.shadowRoot!.querySelector('.diff-grid'));
+    assert.isNotNull(element.shadowRoot!.querySelector('.column-headers'));
+  });
+
+  suite('comment threads rendering and creation', () => {
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {ab: ['# Title', '']},
+        {a: ['Old paragraph.'], b: ['New paragraph.']},
+      ],
+    };
+
+    test('renders file-level and block-level threads in side-by-side mode', async () => {
+      const fileThread: CommentThread = createCommentThread([
+        {...createComment(), line: undefined, message: 'File comment'},
+      ]);
+      const titleThread: CommentThread = createCommentThread([
+        {
+          ...createComment(),
+          line: 1,
+          message: 'Title comment',
+          patch_set: 1 as RevisionPatchSetNum,
+        },
+      ]);
+      const baseParagraphThread: CommentThread = createCommentThread([
+        {
+          ...createComment(),
+          line: 3,
+          message: 'Old paragraph comment',
+          side: CommentSide.PARENT,
+        },
+      ]);
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      element.threads = [fileThread, titleThread, baseParagraphThread];
+      await element.updateComplete;
+
+      // File level threads section
+      const fileSection = element.shadowRoot!.querySelector(
+        '.file-level-threads'
+      );
+      assert.isNotNull(fileSection);
+      const fileThreads = fileSection.querySelectorAll('gr-comment-thread');
+      assert.equal(fileThreads.length, 1);
+
+      // Block threads inside diff cells
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      // Row 0 left: Title (unchanged, line 1)
+      // Row 0 right: Title (unchanged, line 1) -> contains titleThread
+      const rightTitleCell = cells[1];
+      const rightTitleThreads =
+        rightTitleCell.querySelectorAll('gr-comment-thread');
+      assert.equal(rightTitleThreads.length, 1);
+
+      // Row 1 left: Old paragraph (modified-left, line 3) -> contains baseParagraphThread
+      const leftOldCell = cells[2];
+      const leftOldThreads = leftOldCell.querySelectorAll('gr-comment-thread');
+      assert.equal(leftOldThreads.length, 1);
+    });
+
+    test('clicking add comment button calls commentsModel.addNewDraft', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      // Row 0 right (Title, line 1)
+      const rightBtn = cells[1].querySelector<HTMLElement>('.add-comment-btn');
+      assert.isNotNull(rightBtn);
+      rightBtn.click();
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft1: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft1.path, 'test.md');
+      assert.equal(draft1.side, CommentSide.REVISION);
+      assert.equal(draft1.line, 1);
+
+      // Row 1 left (Old paragraph, line 3)
+      const leftBtn = cells[2].querySelector<HTMLElement>('.add-comment-btn');
+      assert.isNotNull(leftBtn);
+      leftBtn.click();
+
+      assert.isTrue(addDraftSpy.calledTwice);
+      const draft2: DraftInfo = addDraftSpy.secondCall.firstArg;
+      assert.equal(draft2.path, 'test.md');
+      assert.equal(draft2.side, CommentSide.PARENT);
+      assert.equal(draft2.line, 3);
+    });
+
+    test('prevents commenting when logged out and fires show-auth-required', async () => {
+      userModel.setAccount(undefined);
+      await element.updateComplete;
+
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+      let authFired = false;
+      element.addEventListener('show-auth-required', () => {
+        authFired = true;
+      });
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const btn =
+        element.shadowRoot!.querySelector<HTMLElement>('.add-comment-btn');
+      assert.isNotNull(btn);
+      btn.click();
+
+      assert.isFalse(addDraftSpy.called);
+      assert.isTrue(authFired);
+    });
+
+    test('renders threads and handles commenting in unified mode', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      const thread: CommentThread = createCommentThread([
+        {
+          ...createComment(),
+          line: 1,
+          message: 'Unified comment',
+          patch_set: 1 as RevisionPatchSetNum,
+        },
+      ]);
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      element.threads = [thread];
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+
+      const unifiedContainer =
+        element.shadowRoot!.querySelector('.unified-container');
+      assert.isNotNull(unifiedContainer);
+
+      const threads = unifiedContainer.querySelectorAll('gr-comment-thread');
+      assert.equal(threads.length, 1);
+
+      const btn =
+        unifiedContainer.querySelector<HTMLElement>('.add-comment-btn');
+      assert.isNotNull(btn);
+      btn.click();
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft.path, 'test.md');
+      assert.equal(draft.line, 1);
+    });
+
+    test('pressing c when cell is hovered creates draft on that line and side', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      // Row 1 left (line 3, Side.LEFT)
+      const leftCell = cells[2];
+      leftCell.dispatchEvent(new MouseEvent('mouseenter'));
+
+      window.dispatchEvent(
+        new KeyboardEvent('keydown', {key: 'c', bubbles: true})
+      );
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft.path, 'test.md');
+      assert.equal(draft.side, CommentSide.PARENT);
+      assert.equal(draft.line, 3);
+    });
+
+    test('selecting text displays selection action box and clicking creates draft', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      const rightCell = cells[1];
+      const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      document.dispatchEvent(new Event('selectionchange'));
+      await element.updateComplete;
+
+      const actionBox = element.shadowRoot!.querySelector(
+        '.selection-action-box'
+      );
+      assert.isNotNull(actionBox);
+      const actionBoxStyle = window.getComputedStyle(actionBox);
+      assert.equal(actionBoxStyle.zIndex, '500');
+
+      const commentBtn = actionBox.querySelector<HTMLElement>(
+        '.selection-comment-btn'
+      );
+      assert.isNotNull(commentBtn);
+      const btnStyle = window.getComputedStyle(commentBtn);
+      assert.isOk(btnStyle.backgroundColor);
+      assert.notEqual(btnStyle.backgroundColor, 'transparent');
+      assert.notEqual(btnStyle.backgroundColor, 'rgba(0, 0, 0, 0)');
+
+      commentBtn.click();
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft.path, 'test.md');
+      assert.equal(draft.side, CommentSide.REVISION);
+      assert.equal(draft.line, 1);
+    });
+
+    test('text selection is preserved after selectionchange event', async () => {
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      const rightCell = cells[1];
+      const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      document.dispatchEvent(new Event('selectionchange'));
+      await element.updateComplete;
+
+      assert.isFalse(selection.isCollapsed);
+      assert.equal(selection.rangeCount, 1);
+      assert.isTrue(
+        element.shadowRoot!.contains(selection.getRangeAt(0).startContainer)
+      );
+    });
+
+    test('pressing c with active text selection creates draft', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      const leftCell = cells[2];
+      const textNode = leftCell.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      document.dispatchEvent(new Event('selectionchange'));
+      await element.updateComplete;
+
+      window.dispatchEvent(
+        new KeyboardEvent('keydown', {key: 'c', bubbles: true})
+      );
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft.path, 'test.md');
+      assert.equal(draft.side, CommentSide.PARENT);
+      assert.equal(draft.line, 3);
+    });
+
+    test('pressing C (uppercase) with active text selection creates draft', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      const leftCell = cells[2];
+      const textNode = leftCell.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      document.dispatchEvent(new Event('selectionchange'));
+      await element.updateComplete;
+
+      window.dispatchEvent(
+        new KeyboardEvent('keydown', {key: 'C', bubbles: true})
+      );
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft.path, 'test.md');
+      assert.equal(draft.side, CommentSide.PARENT);
+      assert.equal(draft.line, 3);
+    });
+
+    test('pressing c and C in unified mode with active selection creates draft', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+
+      const block = element.shadowRoot!.querySelector('.unified-block.added');
+      assert.isNotNull(block);
+      const textNode = block.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      document.dispatchEvent(new Event('selectionchange'));
+      await element.updateComplete;
+
+      assert.isTrue(element.hasActiveSelection());
+
+      window.dispatchEvent(
+        new KeyboardEvent('keydown', {key: 'C', bubbles: true})
+      );
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft.path, 'test.md');
+      assert.equal(draft.side, CommentSide.REVISION);
+      assert.equal(draft.line, 3);
+    });
+
+    test('pressing Ctrl+C or Meta+C with selection does not create draft', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      const rightCell = cells[1];
+      const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      document.dispatchEvent(new Event('selectionchange'));
+      await element.updateComplete;
+
+      window.dispatchEvent(
+        new KeyboardEvent('keydown', {key: 'c', ctrlKey: true, bubbles: true})
+      );
+      window.dispatchEvent(
+        new KeyboardEvent('keydown', {key: 'c', metaKey: true, bubbles: true})
+      );
+      window.dispatchEvent(
+        new KeyboardEvent('keydown', {key: 'C', ctrlKey: true, bubbles: true})
+      );
+
+      assert.isFalse(addDraftSpy.called);
+    });
+
+    test('hasActiveSelection returns true when selection is inside diff and false otherwise', async () => {
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      assert.isFalse(element.hasActiveSelection());
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      const rightCell = cells[1];
+      const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      assert.isTrue(element.hasActiveSelection());
+
+      selection.removeAllRanges();
+      assert.isFalse(element.hasActiveSelection());
+    });
+
+    test('createCommentFromSelectionOrHover creates comment on selection', async () => {
+      const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
+      element.diff = diff;
+      element.patchRange = createPatchRange();
+      element.path = 'test.md';
+      await element.updateComplete;
+
+      const cells = element.shadowRoot!.querySelectorAll('.diff-cell');
+      const rightCell = cells[1];
+      const textNode = rightCell.querySelector('.cell-content')?.firstChild;
+      assert.isNotNull(textNode);
+
+      const range = document.createRange();
+      range.selectNodeContents(textNode!);
+      const selection = window.getSelection()!;
+      selection.removeAllRanges();
+      selection.addRange(range);
+
+      element.createCommentFromSelectionOrHover();
+
+      assert.isTrue(addDraftSpy.calledOnce);
+      const draft: DraftInfo = addDraftSpy.firstCall.firstArg;
+      assert.equal(draft.path, 'test.md');
+      assert.equal(draft.side, CommentSide.REVISION);
+      assert.equal(draft.line, 1);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util.ts
new file mode 100644
index 0000000..2e5752f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util.ts
@@ -0,0 +1,511 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, Side} from '../../../constants/constants';
+import {CommentThread, PatchRange} from '../../../types/common';
+import {isInBaseOfPatchRange} from '../../../utils/comment-util';
+import {getDiffLines} from '../../../utils/diff-util';
+import {htmlEscape} from '../../../utils/inner-html-util';
+import {Marked, Token, Tokens} from 'marked';
+
+export type DiffBlockStatus = 'unchanged' | 'added' | 'deleted' | 'modified';
+
+export type MarkdownToken = Token & {
+  startLine?: number;
+  endLine?: number;
+};
+
+export interface AlignedDiffRow {
+  status: DiffBlockStatus;
+  leftToken?: MarkdownToken;
+  rightToken?: MarkdownToken;
+  leftHtml?: string;
+  rightHtml?: string;
+  leftStartLine?: number;
+  leftEndLine?: number;
+  rightStartLine?: number;
+  rightEndLine?: number;
+}
+
+export interface AlignedDiffRowWithThreads extends AlignedDiffRow {
+  leftThreads: CommentThread[];
+  rightThreads: CommentThread[];
+}
+
+export interface InlineDiffSegment {
+  text: string;
+  type: 'common' | 'added' | 'deleted';
+}
+
+/** Reconstruct the entire file content from diff chunks for the given side. */
+export function reconstructFileContent(diff: DiffInfo, side: Side): string {
+  return getDiffLines(diff, side).join('\n');
+}
+
+/** Parse markdown text into top-level block tokens with line number metadata. */
+export function parseMarkdownBlocks(markdown: string): MarkdownToken[] {
+  if (!markdown) return [];
+  const marked = new Marked();
+  const tokens = marked.lexer(markdown);
+  const result: MarkdownToken[] = [];
+  let curLine = 1;
+  for (const t of tokens) {
+    const raw = t.raw;
+    const newlineCount = (raw.match(/\n/g) || []).length;
+    const startLine = curLine;
+    const endLine = curLine + newlineCount - (raw.endsWith('\n') ? 1 : 0);
+    curLine += newlineCount;
+    if (t.type === 'space') {
+      continue;
+    }
+    (t as MarkdownToken).startLine = startLine;
+    (t as MarkdownToken).endLine = Math.max(startLine, endLine);
+    result.push(t as MarkdownToken);
+  }
+  return result;
+}
+
+/** Tokenize text into words, whitespace, and punctuation for fine-grained inline diffing. */
+export function tokenizeWords(text: string): string[] {
+  return text.match(/\s+|[^\s\w]+|\w+/g) || [];
+}
+
+/** Compute LCS-based diff between two arrays of strings (e.g. words or lines). */
+export function computeSequenceDiff(
+  seqA: string[],
+  seqB: string[]
+): InlineDiffSegment[] {
+  const m = seqA.length;
+  const n = seqB.length;
+  // DP table for LCS lengths
+  const dp: number[][] = Array.from({length: m + 1}, () =>
+    new Array(n + 1).fill(0)
+  );
+
+  for (let i = 0; i < m; i++) {
+    for (let j = 0; j < n; j++) {
+      if (seqA[i] === seqB[j]) {
+        dp[i + 1][j + 1] = dp[i][j] + 1;
+      } else {
+        dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
+      }
+    }
+  }
+
+  // Backtrack to build diff segments
+  const segments: InlineDiffSegment[] = [];
+  let i = m;
+  let j = n;
+
+  while (i > 0 || j > 0) {
+    if (i > 0 && j > 0 && seqA[i - 1] === seqB[j - 1]) {
+      segments.unshift({text: seqA[i - 1], type: 'common'});
+      i--;
+      j--;
+    } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+      segments.unshift({text: seqB[j - 1], type: 'added'});
+      j--;
+    } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
+      segments.unshift({text: seqA[i - 1], type: 'deleted'});
+      i--;
+    }
+  }
+
+  return segments;
+}
+
+/** Render a single marked token to standard HTML using marked. */
+export function renderTokenToHtml(token?: Token): string {
+  if (!token) return '';
+  const marked = new Marked();
+  return marked.parse(token.raw, {async: false}) || '';
+}
+
+/** Render an inline diff for modified text in headings or paragraphs. */
+export function renderInlineTextDiff(
+  textA: string,
+  textB: string,
+  wrapperTag = 'p'
+): {leftHtml: string; rightHtml: string} {
+  const wordsA = tokenizeWords(textA);
+  const wordsB = tokenizeWords(textB);
+  const diff = computeSequenceDiff(wordsA, wordsB);
+
+  // Merge consecutive segments of the same type
+  const mergedSegments: InlineDiffSegment[] = [];
+  for (const seg of diff) {
+    const last = mergedSegments[mergedSegments.length - 1];
+    if (last && last.type === seg.type) {
+      last.text += seg.text;
+    } else {
+      mergedSegments.push({...seg});
+    }
+  }
+
+  let leftContent = '';
+  let rightContent = '';
+
+  for (const seg of mergedSegments) {
+    const escaped = htmlEscape(seg.text).toString();
+    if (seg.type === 'common') {
+      leftContent += escaped;
+      rightContent += escaped;
+    } else if (seg.type === 'deleted') {
+      leftContent += `<del class="diff-highlight-del">${escaped}</del>`;
+    } else if (seg.type === 'added') {
+      rightContent += `<ins class="diff-highlight-add">${escaped}</ins>`;
+    }
+  }
+
+  return {
+    leftHtml: `<${wrapperTag}>${leftContent}</${wrapperTag}>`,
+    rightHtml: `<${wrapperTag}>${rightContent}</${wrapperTag}>`,
+  };
+}
+
+/** Render a modified code block with line-by-line diff highlights. */
+export function renderCodeBlockDiff(
+  tokenA: Tokens.Code,
+  tokenB: Tokens.Code
+): {leftHtml: string; rightHtml: string} {
+  const linesA = tokenA.text.split('\n');
+  const linesB = tokenB.text.split('\n');
+  const lineDiff = computeSequenceDiff(linesA, linesB);
+
+  let leftLines = '';
+  let rightLines = '';
+
+  for (const seg of lineDiff) {
+    const escaped = htmlEscape(seg.text).toString();
+    if (seg.type === 'common') {
+      leftLines += `${escaped}\n`;
+      rightLines += `${escaped}\n`;
+    } else if (seg.type === 'deleted') {
+      leftLines += `<span class="diff-highlight-del">${escaped}</span>\n`;
+    } else if (seg.type === 'added') {
+      rightLines += `<span class="diff-highlight-add">${escaped}</span>\n`;
+    }
+  }
+
+  const langClass = tokenB.lang
+    ? ` class="language-${htmlEscape(tokenB.lang)}"`
+    : '';
+  return {
+    leftHtml: `<pre><code${langClass}>${leftLines.trimEnd()}</code></pre>`,
+    rightHtml: `<pre><code${langClass}>${rightLines.trimEnd()}</code></pre>`,
+  };
+}
+
+/**
+ * Align base (A) and revision (B) markdown block tokens into rows.
+ * Matches exact blocks as anchors, pairs modified blocks of compatible types,
+ * and leaves unmatched blocks as added or deleted with empty partner cells.
+ */
+export function alignMarkdownTokens(
+  tokensA: MarkdownToken[],
+  tokensB: MarkdownToken[]
+): AlignedDiffRow[] {
+  // Filter out whitespace-only 'space' tokens between blocks
+  const blocksA = tokensA.filter(t => t.type !== 'space');
+  const blocksB = tokensB.filter(t => t.type !== 'space');
+
+  const m = blocksA.length;
+  const n = blocksB.length;
+
+  // DP table to find LCS of exact matches
+  const dp: number[][] = Array.from({length: m + 1}, () =>
+    new Array(n + 1).fill(0)
+  );
+
+  for (let i = 0; i < m; i++) {
+    for (let j = 0; j < n; j++) {
+      if (blocksA[i].raw === blocksB[j].raw) {
+        dp[i + 1][j + 1] = dp[i][j] + 1;
+      } else {
+        dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
+      }
+    }
+  }
+
+  // Backtrack to extract exact match pairs
+  const anchorPairs: {aIdx: number; bIdx: number}[] = [];
+  let i = m;
+  let j = n;
+  while (i > 0 && j > 0) {
+    if (blocksA[i - 1].raw === blocksB[j - 1].raw) {
+      anchorPairs.unshift({aIdx: i - 1, bIdx: j - 1});
+      i--;
+      j--;
+    } else if (dp[i][j - 1] >= dp[i - 1][j]) {
+      j--;
+    } else {
+      i--;
+    }
+  }
+
+  const rows: AlignedDiffRow[] = [];
+  let lastA = 0;
+  let lastB = 0;
+
+  function processInterval(endA: number, endB: number) {
+    const unalignedA = blocksA.slice(lastA, endA);
+    const unalignedB = blocksB.slice(lastB, endB);
+
+    let idxA = 0;
+    let idxB = 0;
+
+    // Greedily pair up adjacent tokens of compatible type as 'modified'
+    while (idxA < unalignedA.length && idxB < unalignedB.length) {
+      const tokA = unalignedA[idxA];
+      const tokB = unalignedB[idxB];
+
+      const canPair =
+        tokA.type === tokB.type ||
+        (tokA.type === 'paragraph' && tokB.type === 'paragraph') ||
+        (tokA.type === 'heading' && tokB.type === 'heading') ||
+        (tokA.type === 'code' && tokB.type === 'code');
+
+      if (canPair) {
+        let leftHtml: string;
+        let rightHtml: string;
+
+        if (tokA.type === 'code' && tokB.type === 'code') {
+          const diff = renderCodeBlockDiff(
+            tokA as Tokens.Code,
+            tokB as Tokens.Code
+          );
+          leftHtml = diff.leftHtml;
+          rightHtml = diff.rightHtml;
+        } else if (tokA.type === 'heading' && tokB.type === 'heading') {
+          const depth = (tokB as Tokens.Heading).depth;
+          const diff = renderInlineTextDiff(tokA.text, tokB.text, `h${depth}`);
+          leftHtml = diff.leftHtml;
+          rightHtml = diff.rightHtml;
+        } else if (tokA.type === 'paragraph' && tokB.type === 'paragraph') {
+          const diff = renderInlineTextDiff(tokA.text, tokB.text, 'p');
+          leftHtml = diff.leftHtml;
+          rightHtml = diff.rightHtml;
+        } else {
+          leftHtml = renderTokenToHtml(tokA);
+          rightHtml = renderTokenToHtml(tokB);
+        }
+
+        rows.push({
+          status: 'modified',
+          leftToken: tokA,
+          rightToken: tokB,
+          leftHtml,
+          rightHtml,
+          leftStartLine: tokA.startLine,
+          leftEndLine: tokA.endLine,
+          rightStartLine: tokB.startLine,
+          rightEndLine: tokB.endLine,
+        });
+        idxA++;
+        idxB++;
+      } else {
+        // Output deleted block on left
+        rows.push({
+          status: 'deleted',
+          leftToken: tokA,
+          leftHtml: renderTokenToHtml(tokA),
+          leftStartLine: tokA.startLine,
+          leftEndLine: tokA.endLine,
+        });
+        idxA++;
+      }
+    }
+
+    // Remaining unmatched in A
+    while (idxA < unalignedA.length) {
+      const tokA = unalignedA[idxA];
+      rows.push({
+        status: 'deleted',
+        leftToken: tokA,
+        leftHtml: renderTokenToHtml(tokA),
+        leftStartLine: tokA.startLine,
+        leftEndLine: tokA.endLine,
+      });
+      idxA++;
+    }
+
+    // Remaining unmatched in B
+    while (idxB < unalignedB.length) {
+      const tokB = unalignedB[idxB];
+      rows.push({
+        status: 'added',
+        rightToken: tokB,
+        rightHtml: renderTokenToHtml(tokB),
+        rightStartLine: tokB.startLine,
+        rightEndLine: tokB.endLine,
+      });
+      idxB++;
+    }
+  }
+
+  // Interleave intervals between anchor pairs
+  for (const anchor of anchorPairs) {
+    processInterval(anchor.aIdx, anchor.bIdx);
+    const tokA = blocksA[anchor.aIdx];
+    const tokB = blocksB[anchor.bIdx];
+    const htmlA = renderTokenToHtml(tokA);
+    const htmlB = renderTokenToHtml(tokB);
+    rows.push({
+      status: 'unchanged',
+      leftToken: tokA,
+      rightToken: tokB,
+      leftHtml: htmlA,
+      rightHtml: htmlB,
+      leftStartLine: tokA.startLine,
+      leftEndLine: tokA.endLine,
+      rightStartLine: tokB.startLine,
+      rightEndLine: tokB.endLine,
+    });
+    lastA = anchor.aIdx + 1;
+    lastB = anchor.bIdx + 1;
+  }
+
+  // Trailing interval
+  processInterval(m, n);
+
+  return rows;
+}
+
+/** Determine which side of the diff (LEFT or RIGHT) a comment thread belongs to. */
+export function getThreadDiffSide(
+  thread: CommentThread,
+  patchRange?: PatchRange
+): Side {
+  if (!patchRange) {
+    return thread.commentSide === CommentSide.PARENT ? Side.LEFT : Side.RIGHT;
+  }
+  const commentProps = {
+    patch_set: thread.patchNum,
+    side: thread.commentSide,
+    parent: thread.mergeParentNum,
+  };
+  if (isInBaseOfPatchRange(commentProps, patchRange)) {
+    return Side.LEFT;
+  }
+  return Side.RIGHT;
+}
+
+/**
+ * Assigns comment threads to their corresponding markdown diff rows based on line
+ * numbers and diff side. File-level comments (or threads without line numbers) are
+ * grouped separately.
+ */
+export function attachThreadsToRows(
+  rows: AlignedDiffRow[],
+  threads: CommentThread[] = [],
+  patchRange?: PatchRange
+): {
+  rowsWithThreads: AlignedDiffRowWithThreads[];
+  fileLevelThreads: CommentThread[];
+} {
+  const rowsWithThreads: AlignedDiffRowWithThreads[] = rows.map(r => {
+    return {
+      ...r,
+      leftThreads: [],
+      rightThreads: [],
+    };
+  });
+
+  const fileLevelThreads: CommentThread[] = [];
+
+  if (rowsWithThreads.length === 0) {
+    return {
+      rowsWithThreads,
+      fileLevelThreads: [...threads],
+    };
+  }
+
+  for (const thread of threads) {
+    const line = thread.line;
+    if (line === undefined || line === 'FILE') {
+      fileLevelThreads.push(thread);
+      continue;
+    }
+
+    const lineNum = typeof line === 'number' ? line : Number(line);
+    if (isNaN(lineNum)) {
+      fileLevelThreads.push(thread);
+      continue;
+    }
+
+    const side = getThreadDiffSide(thread, patchRange);
+
+    if (side === Side.LEFT) {
+      const leftRows = rowsWithThreads.filter(
+        r => r.leftStartLine !== undefined
+      );
+      if (leftRows.length === 0) {
+        rowsWithThreads[0].leftThreads.push(thread);
+        continue;
+      }
+
+      const exactRow = leftRows.find(
+        r => lineNum >= r.leftStartLine! && lineNum <= r.leftEndLine!
+      );
+      if (exactRow) {
+        exactRow.leftThreads.push(thread);
+        continue;
+      }
+
+      if (lineNum < leftRows[0].leftStartLine!) {
+        leftRows[0].leftThreads.push(thread);
+        continue;
+      }
+
+      let targetRow = leftRows[leftRows.length - 1];
+      for (let i = 0; i < leftRows.length - 1; i++) {
+        if (
+          lineNum > leftRows[i].leftEndLine! &&
+          lineNum < leftRows[i + 1].leftStartLine!
+        ) {
+          targetRow = leftRows[i];
+          break;
+        }
+      }
+      targetRow.leftThreads.push(thread);
+    } else {
+      const rightRows = rowsWithThreads.filter(
+        r => r.rightStartLine !== undefined
+      );
+      if (rightRows.length === 0) {
+        rowsWithThreads[0].rightThreads.push(thread);
+        continue;
+      }
+
+      const exactRow = rightRows.find(
+        r => lineNum >= r.rightStartLine! && lineNum <= r.rightEndLine!
+      );
+      if (exactRow) {
+        exactRow.rightThreads.push(thread);
+        continue;
+      }
+
+      if (lineNum < rightRows[0].rightStartLine!) {
+        rightRows[0].rightThreads.push(thread);
+        continue;
+      }
+
+      let targetRow = rightRows[rightRows.length - 1];
+      for (let i = 0; i < rightRows.length - 1; i++) {
+        if (
+          lineNum > rightRows[i].rightEndLine! &&
+          lineNum < rightRows[i + 1].rightStartLine!
+        ) {
+          targetRow = rightRows[i];
+          break;
+        }
+      }
+      targetRow.rightThreads.push(thread);
+    }
+  }
+
+  return {rowsWithThreads, fileLevelThreads};
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util_test.ts
new file mode 100644
index 0000000..dd112a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-markdown-viewer/markdown-diff-util_test.ts
@@ -0,0 +1,229 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {DiffInfo} from '../../../types/diff';
+import {CommentSide, Side} from '../../../constants/constants';
+import {
+  createComment,
+  createCommentThread,
+  createDiff,
+} from '../../../test/test-data-generators';
+import {CommentThread, UrlEncodedCommentId} from '../../../types/common';
+import {
+  alignMarkdownTokens,
+  attachThreadsToRows,
+  computeSequenceDiff,
+  parseMarkdownBlocks,
+  reconstructFileContent,
+  tokenizeWords,
+} from './markdown-diff-util';
+
+suite('markdown-diff-util tests', () => {
+  test('reconstructFileContent', () => {
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {ab: ['# Header', '']},
+        {a: ['Old line'], b: ['New line']},
+        {ab: ['', 'Footer']},
+      ],
+    };
+
+    assert.equal(
+      reconstructFileContent(diff, Side.LEFT),
+      '# Header\n\nOld line\n\nFooter'
+    );
+    assert.equal(
+      reconstructFileContent(diff, Side.RIGHT),
+      '# Header\n\nNew line\n\nFooter'
+    );
+  });
+
+  test('parseMarkdownBlocks', () => {
+    const md = '# Title\n\nParagraph text.\n\n```js\nconsole.log(1);\n```';
+    const tokens = parseMarkdownBlocks(md);
+    assert.equal(tokens.length, 3);
+    assert.equal(tokens[0].type, 'heading');
+    assert.equal(tokens[1].type, 'paragraph');
+    assert.equal(tokens[2].type, 'code');
+  });
+
+  test('tokenizeWords', () => {
+    const words = tokenizeWords('Hello world! How are you?');
+    assert.deepEqual(words, [
+      'Hello',
+      ' ',
+      'world',
+      '!',
+      ' ',
+      'How',
+      ' ',
+      'are',
+      ' ',
+      'you',
+      '?',
+    ]);
+  });
+
+  test('computeSequenceDiff', () => {
+    const seqA = ['a', 'b', 'c'];
+    const seqB = ['a', 'x', 'c'];
+    const diff = computeSequenceDiff(seqA, seqB);
+    assert.deepEqual(diff, [
+      {text: 'a', type: 'common'},
+      {text: 'b', type: 'deleted'},
+      {text: 'x', type: 'added'},
+      {text: 'c', type: 'common'},
+    ]);
+  });
+
+  suite('alignMarkdownTokens', () => {
+    test('identical documents produce unchanged rows', () => {
+      const doc = '# Title\n\nParagraph 1.\n\nParagraph 2.';
+      const tokensA = parseMarkdownBlocks(doc);
+      const tokensB = parseMarkdownBlocks(doc);
+
+      const rows = alignMarkdownTokens(tokensA, tokensB);
+      assert.equal(rows.length, 3);
+      assert.isTrue(rows.every(r => r.status === 'unchanged'));
+    });
+
+    test('inserted block creates added row with empty left', () => {
+      const docA = '# Title\n\nFooter.';
+      const docB = '# Title\n\nInserted paragraph.\n\nFooter.';
+      const tokensA = parseMarkdownBlocks(docA);
+      const tokensB = parseMarkdownBlocks(docB);
+
+      const rows = alignMarkdownTokens(tokensA, tokensB);
+      assert.equal(rows.length, 3);
+      assert.equal(rows[0].status, 'unchanged');
+      assert.equal(rows[1].status, 'added');
+      assert.isUndefined(rows[1].leftToken);
+      assert.isDefined(rows[1].rightToken);
+      assert.include(rows[1].rightHtml!, 'Inserted paragraph');
+      assert.equal(rows[2].status, 'unchanged');
+    });
+
+    test('deleted block creates deleted row with empty right', () => {
+      const docA = '# Title\n\nDeleted paragraph.\n\nFooter.';
+      const docB = '# Title\n\nFooter.';
+      const tokensA = parseMarkdownBlocks(docA);
+      const tokensB = parseMarkdownBlocks(docB);
+
+      const rows = alignMarkdownTokens(tokensA, tokensB);
+      assert.equal(rows.length, 3);
+      assert.equal(rows[0].status, 'unchanged');
+      assert.equal(rows[1].status, 'deleted');
+      assert.isDefined(rows[1].leftToken);
+      assert.isUndefined(rows[1].rightToken);
+      assert.include(rows[1].leftHtml!, 'Deleted paragraph');
+      assert.equal(rows[2].status, 'unchanged');
+    });
+
+    test('modified paragraph generates inline diff highlights', () => {
+      const docA = 'Follow this structure:';
+      const docB = 'Follow this structure. Only the first block is required:';
+      const tokensA = parseMarkdownBlocks(docA);
+      const tokensB = parseMarkdownBlocks(docB);
+
+      const rows = alignMarkdownTokens(tokensA, tokensB);
+      assert.equal(rows.length, 1);
+      assert.equal(rows[0].status, 'modified');
+      assert.include(rows[0].leftHtml!, 'Follow this structure');
+      assert.include(rows[0].rightHtml!, 'diff-highlight-add');
+      assert.include(rows[0].rightHtml!, 'Only the first block is required');
+    });
+
+    test('modified code block generates line diff highlights', () => {
+      const docA = '```bash\nline 1\nold code\nline 3\n```';
+      const docB = '```bash\nline 1\nnew code\nline 3\n```';
+      const tokensA = parseMarkdownBlocks(docA);
+      const tokensB = parseMarkdownBlocks(docB);
+
+      const rows = alignMarkdownTokens(tokensA, tokensB);
+      assert.equal(rows.length, 1);
+      assert.equal(rows[0].status, 'modified');
+      assert.include(rows[0].leftHtml!, 'diff-highlight-del');
+      assert.include(rows[0].leftHtml!, 'old code');
+      assert.include(rows[0].rightHtml!, 'diff-highlight-add');
+      assert.include(rows[0].rightHtml!, 'new code');
+    });
+
+    test('line numbers are correctly assigned to tokens and rows', () => {
+      const docA = '# Header\n\nLine 1\nLine 2';
+      const docB = '# Header\n\nLine 1\nLine 2 modified';
+      const tokensA = parseMarkdownBlocks(docA);
+      const tokensB = parseMarkdownBlocks(docB);
+
+      assert.equal(tokensA[0].startLine, 1);
+      assert.equal(tokensA[0].endLine, 1);
+      assert.equal(tokensA[1].startLine, 3);
+      assert.equal(tokensA[1].endLine, 4);
+
+      const rows = alignMarkdownTokens(tokensA, tokensB);
+      assert.equal(rows[0].leftStartLine, 1);
+      assert.equal(rows[0].leftEndLine, 1);
+      assert.equal(rows[0].rightStartLine, 1);
+      assert.equal(rows[0].rightEndLine, 1);
+
+      assert.equal(rows[1].leftStartLine, 3);
+      assert.equal(rows[1].leftEndLine, 4);
+      assert.equal(rows[1].rightStartLine, 3);
+      assert.equal(rows[1].rightEndLine, 4);
+    });
+  });
+
+  suite('attachThreadsToRows', () => {
+    test('attaches line threads to matching rows and separates file-level threads', () => {
+      const docA = '# Header\n\nParagraph 1\n\nParagraph 2';
+      const docB = '# Header\n\nParagraph 1 modified\n\nParagraph 2';
+      const tokensA = parseMarkdownBlocks(docA);
+      const tokensB = parseMarkdownBlocks(docB);
+      const rows = alignMarkdownTokens(tokensA, tokensB);
+
+      const fileThread: CommentThread = createCommentThread([
+        {
+          ...createComment(),
+          id: 'file-1' as UrlEncodedCommentId,
+          line: undefined,
+          side: CommentSide.REVISION,
+        },
+      ]);
+      const rightThread: CommentThread = createCommentThread([
+        {
+          ...createComment(),
+          id: 'right-1' as UrlEncodedCommentId,
+          line: 3,
+          side: CommentSide.REVISION,
+        },
+      ]);
+      const leftThread: CommentThread = createCommentThread([
+        {
+          ...createComment(),
+          id: 'left-1' as UrlEncodedCommentId,
+          line: 3,
+          side: CommentSide.PARENT,
+        },
+      ]);
+
+      const result = attachThreadsToRows(rows, [
+        fileThread,
+        rightThread,
+        leftThread,
+      ]);
+      assert.equal(result.fileLevelThreads.length, 1);
+      assert.equal(result.fileLevelThreads[0].rootId, 'file-1');
+
+      // Row 1 is "Paragraph 1 modified" which spans lines 3-3
+      assert.equal(result.rowsWithThreads[1].rightThreads.length, 1);
+      assert.equal(result.rowsWithThreads[1].rightThreads[0].rootId, 'right-1');
+
+      assert.equal(result.rowsWithThreads[1].leftThreads.length, 1);
+      assert.equal(result.rowsWithThreads[1].leftThreads[0].rootId, 'left-1');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 7631ef0..6adc5c6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -8,7 +8,7 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {DiffViewMode} from '../../../constants/constants';
 import {customElement, property, state} from 'lit/decorators.js';
-import {fireIronAnnounce} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {resolve} from '../../../models/dependency';
 import {css, html, LitElement} from 'lit';
@@ -120,7 +120,7 @@
       announcement = 'Changed diff view to side by side';
     }
     if (announcement) {
-      fireIronAnnounce(this, announcement);
+      fireAlert(this, announcement);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index bc8e26e..f3f96fc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -16,102 +16,167 @@
 import {DiffPreferencesInfo} from '../../../api/diff';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {assert, fixture, html} from '@open-wc/testing';
+import {MdOutlinedSelect} from '@material/web/select/outlined-select';
 
 suite('gr-diff-preferences-dialog', () => {
   let element: GrDiffPreferencesDialog;
   let originalDiffPrefs: DiffPreferencesInfo;
 
-  setup(async () => {
-    originalDiffPrefs = {
-      ...createDefaultDiffPrefs(),
-      line_wrapping: true,
-    };
+  suite('standard', () => {
+    setup(async () => {
+      originalDiffPrefs = {
+        ...createDefaultDiffPrefs(),
+        responsive_mode: 'FULL_RESPONSIVE',
+      };
 
-    stubRestApi('getDiffPreferences').returns(
-      Promise.resolve(originalDiffPrefs)
-    );
+      stubRestApi('getDiffPreferences').returns(
+        Promise.resolve(originalDiffPrefs)
+      );
 
-    element = await fixture<GrDiffPreferencesDialog>(html`
-      <gr-diff-preferences-dialog></gr-diff-preferences-dialog>
-    `);
-  });
+      element = await fixture<GrDiffPreferencesDialog>(html`
+        <gr-diff-preferences-dialog></gr-diff-preferences-dialog>
+      `);
+    });
 
-  test('render', () => {
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <dialog id="diffPrefsModal" tabindex="-1">
-          <div aria-labelledby="diffPreferencesTitle" role="dialog">
-            <h3 class="diffHeader heading-3" id="diffPreferencesTitle">
-              Diff Preferences
-            </h3>
-            <gr-diff-preferences id="diffPreferences"> </gr-diff-preferences>
-            <div class="diffActions">
-              <gr-button
-                aria-disabled="false"
-                id="cancelButton"
-                link=""
-                role="button"
-                tabindex="0"
-              >
-                Cancel
-              </gr-button>
-              <gr-button
-                aria-disabled="true"
-                disabled=""
-                id="saveButton"
-                link=""
-                primary=""
-                role="button"
-                tabindex="-1"
-              >
-                Save
-              </gr-button>
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <dialog id="diffPrefsModal" tabindex="-1">
+            <div aria-labelledby="diffPreferencesTitle" role="dialog">
+              <h3 class="diffHeader heading-3" id="diffPreferencesTitle">
+                Diff Preferences
+              </h3>
+              <gr-diff-preferences id="diffPreferences"> </gr-diff-preferences>
+              <div class="diffActions">
+                <gr-button
+                  aria-disabled="false"
+                  id="cancelButton"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Cancel
+                </gr-button>
+                <gr-button
+                  aria-disabled="true"
+                  disabled=""
+                  id="saveButton"
+                  link=""
+                  primary=""
+                  role="button"
+                  tabindex="-1"
+                >
+                  Save
+                </gr-button>
+              </div>
             </div>
-          </div>
-        </dialog>
-      `
-    );
+          </dialog>
+        `
+      );
+    });
+
+    test('changes applies only on save', async () => {
+      element.open();
+      await element.updateComplete;
+      assert.isUndefined(element.diffPrefsChanged);
+      assert.equal(
+        queryAndAssert<MdOutlinedSelect>(
+          queryAndAssert(element, '#diffPreferences'),
+          '#lineWrappingSelect'
+        ).value,
+        'FULL_RESPONSIVE'
+      );
+
+      const select = queryAndAssert<MdOutlinedSelect>(
+        queryAndAssert(element, '#diffPreferences'),
+        '#lineWrappingSelect'
+      );
+      select.value = 'NONE';
+      select.dispatchEvent(new Event('change'));
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<MdOutlinedSelect>(
+          queryAndAssert(element, '#diffPreferences'),
+          '#lineWrappingSelect'
+        ).value,
+        'NONE'
+      );
+      assert.isTrue(element.diffPrefsChanged);
+      assert.equal(originalDiffPrefs.responsive_mode, 'FULL_RESPONSIVE');
+
+      stubRestApi('saveDiffPreferences').resolves(
+        new Response(
+          makePrefixedJSON({
+            ...originalDiffPrefs,
+            responsive_mode: 'NONE',
+          })
+        )
+      );
+
+      queryAndAssert<GrButton>(element, '#saveButton').click();
+      await element.updateComplete;
+      // Original prefs must remains unchanged, dialog must expose a new object
+      assert.equal(originalDiffPrefs.responsive_mode, 'FULL_RESPONSIVE');
+      await waitUntil(() => element.diffPrefsChanged === false);
+    });
   });
 
-  test('changes applies only on save', async () => {
-    element.open();
-    await element.updateComplete;
-    assert.isUndefined(element.diffPrefsChanged);
-    assert.isTrue(
+  suite('legacy tests', () => {
+    let element: GrDiffPreferencesDialog;
+    let legacyPrefs: DiffPreferencesInfo;
+
+    setup(async () => {
+      legacyPrefs = {
+        ...createDefaultDiffPrefs(),
+        responsive_mode: undefined,
+        line_wrapping: true,
+      };
+      stubRestApi('getDiffPreferences').returns(Promise.resolve(legacyPrefs));
+      element = await fixture<GrDiffPreferencesDialog>(html`
+        <gr-diff-preferences-dialog></gr-diff-preferences-dialog>
+      `);
+    });
+
+    test('legacy changes applies only on save', async () => {
+      element.open();
+      await element.updateComplete;
+      assert.isUndefined(element.diffPrefsChanged);
+      assert.isTrue(
+        queryAndAssert<HTMLInputElement>(
+          queryAndAssert(element, '#diffPreferences'),
+          '#lineWrappingInput'
+        ).checked
+      );
+
       queryAndAssert<HTMLInputElement>(
         queryAndAssert(element, '#diffPreferences'),
         '#lineWrappingInput'
-      ).checked
-    );
+      ).click();
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(
+          queryAndAssert(element, '#diffPreferences'),
+          '#lineWrappingInput'
+        ).checked
+      );
+      assert.isTrue(element.diffPrefsChanged);
+      assert.isTrue(legacyPrefs.line_wrapping);
 
-    queryAndAssert<HTMLInputElement>(
-      queryAndAssert(element, '#diffPreferences'),
-      '#lineWrappingInput'
-    ).click();
-    await element.updateComplete;
-    assert.isFalse(
-      queryAndAssert<HTMLInputElement>(
-        queryAndAssert(element, '#diffPreferences'),
-        '#lineWrappingInput'
-      ).checked
-    );
-    assert.isTrue(element.diffPrefsChanged);
-    assert.isTrue(originalDiffPrefs.line_wrapping);
+      stubRestApi('saveDiffPreferences').resolves(
+        new Response(
+          makePrefixedJSON({
+            ...legacyPrefs,
+            line_wrapping: false,
+          })
+        )
+      );
 
-    stubRestApi('saveDiffPreferences').resolves(
-      new Response(
-        makePrefixedJSON({
-          ...originalDiffPrefs,
-          line_wrapping: false,
-        })
-      )
-    );
-
-    queryAndAssert<GrButton>(element, '#saveButton').click();
-    await element.updateComplete;
-    // Original prefs must remains unchanged, dialog must expose a new object
-    assert.isTrue(originalDiffPrefs.line_wrapping);
-    await waitUntil(() => element.diffPrefsChanged === false);
+      queryAndAssert<GrButton>(element, '#saveButton').click();
+      await element.updateComplete;
+      // Original prefs must remains unchanged, dialog must expose a new object
+      assert.isTrue(legacyPrefs.line_wrapping);
+      await waitUntil(() => element.diffPrefsChanged === false);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 85cf6cb..188e3e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -19,6 +19,8 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import '../../change/gr-download-dialog/gr-download-dialog';
+import '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../../../services/app-context';
 import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
 import {
@@ -38,6 +40,7 @@
   BasePatchSetNum,
   Comment,
   CommentMap,
+  CommentThread,
   DropdownLink,
   EDIT,
   NumericChangeId,
@@ -86,8 +89,10 @@
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ifDefined} from 'lit/directives/if-defined.js';
+import {ref} from 'lit/directives/ref.js';
+import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
-import {styleMap} from 'lit/directives/style-map.js';
+import {keyed} from 'lit/directives/keyed.js';
 import {
   ChangeChildView,
   changeViewModelToken,
@@ -99,7 +104,9 @@
   FileNameToNormalizedFileInfoMap,
   filesModelToken,
 } from '../../../models/change/files-model';
-import {isImageDiff} from '../../../utils/diff-util';
+import {isImageDiff, isMarkdownDiff} from '../../../utils/diff-util';
+import '../gr-diff-markdown-viewer/gr-diff-markdown-viewer';
+import type {GrDiffMarkdownViewer} from '../gr-diff-markdown-viewer/gr-diff-markdown-viewer';
 import {formStyles} from '../../../styles/form-styles';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 import {configModelToken} from '../../../models/config/config-model';
@@ -126,6 +133,8 @@
 
 @customElement('gr-diff-view')
 export class GrDiffView extends LitElement {
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   /**
    * Fired when user tries to navigate away while comments are pending save.
    *
@@ -134,9 +143,15 @@
   @query('#diffHost')
   diffHost?: GrDiffHost;
 
+  @query('#markdownViewer')
+  markdownViewer?: GrDiffMarkdownViewer;
+
   @state()
   reviewed = false;
 
+  @state()
+  showRichMarkdown = false;
+
   @query('#downloadModal')
   downloadModal?: HTMLDialogElement;
 
@@ -152,11 +167,6 @@
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrDiffPreferencesDialog;
 
-  @query('.sidebarAnchor')
-  sidebarAnchor?: HTMLDivElement;
-
-  @state() private sidebarHeight = 0;
-
   // Private but used in tests.
   @state()
   get patchRange(): PatchRange | undefined {
@@ -168,6 +178,16 @@
   }
 
   // Private but used in tests.
+  get threadsForFile(): CommentThread[] {
+    if (!this.changeComments || !this.path || !this.patchRange) return [];
+    const file = this.files?.changeFilesByPath?.[this.path];
+    return this.changeComments.getThreadsBySideForFile(
+      {path: this.path, basePath: file?.old_path},
+      this.patchRange
+    );
+  }
+
+  // Private but used in tests.
   @state()
   patchNum?: RevisionPatchSetNum;
 
@@ -202,7 +222,8 @@
 
   @state() file?: NormalizedFileInfo;
 
-  @state() private shownSidebar?: string;
+  // Private but used in tests.
+  @state() shownSidebar?: string;
 
   /** Allows us to react when the user switches to the DIFF view. */
   // Private but used in tests.
@@ -241,6 +262,11 @@
   @state()
   focusLineNum?: number;
 
+  /** Directly reflects the view model property `diffView.endLineNum`. */
+  // Private but used in tests.
+  @state()
+  focusEndLineNum?: number;
+
   /** Directly reflects the view model property `diffView.leftSide`. */
   @state()
   leftSide = false;
@@ -280,6 +306,18 @@
 
   private readonly shortcutsController = new ShortcutController(this);
 
+  private stickyHeaderEl?: HTMLElement;
+
+  private readonly headerResizeObserver = new ResizeObserver(entries => {
+    for (const entry of entries) {
+      const height = entry.borderBoxSize[0].blockSize;
+      document.documentElement.style.setProperty(
+        '--diff-header-height',
+        `${height}px`
+      );
+    }
+  });
+
   constructor() {
     super();
     this.setupKeyboardShortcuts();
@@ -447,6 +485,13 @@
     );
     subscribe(
       this,
+      () => this.getViewModel().diffEndLine$,
+      endLine => {
+        this.focusEndLineNum = endLine;
+      }
+    );
+    subscribe(
+      this,
       () => this.getViewModel().diffLeftSide$,
       leftSide => (this.leftSide = leftSide)
     );
@@ -455,9 +500,13 @@
       () => this.getViewModel().patchNum$,
       patchNum => (this.patchNum = patchNum)
     );
+    // Note that this is the change model, not the view model, so that the
+    // `default_base_for_merges` preference is applied to diff URLs that do not
+    // specify a base, just like it is applied in the change view. An explicitly
+    // chosen auto-merge base is encoded as `0` in the URL, so it survives this.
     subscribe(
       this,
-      () => this.getViewModel().basePatchNum$,
+      () => this.getChangeModel().basePatchNum$,
       basePatchNum => (this.basePatchNum = basePatchNum ?? PARENT)
     );
     subscribe(
@@ -525,7 +574,9 @@
         :host {
           display: block;
           background-color: var(--view-background-color);
-          --sidebar-width: 300px;
+          --sidebar-top: calc(
+            var(--main-header-height) + var(--diff-header-height, 80px)
+          );
         }
         .hidden {
           display: none;
@@ -533,6 +584,7 @@
         .headerLeft {
           display: flex;
           align-items: center;
+          min-width: 0;
         }
         gr-patch-range-select {
           display: block;
@@ -568,7 +620,9 @@
           margin-right: var(--spacing-m);
           font-weight: var(--font-weight-medium);
           white-space: nowrap;
-          overflow: auto;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          flex-shrink: 100;
         }
         .patchRangeLeft {
           align-items: center;
@@ -592,6 +646,8 @@
         .jumpToFileContainer {
           display: inline-block;
           word-break: break-all;
+          min-width: 0;
+          overflow: hidden;
         }
         .mobile {
           display: none;
@@ -701,43 +757,46 @@
         :host(.hideCheckCodePointers) {
           --gr-check-code-pointers-display: none;
         }
-        .diffContainer.sidebarOpen {
-          margin-left: var(--sidebar-width);
-        }
         .sidebarTriggerContainer {
           display: inline-block;
           margin-right: var(--spacing-m);
         }
-        .sidebarAnchor {
-          height: 0;
-          width: 0;
-          overflow: visible;
-        }
         .sidebarContents {
           background: var(--background-color-secondary);
-          width: var(--sidebar-width);
-          border: var(--spacing-xxs) solid var(--border-color);
-          border-left: 0;
-          overflow: auto;
+          box-sizing: border-box;
+          height: 100%;
         }
         md-checkbox {
           --md-checkbox-container-size: 15px;
           --md-checkbox-icon-size: 15px;
         }
+        .expensiveDiff {
+          color: var(--warning-foreground);
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-m) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          gap: var(--spacing-s);
+        }
+        gr-endpoint-decorator:has([replace-content]) gr-diff-host {
+          display: none;
+        }
       `,
     ];
   }
 
   override connectedCallback() {
     super.connectedCallback();
+    if (this.stickyHeaderEl) {
+      this.headerResizeObserver.observe(this.stickyHeaderEl);
+    }
     this.throttledToggleFileReviewed = throttleWrap(_ =>
       this.handleToggleFileReviewed()
     );
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
     this.cursor = new GrDiffCursor();
     if (this.diffHost) this.reInitCursor();
-    window.addEventListener('scroll', this.updateSidebarHeight);
-    window.addEventListener('resize', this.updateSidebarHeight);
     this.getUserModel()
       .preferences$.pipe(
         map(p => p.diff_page_sidebar),
@@ -755,8 +814,8 @@
 
   override disconnectedCallback() {
     this.cursor?.dispose();
-    window.removeEventListener('scroll', this.updateSidebarHeight);
-    window.removeEventListener('resize', this.updateSidebarHeight);
+    this.headerResizeObserver.disconnect();
+    document.documentElement.style.setProperty('--diff-header-height', '0px');
     super.disconnectedCallback();
   }
 
@@ -766,13 +825,6 @@
     this.cursor?.reInitCursor();
   }
 
-  private readonly updateSidebarHeight = () => {
-    if (this.sidebarAnchor) {
-      this.sidebarHeight =
-        window.innerHeight - this.sidebarAnchor.getBoundingClientRect().bottom;
-    }
-  };
-
   protected override updated(changedProperties: PropertyValues): void {
     super.updated(changedProperties);
     if (
@@ -790,31 +842,42 @@
     }
     if (
       changedProperties.has('focusLineNum') ||
+      changedProperties.has('focusEndLineNum') ||
       changedProperties.has('leftSide')
     ) {
       this.initCursor();
     }
+    if (changedProperties.has('showRichMarkdown') && !this.showRichMarkdown) {
+      this.reInitCursor();
+    }
     if (
       changedProperties.has('change') ||
       changedProperties.has('changeComments') ||
       changedProperties.has('path') ||
       changedProperties.has('patchNum') ||
       changedProperties.has('basePatchNum') ||
-      changedProperties.has('files')
+      changedProperties.has('files') ||
+      changedProperties.has('showRichMarkdown')
     ) {
       if (this.change && this.changeComments && this.path && this.patchRange) {
         assertIsDefined(this.diffHost, 'diffHost');
         const file = this.files?.changeFilesByPath?.[this.path];
-        this.diffHost.updateComplete.then(() => {
-          assertIsDefined(this.path);
-          assertIsDefined(this.patchRange);
-          assertIsDefined(this.diffHost);
-          assertIsDefined(this.changeComments);
-          this.diffHost.threads = this.changeComments.getThreadsBySideForFile(
-            {path: this.path, basePath: file?.old_path},
-            this.patchRange
-          );
-        });
+        if (!this.isShowingRichMarkdown()) {
+          this.diffHost.disabledThreads = false;
+          this.diffHost.updateComplete.then(() => {
+            assertIsDefined(this.path);
+            assertIsDefined(this.patchRange);
+            assertIsDefined(this.diffHost);
+            assertIsDefined(this.changeComments);
+            this.diffHost.threads = this.changeComments.getThreadsBySideForFile(
+              {path: this.path, basePath: file?.old_path},
+              this.patchRange
+            );
+          });
+        } else {
+          this.diffHost.disabledThreads = true;
+          this.diffHost.threads = [];
+        }
       }
     }
     if (
@@ -831,7 +894,6 @@
         this.patchRange
       );
     }
-    this.updateSidebarHeight();
   }
 
   override render() {
@@ -843,33 +905,86 @@
     return html`
       ${this.renderStickyHeader()}
       <h2 class="assistive-tech-only">Diff view</h2>
-      <div class="diffContainer ${this.shownSidebar && 'sidebarOpen'}">
-        <gr-diff-host
-          id="diffHost"
-          .changeNum=${this.changeNum}
-          .change=${this.change}
-          .patchRange=${this.patchRange}
-          .file=${file}
-          .lineOfInterest=${this.getLineOfInterest()}
-          .path=${this.path}
-          .projectName=${this.change?.project}
-          @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
-          @comment-anchor-tap=${this.onCommentAnchorTap}
-          @line-selected=${this.onLineSelected}
-          @diff-changed=${this.onDiffChanged}
-          @edit-weblinks-changed=${this.onEditWeblinksChanged}
-          @files-weblinks-changed=${this.onFilesWeblinksChanged}
-          @render=${this.reInitCursor}
+      ${when(
+        this.file?.diffs_too_expensive_to_compute,
+        () => html`
+          <div class="expensiveDiff">
+            <gr-icon icon="warning"></gr-icon>
+            Diff too expensive to compute.
+            <gr-button link @click=${this.handleOpenDownloadDialog}>
+              Please download locally to review
+            </gr-button>
+          </div>
+        `
+      )}
+      <gr-content-with-sidebar
+        .side=${this.getSidebarSide()}
+        .hideSide=${!this.shownSidebar}
+      >
+        <div
+          slot="main"
+          class=${classMap({
+            diffContainer: true,
+            sidebarOpen: !!this.shownSidebar,
+            hidden: !!this.file?.diffs_too_expensive_to_compute,
+          })}
         >
-        </gr-diff-host>
-      </div>
+          ${when(
+            this.isShowingRichMarkdown(),
+            () => html`
+              <gr-diff-markdown-viewer
+                id="markdownViewer"
+                .diff=${this.diff}
+                .path=${this.path}
+                .patchRange=${this.patchRange}
+                .threads=${this.threadsForFile}
+                .loggedIn=${this.loggedIn}
+              ></gr-diff-markdown-viewer>
+            `
+          )}
+          <gr-endpoint-decorator
+            name="diff-content"
+            ?hidden=${this.isShowingRichMarkdown()}
+          >
+            <gr-diff-host
+              id="diffHost"
+              ?hidden=${this.isShowingRichMarkdown()}
+              ?disabledThreads=${this.isShowingRichMarkdown()}
+              .changeNum=${this.changeNum}
+              .change=${this.change}
+              .patchRange=${this.patchRange}
+              .file=${file}
+              .lineOfInterest=${this.getLineOfInterest()}
+              .path=${this.path}
+              .projectName=${this.change?.project}
+              @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
+              @comment-anchor-tap=${this.onCommentAnchorTap}
+              @line-selected=${this.onLineSelected}
+              @diff-changed=${this.onDiffChanged}
+              @edit-weblinks-changed=${this.onEditWeblinksChanged}
+              @files-weblinks-changed=${this.onFilesWeblinksChanged}
+              @render=${this.reInitCursor}
+            >
+            </gr-diff-host>
+          </gr-endpoint-decorator>
+        </div>
+        <div slot="side">${this.renderSidebarContent()}</div>
+      </gr-content-with-sidebar>
       ${this.renderDialogs()}
     `;
   }
 
+  private onStickyHeaderCreated(el?: Element) {
+    this.stickyHeaderEl = el as HTMLElement | undefined;
+    if (el) {
+      this.headerResizeObserver.observe(el);
+    }
+  }
+
   private renderStickyHeader() {
     return html` <div
       class="stickyHeader ${this.patchNum === EDIT ? 'editMode' : ''}"
+      ${ref(this.onStickyHeaderCreated)}
     >
       <h1 class="assistive-tech-only">
         Diff of ${this.path ? computeTruncatedPath(this.path) : ''}
@@ -887,7 +1002,6 @@
           >&gt;</a
         >
       </div>
-      ${this.renderSidebarContent()}
     </div>`;
   }
 
@@ -901,11 +1015,9 @@
             >${this.changeNum}</a
           ><span class="changeNumberColon">:</span>
         </div>
-        <div>
-          <span class="headerSubject"
-            >${trimWithEllipsis(this.change?.subject, 80)}</span
-          >
-        </div>
+        <span class="headerSubject"
+          >${trimWithEllipsis(this.change?.subject, 80)}</span
+        >
         <div class="checkboxDiv">
           <md-checkbox
             id="reviewed"
@@ -990,17 +1102,35 @@
     `;
   }
 
+  private getSidebarSide(): 'left' | 'right' {
+    if (!this.shownSidebar) {
+      return 'left';
+    }
+    const details = this.getPluginLoader().pluginEndPoints.getDetails(
+      `sidebarContent-${this.shownSidebar}`
+    );
+    for (const info of details) {
+      if (info.moduleName) {
+        const customElement = customElements.get(info.moduleName) as
+          | {sidebarPosition?: string}
+          | undefined;
+        // Bracket notation prevents Closure Compiler from mangling the property name
+        // when plugins are minified.
+        if (customElement?.['sidebarPosition'] === 'right') {
+          return 'right';
+        }
+      }
+    }
+    return 'left';
+  }
+
   private renderSidebarContent() {
-    // Always renders the 0x0px .sidebarAnchor div for scroll measurements.
     return html`
-      <div class="sidebarAnchor">
-        ${when(
-          this.shownSidebar !== undefined,
-          () => html`
-            <div
-              class="sidebarContents"
-              style=${styleMap({height: `${this.sidebarHeight}px`})}
-            >
+      ${when(this.shownSidebar !== undefined, () =>
+        keyed(
+          this.shownSidebar,
+          html`
+            <div class="sidebarContents">
               <gr-endpoint-decorator
                 name=${`sidebarContent-${this.shownSidebar}`}
               >
@@ -1047,7 +1177,9 @@
                     // Only close the sidebar if that particular sidebar is
                     // still open. An async onClose callback should not close a
                     // different sidebar.
-                    if (this.shownSidebar !== pluginName) return;
+                    if (this.shownSidebar !== pluginName) {
+                      return;
+                    }
                     this.shownSidebar = undefined;
                     this.getUserModel().updatePreferences({
                       diff_page_sidebar: 'NONE',
@@ -1058,8 +1190,8 @@
               </gr-endpoint-decorator>
             </div>
           `
-        )}
-      </div>
+        )
+      )}
     `;
   }
 
@@ -1092,8 +1224,11 @@
   private renderRightControls() {
     const diffModeSelectorClass = !this.diff || this.diff.binary ? 'hide' : '';
     return html` <div class="rightControls">
+      <gr-endpoint-decorator
+        name="diff-header-controls"
+      ></gr-endpoint-decorator>
       ${this.renderSidebarTriggers()} ${this.renderShowEntireFileButton()}
-      ${this.renderBlameButton()}
+      ${this.renderBlameButton()} ${this.renderRichMarkdownToggle()}
       ${when(
         this.computeCanEdit(),
         () => html`
@@ -1201,6 +1336,45 @@
       </span>`;
   }
 
+  private renderRichMarkdownToggle() {
+    if (!isMarkdownDiff(this.path, this.diff)) return nothing;
+    return html`<span class="separator"></span
+      ><span class="richMarkdownToggle">
+        <gr-tooltip-content
+          has-tooltip=""
+          position-below=""
+          title=${this.showRichMarkdown
+            ? 'Switch to source diff'
+            : 'Switch to rich rendered Markdown diff'}
+        >
+          <gr-button
+            link=""
+            id="toggleRichMarkdown"
+            @click=${this.toggleRichMarkdown}
+          >
+            <gr-icon
+              icon=${this.showRichMarkdown ? 'code' : 'preview'}
+              filled=""
+            ></gr-icon>
+            ${this.showRichMarkdown ? 'Source diff' : 'Rich diff'}
+          </gr-button>
+        </gr-tooltip-content>
+      </span>`;
+  }
+
+  async toggleRichMarkdown() {
+    if (this.isShowingRichMarkdown()) {
+      await this.markdownViewer?.autoSaveDrafts();
+    } else {
+      await this.diffHost?.autoSaveDrafts();
+    }
+    this.showRichMarkdown = !this.showRichMarkdown;
+  }
+
+  private isShowingRichMarkdown(): boolean {
+    return this.showRichMarkdown && isMarkdownDiff(this.path, this.diff);
+  }
+
   private renderDialogs() {
     return html`
       <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
@@ -1320,6 +1494,10 @@
   private handleNewComment() {
     this.classList.remove('hideComments');
     this.classList.remove('hideCheckCodePointers');
+    if (this.isShowingRichMarkdown()) {
+      this.markdownViewer?.createCommentFromSelectionOrHover();
+      return;
+    }
     this.cursor?.createCommentInPlace();
   }
 
@@ -1417,7 +1595,8 @@
     this.diffHost.toggleLeftDiff();
   }
 
-  private handleOpenDownloadDialog() {
+  // private but used in tests
+  handleOpenDownloadDialog() {
     assertIsDefined(this.downloadModal, 'downloadModal');
     this.downloadModal.showModal();
     whenVisible(this.downloadModal, () => {
@@ -1540,13 +1719,18 @@
     return {path: fileList[idx]};
   }
 
-  private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
+  private updateUrlToDiffUrl(
+    lineNum?: number,
+    leftSide?: boolean,
+    endLineNum?: number
+  ) {
     if (!this.path || !this.patchNum) return;
     const url = this.getViewModel().diffUrl({
       diffView: {
         path: this.path,
         lineNum,
         leftSide,
+        endLineNum,
       },
       patchNum: this.patchNum,
     });
@@ -1584,10 +1768,24 @@
    * Private but used in tests.
    */
   initCursor() {
-    if (!this.focusLineNum) return;
-    if (!this.cursor) return;
+    if (!this.focusLineNum) {
+      return;
+    }
+    if (!this.cursor) {
+      return;
+    }
     this.cursor.side = this.leftSide ? Side.LEFT : Side.RIGHT;
     this.cursor.initialLineNumber = this.focusLineNum;
+    this.cursor.initialEndLineNumber = this.focusEndLineNum;
+    if (this.diffHost?.diffElement && !this.diffHost.diffElement.loading) {
+      this.cursor.moveToLineNumber(
+        this.focusLineNum,
+        this.cursor.side,
+        this.path,
+        true,
+        this.focusEndLineNum
+      );
+    }
   }
 
   // Private but used in tests.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index f8fd10e..d015b62 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -28,6 +28,7 @@
   createConfig,
   createDiff,
   createDiffViewState,
+  createDraft,
   createFileInfo,
   createParsedChange,
   createRange,
@@ -40,6 +41,7 @@
 import {
   BasePatchSetNum,
   CommentInfo,
+  DraftInfo,
   EDIT,
   NumericChangeId,
   PARENT,
@@ -51,7 +53,9 @@
 } from '../../../types/common';
 import {CursorMoveResult} from '../../../api/core';
 import {Side} from '../../../api/diff';
+import {PluginApi} from '../../../api/plugin';
 import {Files, GrDiffView} from './gr-diff-view';
+import {GrContentWithSidebar} from '../../shared/gr-content-with-sidebar/gr-content-with-sidebar';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {SinonFakeTimers, SinonStub, SinonStubbedMember} from 'sinon';
 import {
@@ -62,6 +66,8 @@
 import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {
@@ -79,6 +85,7 @@
 import {MdCheckbox} from '@material/web/checkbox/checkbox';
 import {FileNameToNormalizedFileInfoMap} from '../../../models/change/files-model';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {createNew} from '../../../utils/comment-util';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {LoadingStatus} from '../../../types/types';
 import {RunResult} from '../../../models/checks/checks-model';
@@ -182,6 +189,28 @@
       sinon.restore();
     });
 
+    test('expensive diff warning', async () => {
+      const openDownloadDialogStub = sinon.stub(
+        element,
+        'handleOpenDownloadDialog'
+      );
+      element.file = {
+        ...createFileInfo(),
+        __path: 'some/path.txt',
+        diffs_too_expensive_to_compute: true,
+      };
+      await element.updateComplete;
+      const warning = queryAndAssert(element, '.expensiveDiff');
+      assert.include(warning.textContent, 'Diff too expensive to compute');
+
+      const diffContainer = queryAndAssert(element, '.diffContainer');
+      assert.isTrue(diffContainer?.classList.contains('hidden'));
+
+      const downloadButton = queryAndAssert<GrButton>(warning, 'gr-button');
+      downloadButton.click();
+      await waitUntil(() => openDownloadDialogStub.called);
+    });
+
     test('toggle left diff with a hotkey', () => {
       assertIsDefined(element.diffHost);
       const toggleLeftDiffStub = sinon.stub(element.diffHost, 'toggleLeftDiff');
@@ -222,9 +251,7 @@
                   <a href="/c/test-project/+/42"> 42 </a>
                   <span class="changeNumberColon"> : </span>
                 </div>
-                <div>
-                  <span class="headerSubject"> Test subject </span>
-                </div>
+                <span class="headerSubject"> Test subject </span>
                 <div class="checkboxDiv">
                   <md-checkbox
                     class="hideOnEdit reviewed"
@@ -281,6 +308,8 @@
                 </span>
               </div>
               <div class="rightControls">
+                <gr-endpoint-decorator name="diff-header-controls">
+                </gr-endpoint-decorator>
                 <div class="sidebarTriggerContainer">
                   <gr-endpoint-decorator name="sidebarTrigger">
                     <gr-endpoint-param name="onTrigger"> </gr-endpoint-param>
@@ -374,12 +403,16 @@
                 >
               </a>
             </div>
-            <div class="sidebarAnchor"></div>
           </div>
           <h2 class="assistive-tech-only">Diff view</h2>
-          <div class="diffContainer">
-            <gr-diff-host id="diffHost"> </gr-diff-host>
-          </div>
+          <gr-content-with-sidebar>
+            <div class="diffContainer" slot="main">
+              <gr-endpoint-decorator name="diff-content">
+                <gr-diff-host id="diffHost"> </gr-diff-host>
+              </gr-endpoint-decorator>
+            </div>
+            <div slot="side"></div>
+          </gr-content-with-sidebar>
           <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
           <gr-diff-preferences-dialog id="diffPreferencesDialog">
           </gr-diff-preferences-dialog>
@@ -391,6 +424,51 @@
       );
     });
 
+    test('nav links stay inside the header when space runs out', async () => {
+      element.change = {
+        ...createParsedChange(),
+        subject:
+          'A change subject that is long enough to overflow a narrow header',
+      };
+      element.path =
+        'polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts';
+      element.files = getFilesFromFileList([element.path]);
+      const forceDesktop = document.createElement('style');
+      forceDesktop.textContent = '.navLinks.desktop { display: flex; }';
+      element.shadowRoot!.appendChild(forceDesktop);
+      await element.updateComplete;
+
+      const header = queryAndAssert(element, 'header');
+      const navLinks = queryAndAssert(element, '.navLinks');
+      const subject = queryAndAssert(element, '.headerSubject');
+      assert.notEqual(getComputedStyle(navLinks).display, 'none');
+      const naturalSubjectWidth = subject.getBoundingClientRect().width;
+
+      element.style.width = '600px';
+      await element.updateComplete;
+
+      assert.isAtMost(
+        navLinks.getBoundingClientRect().right,
+        header.getBoundingClientRect().right,
+        'nav links overflow the header'
+      );
+      assert.isAtMost(
+        header.scrollWidth,
+        header.clientWidth,
+        'header content overflows the header box'
+      );
+      assert.isBelow(
+        subject.getBoundingClientRect().width,
+        naturalSubjectWidth,
+        'subject should absorb the width deficit'
+      );
+      assert.isAbove(
+        subject.scrollWidth,
+        subject.clientWidth,
+        'subject should be ellipsized rather than pushing the nav links out'
+      );
+    });
+
     test('keyboard shortcuts', async () => {
       clock = sinon.useFakeTimers({
         toFake: ['Date'],
@@ -1882,6 +1960,287 @@
       });
     });
 
+    suite('rich markdown diff', () => {
+      test('toggle rich markdown diff', async () => {
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}, {a: ['Old line'], b: ['New line']}],
+        };
+        await element.updateComplete;
+
+        const toggleBtn = element.shadowRoot!.querySelector<GrButton>(
+          '#toggleRichMarkdown'
+        );
+        assert.isNotNull(toggleBtn);
+        assert.isFalse(element.showRichMarkdown);
+
+        await element.toggleRichMarkdown();
+        await element.updateComplete;
+
+        assert.isTrue(element.showRichMarkdown);
+        const markdownViewer =
+          element.shadowRoot!.querySelector('#markdownViewer');
+        assert.isNotNull(markdownViewer);
+
+        await element.toggleRichMarkdown();
+        await element.updateComplete;
+
+        assert.isFalse(element.showRichMarkdown);
+      });
+
+      test('toggle is hidden for non-markdown files', async () => {
+        element.path = 'foo.txt';
+        element.diff = createDiff();
+        await element.updateComplete;
+
+        const toggleBtn = element.shadowRoot!.querySelector(
+          '#toggleRichMarkdown'
+        );
+        assert.isNull(toggleBtn);
+      });
+
+      test('handleNewComment delegates to markdownViewer when rich markdown is active', async () => {
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}],
+        };
+        element.showRichMarkdown = true;
+        await element.updateComplete;
+
+        assert.isDefined(element.markdownViewer);
+        const createCommentSpy = sinon.spy(
+          element.markdownViewer,
+          'createCommentFromSelectionOrHover'
+        );
+
+        // Simulate new comment shortcut/action
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        (element as any).handleNewComment();
+
+        assert.isTrue(createCommentSpy.calledOnce);
+      });
+
+      test('c and C shortcuts delegate to markdownViewer when rich markdown is active', async () => {
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}],
+        };
+        element.showRichMarkdown = true;
+        await element.updateComplete;
+
+        assert.isDefined(element.markdownViewer);
+        const createCommentSpy = sinon.spy(
+          element.markdownViewer,
+          'createCommentFromSelectionOrHover'
+        );
+
+        pressKey(element, 'c');
+        assert.isTrue(createCommentSpy.calledOnce);
+
+        pressKey(element, 'C');
+        assert.isTrue(createCommentSpy.calledTwice);
+      });
+
+      test('toggleRichMarkdown flushes diffHost drafts before switching to rich mode', async () => {
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}],
+        };
+        element.showRichMarkdown = false;
+        await element.updateComplete;
+
+        assert.isDefined(element.diffHost);
+        const autoSaveSpy = sinon.spy(element.diffHost, 'autoSaveDrafts');
+
+        await element.toggleRichMarkdown();
+        await element.updateComplete;
+
+        assert.isTrue(autoSaveSpy.calledOnce);
+        assert.isTrue(element.showRichMarkdown);
+      });
+
+      test('toggleRichMarkdown flushes markdownViewer drafts before switching to source mode', async () => {
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}],
+        };
+        element.showRichMarkdown = true;
+        await element.updateComplete;
+
+        assert.isDefined(element.markdownViewer);
+        const autoSaveSpy = sinon.spy(element.markdownViewer, 'autoSaveDrafts');
+
+        await element.toggleRichMarkdown();
+        await element.updateComplete;
+
+        assert.isTrue(autoSaveSpy.calledOnce);
+        assert.isFalse(element.showRichMarkdown);
+      });
+
+      test('toggling from rich markdown back to source diff updates diffHost threads', async () => {
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}],
+        };
+        element.showRichMarkdown = true;
+        await element.updateComplete;
+
+        commentsModel.setState({
+          comments: {},
+          drafts: {
+            'README.md': [
+              createDraft({
+                id: 'draft_1' as UrlEncodedCommentId,
+                line: 1,
+                message: 'Draft from rich mode',
+                patch_set: 1 as RevisionPatchSetNum,
+              }),
+            ],
+          },
+          portedComments: {},
+          portedDrafts: {},
+          discardedDrafts: [],
+        });
+        await element.updateComplete;
+        await element.diffHost?.updateComplete;
+
+        // Toggle back to source diff
+        element.showRichMarkdown = false;
+        await element.updateComplete;
+        await element.diffHost?.updateComplete;
+
+        assert.isDefined(element.diffHost);
+        assert.equal(element.diffHost.threads.length, 1);
+        assert.equal(element.diffHost.threads[0].line, 1);
+      });
+
+      test('toggling from rich markdown back to source diff preserves saved draft without empty composer', async () => {
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}],
+        };
+        element.showRichMarkdown = true;
+        await element.updateComplete;
+
+        assert.isDefined(element.diffHost);
+        assert.isTrue(element.diffHost.hidden);
+        assert.equal(element.diffHost.threads.length, 0);
+
+        commentsModel.setState({
+          comments: {},
+          drafts: {
+            'README.md': [
+              createDraft({
+                id: 'draft_1' as UrlEncodedCommentId,
+                line: 7,
+                message: '123',
+                patch_set: 1 as RevisionPatchSetNum,
+              }),
+            ],
+          },
+          portedComments: {},
+          portedDrafts: {},
+          discardedDrafts: [],
+        });
+        await element.updateComplete;
+        await element.diffHost?.updateComplete;
+
+        // Toggle back to source diff
+        element.showRichMarkdown = false;
+        await element.updateComplete;
+        await element.diffHost?.updateComplete;
+
+        assert.isFalse(element.diffHost.hidden);
+        assert.equal(element.diffHost.threads.length, 1);
+        assert.equal(element.diffHost.threads[0].line, 7);
+        assert.equal(element.diffHost.threads[0].comments[0].message, '123');
+      });
+
+      test('toggling rich to source once renders saved draft in view mode, not editing composer', async () => {
+        stubRestApi('saveDiffDraft').callsFake((_changeNum, _patchNum, draft) =>
+          Promise.resolve({
+            ok: true,
+            text: () =>
+              Promise.resolve(
+                ")]}'\n" +
+                  JSON.stringify({
+                    ...draft,
+                    id: 'draft_saved_1',
+                    updated: '2026-09-03 12:21:00.000000000',
+                  })
+              ),
+          } as Response)
+        );
+
+        element.path = 'README.md';
+        element.diff = {
+          ...createDiff(),
+          content: [{ab: ['# Title']}],
+        };
+        element.showRichMarkdown = true;
+        await element.updateComplete;
+
+        // 1. User adds a draft while in rich markdown diff mode
+        const draft: DraftInfo = {
+          ...createNew('', true),
+          path: 'README.md',
+          patch_set: 1 as RevisionPatchSetNum,
+          line: 7,
+          message: '',
+        };
+        commentsModel.addNewDraft(draft);
+        await element.updateComplete;
+        await element.diffHost?.updateComplete;
+
+        // While in rich markdown, diffHost is hidden and must not render threads
+        assert.isTrue(element.diffHost?.hidden);
+        assert.equal(element.diffHost?.threads.length, 0);
+
+        // 2. User saves the draft in rich markdown diff mode
+        await commentsModel.saveDraft({
+          ...draft,
+          message: 'some full line',
+        });
+        await element.updateComplete;
+        await element.diffHost?.updateComplete;
+
+        // Still hidden and no threads in diffHost
+        assert.equal(element.diffHost?.threads.length, 0);
+
+        // 3. User toggles from rich to source ONCE
+        element.showRichMarkdown = false;
+        await element.updateComplete;
+        await element.diffHost?.updateComplete;
+
+        // 4. Assert diffHost renders the saved comment, and it is NOT in edit mode
+        assertIsDefined(element.diffHost);
+        assert.isFalse(element.diffHost.hidden);
+        assert.equal(element.diffHost.threads.length, 1);
+        assert.equal(element.diffHost.threads[0].line, 7);
+        assert.equal(
+          element.diffHost.threads[0].comments[0].message,
+          'some full line'
+        );
+
+        const threadEl = queryAndAssert<GrCommentThread>(
+          element.diffHost,
+          'gr-comment-thread'
+        );
+        await threadEl.updateComplete;
+        const commentEl = queryAndAssert<GrComment>(threadEl, 'gr-comment');
+        await commentEl.updateComplete;
+
+        assert.isFalse(commentEl.editing);
+      });
+    });
+
     suite('editMode behavior', () => {
       setup(async () => {
         element.loggedIn = true;
@@ -2235,5 +2594,103 @@
         'Button should be hidden for image diffs'
       );
     });
+
+    suite('sidebar', () => {
+      test('switching sidebars dismounts old component and mounts new one', async () => {
+        element.shownSidebar = 'sidebar-a';
+        await element.updateComplete;
+        const oldDecorator = element.shadowRoot?.querySelector(
+          '.sidebarContents gr-endpoint-decorator'
+        );
+        assert.isNotNull(oldDecorator);
+        assert.equal(
+          oldDecorator?.getAttribute('name'),
+          'sidebarContent-sidebar-a'
+        );
+
+        element.shownSidebar = 'sidebar-b';
+        await element.updateComplete;
+        const newDecorator = element.shadowRoot?.querySelector(
+          '.sidebarContents gr-endpoint-decorator'
+        );
+        assert.isNotNull(newDecorator);
+        assert.equal(
+          newDecorator?.getAttribute('name'),
+          'sidebarContent-sidebar-b'
+        );
+        assert.notEqual(oldDecorator, newDecorator);
+      });
+
+      test('defaults to left sidebar side when no sidebarPosition property set', async () => {
+        element.shownSidebar = 'left-sidebar';
+        await element.updateComplete;
+
+        const contentWithSidebar =
+          element.shadowRoot?.querySelector<GrContentWithSidebar>(
+            'gr-content-with-sidebar'
+          );
+        assert.isNotNull(contentWithSidebar);
+        assert.equal(contentWithSidebar?.side, 'left');
+      });
+
+      test('detects right sidebar side when static sidebarPosition === "right"', async () => {
+        class RightSidebar extends HTMLElement {
+          static sidebarPosition = 'right';
+        }
+
+        customElements.define('right-sidebar-element', RightSidebar);
+
+        let plugin!: PluginApi;
+        window.Gerrit.install(
+          p => (plugin = p),
+          '0.1',
+          'http://test.com/plugins/testplugin/static/test.js'
+        );
+        plugin.registerDynamicCustomComponent(
+          'sidebarContent',
+          'right-sidebar-element'
+        );
+
+        element.shownSidebar = 'testplugin';
+        await element.updateComplete;
+
+        const contentWithSidebar =
+          element.shadowRoot?.querySelector<GrContentWithSidebar>(
+            'gr-content-with-sidebar'
+          );
+        assert.equal(contentWithSidebar?.side, 'right');
+      });
+
+      test('detects right sidebar side when static readonly \'sidebarPosition\' === "right"', async () => {
+        class RightSidebarBracket extends HTMLElement {
+          static readonly 'sidebarPosition' = 'right';
+        }
+
+        customElements.define(
+          'right-sidebar-bracket-element',
+          RightSidebarBracket
+        );
+
+        let plugin!: PluginApi;
+        window.Gerrit.install(
+          p => (plugin = p),
+          '0.1',
+          'http://test.com/plugins/testplugin-bracket/static/test.js'
+        );
+        plugin.registerDynamicCustomComponent(
+          'sidebarContent',
+          'right-sidebar-bracket-element'
+        );
+
+        element.shownSidebar = 'testplugin-bracket';
+        await element.updateComplete;
+
+        const contentWithSidebar =
+          element.shadowRoot?.querySelector<GrContentWithSidebar>(
+            'gr-content-with-sidebar'
+          );
+        assert.equal(contentWithSidebar?.side, 'right');
+      });
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
index f94b885..da72996 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
@@ -12,7 +12,7 @@
 export const GrEditConstants = {
   // Order corresponds to order in the UI.
   Actions: {
-    OPEN: {label: 'Add/Open/Upload', id: 'open'},
+    OPEN: {label: 'Edit', id: 'open'},
     DELETE: {label: 'Delete', id: 'delete'},
     RENAME: {label: 'Rename', id: 'rename'},
     RESTORE: {label: 'Restore', id: 'restore'},
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index 57b6524..b2052bf 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -85,7 +85,7 @@
           role="button"
           tabindex="0"
         >
-          Add/Open/Upload
+          Edit
         </gr-button>
         <gr-button
           aria-disabled="false"
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 7184e00..9c9709d 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -6,6 +6,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-editable-label/gr-editable-label';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-default-editor/gr-default-editor';
@@ -28,6 +29,7 @@
 import {changeIsAbandoned, changeIsMerged} from '../../../utils/change-util';
 import {Modifier} from '../../../utils/dom-util';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, query, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
@@ -66,6 +68,9 @@
   @query('#editPreferencesDialog')
   editPreferencesDialog?: GrEditPreferencesDialog;
 
+  @query('#confirmCloseModal')
+  private confirmCloseModal?: HTMLDialogElement;
+
   @state() viewState?: ChangeViewState;
 
   // private but used in test
@@ -171,6 +176,7 @@
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         :host {
           background-color: var(--view-background-color);
@@ -200,8 +206,9 @@
           white-space: initial;
           word-break: break-all;
         }
-        header gr-editable-label::part(input-container) {
-          margin-top: var(--spacing-l);
+        header gr-editable-label::part(container) {
+          display: flex;
+          align-items: center;
         }
         .textareaWrapper {
           border: 1px solid var(--border-color);
@@ -291,6 +298,18 @@
         @has-edit-pref-change-saved=${this.handleEditPrefChangeSaved}
       >
       </gr-edit-preferences-dialog>
+      <dialog id="confirmCloseModal" tabindex="-1">
+        <gr-dialog
+          id="confirmCloseDialog"
+          confirm-label="Discard"
+          cancel-label="Keep Editing"
+          @confirm=${this.handleConfirmClose}
+          @cancel=${this.handleCancelClose}
+        >
+          <div class="header" slot="header">Discard unsaved changes?</div>
+          <div class="main" slot="main">Your modifications will be lost.</div>
+        </gr-dialog>
+      </dialog>
     `;
   }
 
@@ -507,8 +526,11 @@
 
   // private but used in test
   handleCloseTap = () => {
-    // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
-    this.viewEditInChangeView();
+    if ((this.content ?? '') !== this.newContent) {
+      this.confirmCloseModal?.showModal();
+    } else {
+      this.viewEditInChangeView();
+    }
   };
 
   private handleSaveTap = () => {
@@ -582,10 +604,12 @@
     this.storeTask = debounce(
       this.storeTask,
       () => {
-        const content = e.detail.value;
-        if (content) {
-          this.newContent = e.detail.value;
-          this.getStorage().setEditableContentItem(this.storageKey, content);
+        const newContent = e.detail.value;
+
+        this.newContent = newContent;
+
+        if (newContent && newContent !== this.content) {
+          this.getStorage().setEditableContentItem(this.storageKey, newContent);
         } else {
           this.getStorage().eraseEditableContentItem(this.storageKey);
         }
@@ -610,6 +634,16 @@
     // We have to fire a reload so the change takes effect within a plugin.
     fireReload(this);
   }
+
+  private handleConfirmClose = () => {
+    this.getStorage().eraseEditableContentItem(this.storageKey);
+    this.confirmCloseModal?.close();
+    this.viewEditInChangeView();
+  };
+
+  private handleCancelClose = () => {
+    this.confirmCloseModal?.close();
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_screenshot_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_screenshot_test.ts
new file mode 100644
index 0000000..ec1bd6a
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_screenshot_test.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-editor-view';
+import {GrEditorView} from './gr-editor-view';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {
+  query,
+  stubRestApi,
+  visualDiffDarkTheme,
+} from '../../../test/test-utils';
+import {createEditViewState} from '../../../test/test-data-generators';
+import {NumericChangeId, RevisionPatchSetNum} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+suite('gr-editor-view screenshot tests', () => {
+  let element: GrEditorView;
+
+  setup(async () => {
+    stubRestApi('getFileContent').resolves({
+      ok: true,
+      type: 'text/javascript',
+      content: 'original content',
+    });
+    element = await fixture<GrEditorView>(
+      html`<gr-editor-view></gr-editor-view>`
+    );
+    element.viewState = {
+      ...createEditViewState(),
+      changeNum: 42 as NumericChangeId,
+      patchNum: 1 as RevisionPatchSetNum,
+      editView: {path: 'foo/bar.baz'},
+    };
+    element.latestPatchsetNumber = 1 as RevisionPatchSetNum;
+    element.content = 'original content';
+    element.newContent = 'original content';
+    await element.updateComplete;
+  });
+
+  test('editor view', async () => {
+    await visualDiff(element, 'gr-editor-view-normal');
+    await visualDiffDarkTheme(element, 'gr-editor-view-normal');
+  });
+
+  test('cancel modal open', async () => {
+    element.newContent = 'modified content';
+    await element.updateComplete;
+
+    query<GrButton>(element, '#close')!.click();
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-editor-view-cancel-modal');
+    await visualDiffDarkTheme(element, 'gr-editor-view-cancel-modal');
+  });
+});
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 690180c..2c5a792 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -127,6 +127,17 @@
         </div>
         <gr-edit-preferences-dialog id="editPreferencesDialog">
         </gr-edit-preferences-dialog>
+        <dialog id="confirmCloseModal" tabindex="-1">
+          <gr-dialog
+            cancel-label="Keep Editing"
+            confirm-label="Discard"
+            id="confirmCloseDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Discard unsaved changes?</div>
+            <div class="main" slot="main">Your modifications will be lost.</div>
+          </gr-dialog>
+        </dialog>
         <div class="textareaWrapper">
           <gr-endpoint-decorator id="editorEndpoint" name="editor">
             <gr-endpoint-param name="fileContent"> </gr-endpoint-param>
@@ -216,6 +227,11 @@
     const newText = 'file text changed';
 
     setup(async () => {
+      stubRestApi('getFileContent').resolves({
+        ok: true,
+        type: 'text/javascript',
+        content: originalText,
+      });
       element.viewState = {...createEditViewState()};
       element.content = originalText;
       element.newContent = originalText;
@@ -351,17 +367,89 @@
       });
     });
 
-    test('file modification and close', async () => {
-      const closeSpy = sinon.spy(element, 'handleCloseTap');
+    test('file modification and close show dialog, then discard', async () => {
+      storageService.setEditableContentItem(
+        element.storageKey,
+        'cached content'
+      );
+      assert.equal(
+        storageService.getEditableContentItem(element.storageKey)?.message,
+        'cached content'
+      );
+
       element.newContent = newText;
       await element.updateComplete;
 
-      assert.isFalse(
-        query<GrButton>(element, '#save')!.hasAttribute('disabled')
-      );
+      const dialog = query<HTMLDialogElement>(element, '#confirmCloseModal')!;
+      assert.isFalse(dialog.hasAttribute('open'));
 
       query<GrButton>(element, '#close')!.click();
-      assert.isTrue(closeSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isFalse(navigateStub.called);
+      assert.isTrue(dialog.hasAttribute('open'));
+
+      query(element, '#confirmCloseDialog')!.dispatchEvent(
+        new CustomEvent('confirm')
+      );
+      await element.updateComplete;
+
+      assert.isFalse(dialog.hasAttribute('open'));
+      assert.isTrue(navigateStub.called);
+      assert.isNull(storageService.getEditableContentItem(element.storageKey));
+    });
+
+    test('file modification and close show dialog, then keep editing', async () => {
+      element.newContent = newText;
+      await element.updateComplete;
+
+      const dialog = query<HTMLDialogElement>(element, '#confirmCloseModal')!;
+      assert.isFalse(dialog.hasAttribute('open'));
+
+      query<GrButton>(element, '#close')!.click();
+      assert.isFalse(saveFileStub.called);
+      assert.isFalse(navigateStub.called);
+      assert.isTrue(dialog.hasAttribute('open'));
+
+      query(element, '#confirmCloseDialog')!.dispatchEvent(
+        new CustomEvent('cancel')
+      );
+      await element.updateComplete;
+
+      assert.isFalse(dialog.hasAttribute('open'));
+      assert.isFalse(navigateStub.called);
+    });
+
+    test('render open dialog', async () => {
+      element.newContent = newText;
+      await element.updateComplete;
+
+      query<GrButton>(element, '#close')!.click();
+      await element.updateComplete;
+
+      const dialog = query<HTMLDialogElement>(element, '#confirmCloseModal')!;
+      assert.isTrue(dialog.hasAttribute('open'));
+
+      assert.lightDom.equal(
+        dialog,
+        /* HTML */ `
+          <gr-dialog
+            cancel-label="Keep Editing"
+            confirm-label="Discard"
+            id="confirmCloseDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Discard unsaved changes?</div>
+            <div class="main" slot="main">Your modifications will be lost.</div>
+          </gr-dialog>
+        `
+      );
+    });
+
+    test('close when content is not modified', async () => {
+      element.newContent = originalText;
+      await element.updateComplete;
+
+      query<GrButton>(element, '#close')!.click();
       assert.isFalse(saveFileStub.called);
       assert.isTrue(navigateStub.called);
     });
@@ -601,6 +689,67 @@
       };
       assert.equal(element.storageKey, 'c1_ps1_test');
     });
+
+    test('does not store content-change when content is unchanged', async () => {
+      const storageStub = sinon.stub(storageService, 'setEditableContentItem');
+
+      element.content = 'test';
+      element.newContent = 'test';
+      await element.updateComplete;
+
+      query<GrEndpointDecorator>(element, '#editorEndpoint')!.dispatchEvent(
+        new CustomEvent('content-change', {
+          bubbles: true,
+          composed: true,
+          detail: {value: 'test'},
+        })
+      );
+
+      element.storeTask?.flush();
+      await element.updateComplete;
+
+      assert.equal(element.newContent, 'test');
+      assert.isFalse(
+        storageStub.called,
+        'should not store content when it has not changed'
+      );
+    });
+
+    test('erases stored content when content-change returns to original content', async () => {
+      const setStorageStub = sinon.stub(
+        storageService,
+        'setEditableContentItem'
+      );
+      const eraseStorageStub = sinon.stub(
+        storageService,
+        'eraseEditableContentItem'
+      );
+
+      element.content = 'original content';
+      element.newContent = 'modified content';
+      await element.updateComplete;
+
+      query<GrEndpointDecorator>(element, '#editorEndpoint')!.dispatchEvent(
+        new CustomEvent('content-change', {
+          bubbles: true,
+          composed: true,
+          detail: {value: 'original content'},
+        })
+      );
+
+      element.storeTask?.flush();
+      await element.updateComplete;
+
+      assert.equal(element.newContent, 'original content');
+      assert.isFalse(
+        setStorageStub.called,
+        'should not store content when it matches the original'
+      );
+      assert.isTrue(
+        eraseStorageStub.calledOnce,
+        'should erase the cached edit when content matches the original'
+      );
+    });
   });
 
   suite('save enabled/disabled', () => {
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index a82eed4..305eaf6 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -23,6 +23,7 @@
   initWebVitals,
 } from '../services/gr-reporting/gr-reporting_impl';
 import {Finalizable} from '../types/types';
+import {css, html, LitElement} from 'lit';
 
 export function initGlobalVariables(
   appContext: AppContext & Finalizable,
@@ -39,6 +40,9 @@
     initInteractionReporter(reportingService);
   }
   window.GrPluginActionContext = GrPluginActionContext;
+  window.LitElement = LitElement;
+  window.html = html;
+  window.css = css;
 }
 
 export function initGerrit(pluginLoader: PluginLoader) {
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index bdfb052..fa8ee18 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -3,24 +3,8 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {safeTypesBridge} from '../utils/safe-types-util';
 import './font-roboto-local-loader';
 import '../types/globals';
-// Sets up global Polymer variable, because plugins requires it.
-import '../scripts/bundled-polymer';
-
-/**
- * setCancelSyntheticClickEvents is set to true by
- * default which will cancel synthetic click events
- * on older touch device.
- * See https://github.com/Polymer/polymer/issues/5289
- */
-import {
-  setCancelSyntheticClickEvents,
-  setPassiveTouchGestures,
-} from '@polymer/polymer/lib/utils/settings';
-setCancelSyntheticClickEvents(false);
-setPassiveTouchGestures(true);
 
 import {initGerrit, initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
@@ -30,7 +14,6 @@
   provide,
   Provider,
 } from '../models/dependency';
-import {installPolymerResin} from '../scripts/polymer-resin-install';
 
 import {
   createAppContext,
@@ -49,7 +32,8 @@
 
 initGlobalVariables(createAppContext(), true);
 
-installPolymerResin(safeTypesBridge);
+export const SCROLL_PADDING_TOP_CALC =
+  'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))';
 
 @customElement('gr-app')
 export class GrApp extends LitElement {
@@ -101,16 +85,63 @@
     if (!this.serviceWorkerInstaller) {
       this.serviceWorkerInstaller = resolver(serviceWorkerInstallerToken);
     }
+
+    // Defines top optimal viewing region for PageDown/PageUp keyboard paging
+    // and anchor jumps when sticky headers are active.
+    document.documentElement.style.setProperty(
+      'scroll-padding-top',
+      SCROLL_PADDING_TOP_CALC
+    );
+    document.addEventListener('focusin', this.handleFocusIn);
+    document.addEventListener('focusout', this.handleFocusOut);
   }
 
   override disconnectedCallback() {
+    document.removeEventListener('focusin', this.handleFocusIn);
+    document.removeEventListener('focusout', this.handleFocusOut);
     for (const f of this.finalizables) {
       f.finalize();
     }
     this.finalizables = [];
+    document.documentElement.style.removeProperty('scroll-padding-top');
     super.disconnectedCallback();
   }
 
+  private readonly handleFocusIn = (e: FocusEvent) => {
+    const path = e.composedPath();
+    if (this.isInsideStickyContainer(path)) {
+      document.documentElement.style.setProperty('scroll-padding-top', '0px');
+    } else {
+      document.documentElement.style.setProperty(
+        'scroll-padding-top',
+        SCROLL_PADDING_TOP_CALC
+      );
+    }
+  };
+
+  private readonly handleFocusOut = (e: FocusEvent) => {
+    if (!e.relatedTarget) {
+      document.documentElement.style.setProperty(
+        'scroll-padding-top',
+        SCROLL_PADDING_TOP_CALC
+      );
+    }
+  };
+
+  private isInsideStickyContainer(path: EventTarget[]): boolean {
+    for (const target of path) {
+      if (!(target instanceof HTMLElement)) continue;
+      if (target === document.body || target === document.documentElement) {
+        break;
+      }
+      const style = window.getComputedStyle(target);
+      if (style.position === 'sticky' && style.top !== 'auto') {
+        return true;
+      }
+    }
+    return false;
+  }
+
   override render() {
     return html`<gr-app-element id="app-element"></gr-app-element>`;
   }
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index d33f990..17c64d7 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -87,4 +87,121 @@
     grAppElement.paramsChanged();
     assert.ok(grAppElement.lastSearchPage);
   });
+
+  test('scroll-padding-top is set when connected', () => {
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+    );
+  });
+
+  test('scroll-padding-top is removed when disconnected', () => {
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+    );
+    grApp.remove();
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      ''
+    );
+  });
+
+  test('scroll-padding-top drops to 0px on focus inside sticky header and restores on blur', () => {
+    const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
+    const mainHeader = queryAndAssert(grAppElement, 'gr-main-header');
+
+    const searchBar = queryAndAssert(mainHeader, 'gr-smart-search');
+    const searchAutocomplete = queryAndAssert(
+      searchBar,
+      'gr-search-autocomplete'
+    );
+    const autocomplete = queryAndAssert(searchAutocomplete, 'gr-autocomplete');
+    const input = queryAndAssert(autocomplete, '#input');
+
+    // 1. Focus inside nested search bar input in sticky mainHeader
+    input.dispatchEvent(
+      new FocusEvent('focusin', {bubbles: true, composed: true})
+    );
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      '0px'
+    );
+
+    // 2. Focus moves to non-sticky content
+    grAppElement.dispatchEvent(
+      new FocusEvent('focusin', {bubbles: true, composed: true})
+    );
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+    );
+
+    // 3. Re-focus inside sticky header
+    mainHeader.dispatchEvent(
+      new FocusEvent('focusin', {bubbles: true, composed: true})
+    );
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      '0px'
+    );
+
+    // 4. Focus leaves the window (blur, relatedTarget: null)
+    mainHeader.dispatchEvent(
+      new FocusEvent('focusout', {
+        bubbles: true,
+        composed: true,
+        relatedTarget: null,
+      })
+    );
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      'calc(var(--main-header-height) + var(--change-header-height) + var(--diff-header-height))'
+    );
+  });
+
+  test('focus transition between sticky elements maintains 0px without intermediate reset', () => {
+    const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
+    const mainHeader = queryAndAssert(grAppElement, 'gr-main-header');
+
+    // Create a second sticky element to simulate another sticky header or sibling
+    const secondSticky = document.createElement('div');
+    secondSticky.style.position = 'sticky';
+    secondSticky.style.top = '48px';
+    grAppElement.shadowRoot!.appendChild(secondSticky);
+
+    // Focus first sticky element
+    mainHeader.dispatchEvent(
+      new FocusEvent('focusin', {bubbles: true, composed: true})
+    );
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      '0px'
+    );
+
+    // Focusout from mainHeader transferring to secondSticky
+    mainHeader.dispatchEvent(
+      new FocusEvent('focusout', {
+        bubbles: true,
+        composed: true,
+        relatedTarget: secondSticky,
+      })
+    );
+    // Because relatedTarget is non-null, focusout does not prematurely reset
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      '0px'
+    );
+
+    // Focusin on secondSticky
+    secondSticky.dispatchEvent(
+      new FocusEvent('focusin', {bubbles: true, composed: true})
+    );
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      '0px'
+    );
+
+    secondSticky.remove();
+  });
 });
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index d4957a8..9aa9eec 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -39,6 +39,8 @@
 
   private readonly initializedPlugins = new Map<string, boolean>();
 
+  private readonly timeoutIds = new Set<number>();
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -68,6 +70,10 @@
   }
 
   override disconnectedCallback() {
+    for (const timeoutId of this.timeoutIds) {
+      window.clearTimeout(timeoutId);
+    }
+    this.timeoutIds.clear();
     for (const [el, domHook] of this.domHooks) {
       domHook.handleInstanceDetached(el);
     }
@@ -144,13 +150,16 @@
     const expectProperties = this.getEndpointParams().map(paramEl =>
       this.setupParamBinding(paramEl, el, pluginName)
     );
-    let timeoutId: number;
+    let timeoutId: number | undefined;
     const timeout = new Promise(
       () =>
         // specify window here so that TS pulls the correct setTimeout method
         // if window is not specified, then the function is pulled from node
         // and the return type is NodeJS.Timeout object
         (timeoutId = window.setTimeout(() => {
+          if (timeoutId !== undefined) {
+            this.timeoutIds.delete(timeoutId);
+          }
           this.reporting.error(
             `Plugin '${pluginName}', endpoint '${this.name}'`,
             new Error(
@@ -160,10 +169,16 @@
           );
         }, INIT_PROPERTIES_TIMEOUT_MS))
     );
+    if (timeoutId !== undefined) {
+      this.timeoutIds.add(timeoutId);
+    }
     return Promise.race([timeout, Promise.all(expectProperties)])
       .then(() => el)
       .finally(() => {
-        if (timeoutId) clearTimeout(timeoutId);
+        if (timeoutId !== undefined) {
+          window.clearTimeout(timeoutId);
+          this.timeoutIds.delete(timeoutId);
+        }
       });
   }
 
@@ -228,6 +243,7 @@
     }
     this.initializedPlugins.set(name, true);
     initPromise.then(el => {
+      if (!this.isConnected) return;
       if (domHook) {
         domHook.handleInstanceAttached(el);
         this.domHooks.set(el, domHook);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
index 807ab5d..2d1b9ab 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -13,6 +13,7 @@
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, PluginElement} from '../../../api/hook';
 import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
 
 interface TestModule extends PluginElement {
   'first-param'?: string;
@@ -276,4 +277,40 @@
     await param.updateComplete;
     assert.strictEqual((module as TestModule)['banana-param'], value2);
   });
+
+  test('disconnected before param setup does not log timeout error', async () => {
+    const errorStub = sinon.stub(getAppContext().reportingService, 'error');
+    const clock = sinon.useFakeTimers();
+    try {
+      const el = await fixture<GrEndpointDecorator>(html`
+        <gr-endpoint-decorator name="late-endpoint">
+          <gr-endpoint-param name="late-param"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `);
+      plugin.registerCustomComponent('late-endpoint', 'late-module');
+      el.remove();
+      clock.tick(15000);
+      assert.isFalse(errorStub.called);
+    } finally {
+      clock.restore();
+      errorStub.restore();
+    }
+  });
+
+  test('disconnected element does not attach custom component after late resolution', async () => {
+    const el = await fixture<GrEndpointDecorator>(html`
+      <gr-endpoint-decorator name="late-endpoint-2">
+        <gr-endpoint-param name="late-param-2"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `);
+    const hook = plugin.registerCustomComponent(
+      'late-endpoint-2',
+      'late-module-2'
+    );
+    const param = queryAndAssert<GrEndpointParam>(el, 'gr-endpoint-param');
+    el.remove();
+    param.value = 'test-value';
+    await param.updateComplete;
+    assert.equal(hook.getAllAttached().length, 0);
+  });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index f8c368d..8636dae 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -38,16 +38,16 @@
   @property({type: Boolean}) hasUnsavedChanges = false;
 
   // private but used in test
-  @state() hasNameChange = false;
+  @property({type: Boolean}) hasNameChange = false;
 
   // private but used in test
-  @state() hasUsernameChange = false;
+  @property({type: Boolean}) hasUsernameChange = false;
 
   // private but used in test
-  @state() hasDisplayNameChange = false;
+  @property({type: Boolean}) hasDisplayNameChange = false;
 
   // private but used in test
-  @state() hasStatusChange = false;
+  @property({type: Boolean}) hasStatusChange = false;
 
   // private but used in test
   @state() loading = false;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index 55ce032..fdcc2b0 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -41,6 +41,8 @@
   // private but used in test
   @state() showNumber?: boolean;
 
+  @state() labelFilterInput: string = ''; // comma-separated
+
   private readonly getConfigModel = resolve(this, configModelToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
@@ -57,10 +59,17 @@
           width: auto;
         }
         #changeCols .visibleHeader {
-          text-align: center;
+          text-align: left;
         }
         .checkboxContainer {
-          text-align: center;
+          text-align: left;
+        }
+        .labelsFilterInput {
+          width: 20em;
+          display: block;
+        }
+        .labelsFilterCell {
+          width: auto;
         }
       `,
     ];
@@ -85,6 +94,7 @@
         this.prefs = prefs;
         this.showNumber = !!prefs.legacycid_in_change_table;
         this.localChangeTableColumns = changeTablePrefs(prefs);
+        this.labelFilterInput = prefs.label_filter ?? '';
       }
     );
   }
@@ -122,6 +132,18 @@
               </tr>
 
               ${this.defaultColumns.map(col => this.renderRow(col))}
+              <tr>
+                <td><label for="labelsFilter">Shown Labels</label></td>
+                <td class="labelsFilterCell">
+                  <md-outlined-text-field
+                    id="labelsFilter"
+                    class="showBlueFocusBorder labelsFilterInput"
+                    placeholder="CR,V (leave empty to see all labels)"
+                    .value=${this.labelFilterInput}
+                    @input=${this.handleLabelsFilterInput}
+                  ></md-outlined-text-field>
+                </td>
+              </tr>
             </tbody>
           </table>
           <gr-button
@@ -167,6 +189,14 @@
   }
 
   /**
+   * Handle input in the labels filter text field and update the labelFilterInput property
+   * accordingly.
+   */
+  private handleLabelsFilterInput(e: Event) {
+    this.labelFilterInput = (e.target as HTMLInputElement).value;
+  }
+
+  /**
    * Handle a click on a displayed column checkboxes (excluding number) and
    * update the localChangeTableColumns property accordingly.
    */
@@ -196,26 +226,24 @@
 
   // private but used in test
   async handleSaveChangeTable() {
-    const newPrefs = {
+    await this.getUserModel().updatePreferences({
       ...this.prefs,
       change_table: this.localChangeTableColumns,
       legacycid_in_change_table: this.showNumber,
-    };
-
-    await this.getUserModel().updatePreferences(newPrefs);
+      label_filter: this.labelFilterInput.trim(),
+    });
   }
 
   private hasUnsavedChanges(): boolean {
     const prefsColumns = changeTablePrefs(this.prefs);
-
     const columnsChanged =
       prefsColumns.length !== this.localChangeTableColumns.length ||
       prefsColumns.some(c => !this.localChangeTableColumns.includes(c));
-
     const numberChanged =
       !!this.prefs.legacycid_in_change_table !== !!this.showNumber;
-
-    return columnsChanged || numberChanged;
+    const savedFilter = this.prefs.label_filter ?? '';
+    const labelsChanged = savedFilter !== this.labelFilterInput.trim();
+    return columnsChanged || numberChanged || labelsChanged;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index 1c5c589..9029a5c 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -104,6 +104,14 @@
               </tr>
               <tr>
                 <td>
+                  <label for="Hashtags"> Hashtags </label>
+                </td>
+                <td class="checkboxContainer">
+                  <md-checkbox id="Hashtags" name="Hashtags"> </md-checkbox>
+                </td>
+              </tr>
+              <tr>
+                <td>
                   <label for="Updated"> Updated </label>
                 </td>
                 <td class="checkboxContainer">
@@ -126,6 +134,19 @@
                   <md-checkbox id="Status" name="Status"> </md-checkbox>
                 </td>
               </tr>
+              <tr>
+                <td>
+                  <label for="labelsFilter"> Shown Labels </label>
+                </td>
+                <td class="labelsFilterCell">
+                  <md-outlined-text-field
+                    class="labelsFilterInput showBlueFocusBorder"
+                    id="labelsFilter"
+                    placeholder="CR,V (leave empty to see all labels)"
+                  >
+                  </md-outlined-text-field>
+                </td>
+              </tr>
             </tbody>
           </table>
           <gr-button
@@ -145,9 +166,9 @@
     const rows = queryAndAssert(element, 'tbody').querySelectorAll('tr');
     let tds;
 
-    // The `+ 1` is for the number column, which isn't included in the change
-    // table behavior's list.
-    assert.equal(rows.length, element.defaultColumns.length + 1);
+    // The `+ 2` is for the number column and the labels column, which aren't
+    // included in the change table behavior's list.
+    assert.equal(rows.length, element.defaultColumns.length + 2);
     for (let i = 0; i < element.defaultColumns.length; i++) {
       tds = rows[i + 1].querySelectorAll('td');
       assert.equal(tds[0].textContent, element.defaultColumns[i]);
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
index 6bb104d..bd738ee 100644
--- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -17,6 +17,7 @@
 import {
   AppTheme,
   DateFormat,
+  DefaultBase,
   DiffViewMode,
   EmailFormat,
   EmailStrategy,
@@ -550,37 +551,30 @@
     `;
   }
 
-  // When this is fixed and can be re-enabled, move this back to render()
-  // and remove function.
   private renderDefaultBaseForMerges() {
-    if (!this.prefs?.default_base_for_merges) return nothing;
-    return nothing;
-    // TODO: Re-enable respecting the default_base_for_merges preference.
-    // See corresponding TODO in change-model.
-    // return html`
-    //   <section>
-    //     <span class="title">Default Base For Merges</span>
-    //     <span class="value">
-    //       <md-outlined-select
-    //         .value=${convertToString(
-    //            this.prefs?.default_base_for_merges
-    //          )}
-    //         @change=${(e: Event) => {
-    //           const select = e.target as HTMLSelectElement;
-    //           this.prefs!.default_base_for_merges = select.value as DefaultBase;
-    //           this.requestUpdate();
-    //         }}
-    //       >
-    //         <md-select-option value="AUTO_MERGE">
-    //           <div slot="headline">Auto Merge</div>
-    //         </md-select-option>
-    //         <md-select-option value="FIRST_PARENT">
-    //           <div slot="headline">First Parent</div>
-    //         </md-select-option>
-    //       </md-outlined-select>
-    //     </span>
-    //   </section>
-    // `;
+    return html`
+      <section>
+        <span class="title">Default Base For Merges</span>
+        <span class="value">
+          <md-outlined-select
+            .value=${convertToString(this.prefs?.default_base_for_merges)}
+            @change=${(e: Event) => {
+              const select = e.target as HTMLSelectElement;
+              if (!this.prefs) return;
+              this.prefs.default_base_for_merges = select.value as DefaultBase;
+              this.requestUpdate();
+            }}
+          >
+            <md-select-option value="AUTO_MERGE">
+              <div slot="headline">Auto Merge</div>
+            </md-select-option>
+            <md-select-option value="FIRST_PARENT">
+              <div slot="headline">First Parent</div>
+            </md-select-option>
+          </md-outlined-select>
+        </span>
+      </section>
+    `;
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
index 7780ca9..66b2388 100644
--- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
@@ -92,7 +92,7 @@
                   <md-select-option
                     data-aria-selected="true"
                     md-menu-item=""
-                    tabindex="-1"
+                    tabindex="0"
                     value="LIGHT"
                   >
                     <div slot="headline">Light</div>
@@ -115,7 +115,7 @@
                   <md-select-option
                     data-aria-selected="true"
                     md-menu-item=""
-                    tabindex="-1"
+                    tabindex="0"
                     value="25"
                   >
                     <div slot="headline">25 rows per page</div>
@@ -150,7 +150,7 @@
                   <md-select-option
                     data-aria-selected="true"
                     md-menu-item=""
-                    tabindex="-1"
+                    tabindex="0"
                     value="UK"
                   >
                     <div slot="headline">03/06 ; 03/06/2016 (UK)</div>
@@ -191,7 +191,7 @@
                   <md-select-option
                     data-aria-selected="true"
                     md-menu-item=""
-                    tabindex="-1"
+                    tabindex="0"
                     value="ENABLED"
                   >
                     <div slot="headline">Only comments left by others</div>
@@ -251,6 +251,28 @@
               </span>
             </section>
             <section>
+              <span class="title"> Default Base For Merges </span>
+              <span class="value">
+                <md-outlined-select>
+                  <md-select-option
+                    md-menu-item=""
+                    tabindex="0"
+                    value="AUTO_MERGE"
+                  >
+                    <div slot="headline">Auto Merge</div>
+                  </md-select-option>
+                  <md-select-option
+                    data-aria-selected="true"
+                    md-menu-item=""
+                    tabindex="0"
+                    value="FIRST_PARENT"
+                  >
+                    <div slot="headline">First Parent</div>
+                  </md-select-option>
+                </md-outlined-select>
+              </span>
+            </section>
+            <section>
               <label class="title" for="relativeDateInChangeTable">
                 Show Relative Dates In Changes Table
               </label>
@@ -272,7 +294,7 @@
                   <md-select-option
                     data-aria-selected="true"
                     md-menu-item=""
-                    tabindex="-1"
+                    tabindex="0"
                     value="UNIFIED_DIFF"
                   >
                     <div slot="headline">Unified diff</div>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index f85b029..db7e38f 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -39,9 +39,9 @@
 
   @property() settingsUrl?: string;
 
-  @state() account: Partial<AccountDetailInfo> = {};
+  @property({type: Object}) account: Partial<AccountDetailInfo> = {};
 
-  @state() loading = true;
+  @property({type: Boolean}) loading = true;
 
   @state() saving = false;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 50833da..4798ac1 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -183,7 +183,10 @@
   loadData() {
     return this.restApiService.getWatchedProjects().then(projs => {
       this.originalProjects = projs;
-      this.projects = projs ? [...projs] : [];
+      this.projects =
+        projs?.map(project => {
+          return {...project};
+        }) ?? [];
     });
   }
 
@@ -207,7 +210,10 @@
       })
       .then(projects => {
         this.originalProjects = projects;
-        this.projects = projects ? [...projects] : [];
+        this.projects =
+          projects?.map(project => {
+            return {...project};
+          }) ?? [];
         this.projectsToRemove = [];
         this.setHasUnsavedChanges();
       });
@@ -231,10 +237,15 @@
     const index = this.projects.indexOf(project);
     if (index < 0) return;
     this.projects.splice(index, 1);
-    // Don't add project to projectsToRemove if it wasn't in
-    // originalProjects.
-    if (this.originalProjects.includes(project))
+    if (
+      this.originalProjects.some(
+        originalProject =>
+          originalProject.project === project.project &&
+          this.areFiltersEqual(originalProject.filter, project.filter)
+      )
+    ) {
       this.projectsToRemove.push(project);
+    }
     this.requestUpdate();
     this.setHasUnsavedChanges();
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index b491c70..b0b0c06 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -15,6 +15,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field';
+import {MdCheckbox} from '@material/web/checkbox/checkbox';
 
 suite('gr-watched-projects-editor tests', () => {
   let element: GrWatchedProjectsEditor;
@@ -366,6 +367,28 @@
     assert.isTrue(projects[4]._is_local);
   });
 
+  test('notification change sets has unsaved changes', async () => {
+    const hasUnsavedChangesSpy = sinon.spy();
+    element.addEventListener(
+      'has-unsaved-changes-changed',
+      hasUnsavedChangesSpy
+    );
+    const checkbox = queryAndAssert<MdCheckbox>(
+      element,
+      'tbody tr:nth-child(2) md-checkbox[data-key="notify_new_changes"]'
+    );
+
+    assert.isTrue(checkbox.checked);
+    checkbox.click();
+    await element.updateComplete;
+
+    assert.isFalse(checkbox.checked);
+    assert.isTrue(element.originalProjects![1].notify_new_changes);
+    assert.isFalse(element.projects![1].notify_new_changes);
+    assert.isTrue(hasUnsavedChangesSpy.calledOnce);
+    assert.isTrue(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
+  });
+
   test('_handleAddProject with invalid inputs', () => {
     assertIsDefined(element.newProject, 'newProject');
     element.newProject.value = 'project b';
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 9fe856b..dfddbef 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -10,7 +10,7 @@
 } from '../gr-autocomplete/gr-autocomplete';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, property, query} from 'lit/decorators.js';
 import {
   AddAccountEvent,
   AutocompleteCommitEvent,
@@ -41,7 +41,7 @@
   querySuggestions: AutocompleteQuery<SuggestedReviewerInfo> = () =>
     Promise.resolve([]);
 
-  @state() private inputText = '';
+  @property({type: String}) private inputText = '';
 
   static override get styles() {
     return [
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index d39d0f4..94beb5b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -51,6 +51,9 @@
   @property({type: Boolean})
   forceAttention = false;
 
+  @property({type: Boolean, attribute: 'is-ai'})
+  isAi = false;
+
   /**
    * Only show the first name in the account label.
    */
@@ -198,6 +201,11 @@
         :host([clickable]) a.ownerLink:hover .name {
           text-decoration: underline;
         }
+        .ai-icon {
+          color: var(--primary-text-color);
+          vertical-align: top;
+          --gr-icon-size: 16px;
+        }
       `,
     ];
   }
@@ -310,6 +318,13 @@
             >
               ${this.computeName(account, this.firstName, this.config)}
             </span>
+            ${this.isAi
+              ? html`<gr-icon
+                  icon="spark"
+                  class="ai-icon"
+                  title="AI designation"
+                ></gr-icon>`
+              : ''}
             ${this.renderAccountStatusPlugins()}
           </span>
         `)}
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 7f2cf17..cc51a93 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -160,7 +160,7 @@
   @property({type: String})
   label? = '';
 
-  @state() suggestions: AutocompleteSuggestion[] = [];
+  @property({type: Array}) suggestions: AutocompleteSuggestion[] = [];
 
   @state() queryStatus?: AutocompleteQueryStatus;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 972a580..7846c57 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -39,6 +39,9 @@
   @property({type: Object})
   change?: ChangeInfo;
 
+  @property({type: Boolean})
+  loading = false;
+
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   static override get styles() {
@@ -53,11 +56,19 @@
           visibility: hidden;
           display: block !important;
         }
+        .loadingSpin {
+          display: block;
+          width: 16px;
+          height: 16px;
+        }
       `,
     ];
   }
 
   override render() {
+    if (this.loading) {
+      return html`<span class="loadingSpin"></span>`;
+    }
     return html`
       <button
         role="checkbox"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 0e0dde7..e247022 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -30,6 +30,7 @@
 import {ChangeMessageId, FixSuggestionInfo} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
 import {
+  CommentSide,
   createDefaultDiffPrefs,
   SpecialFilePath,
 } from '../../../constants/constants';
@@ -246,7 +247,7 @@
    * We are listening on the <gr-comment> of the draft, so we even know when the
    * checkbox is checked, even if not yet saved.
    */
-  @state()
+  @property({type: Boolean})
   unresolved = true;
 
   /**
@@ -355,6 +356,7 @@
       sharedStyles,
       css`
         :host {
+          display: block;
           font-family: var(--font-family);
           font-size: var(--font-size-normal);
           font-weight: var(--font-weight-normal);
@@ -379,7 +381,7 @@
           padding: var(--spacing-s) var(--spacing-m);
         }
         .comment-box {
-          width: 80ch;
+          width: var(--gr-comment-thread-width, 80ch);
           max-width: 100%;
           background-color: var(--comment-background-color);
           color: var(--comment-text-color);
@@ -431,7 +433,7 @@
           border: 1px solid var(--border-color);
           flex-grow: 1;
           flex-shrink: 1;
-          max-width: 1200px;
+          max-width: var(--gr-comment-thread-diff-max-width, 1200px);
         }
         .view-diff-button {
           margin: var(--spacing-s) var(--spacing-m);
@@ -526,7 +528,12 @@
         ${href
           ? html`<a href=${href}>${displayPath}</a>`
           : html`<span>${displayPath}</span>`}
-        <gr-copy-clipboard hideInput .text=${displayPath}></gr-copy-clipboard>
+        <gr-copy-clipboard
+          hideInput
+          .text=${displayPath}
+          buttonTitle="Copy file path to clipboard"
+          copyTargetName="File path"
+        ></gr-copy-clipboard>
       </div>
     `;
   }
@@ -632,6 +639,18 @@
                         >
                       `
                     : nothing}
+                  ${this.shouldShowDisagreeButton()
+                    ? html`
+                        <gr-button
+                          id="disagreeBtn"
+                          link
+                          class="action disagree"
+                          ?disabled=${this.saving}
+                          @click=${this.handleCommentDisagree}
+                          >Disagree</gr-button
+                        >
+                      `
+                    : nothing}
                   <gr-button
                     id="ackBtn"
                     link
@@ -722,10 +741,19 @@
   }
 
   override firstUpdated() {
+    const lastComment = this.thread ? this.getLastComment() : undefined;
+    const isNewDraft =
+      isDraft(lastComment) && (lastComment?.message ?? '') === '';
     if (this.shouldScrollIntoView) {
       whenRendered(this, () => {
         this.expandCollapseComments(false);
-        this.commentBox?.focus();
+        // Because of the non-deterministic order of focus events firing from
+        // the JS event loop, focusing the comment box on a new draft can result
+        // in the draft comment not being focused, which means the user has to
+        // click into it to start typing.
+        if (!isNewDraft) {
+          this.commentBox?.focus();
+        }
         // The delay is a hack because we don't know exactly when to
         // scroll the comment into center.
         // TODO: Find a better solution without a setTimeout
@@ -735,9 +763,9 @@
         }, 500);
       });
     }
-    if (this.thread && isDraft(this.getFirstComment())) {
-      const msg = this.getFirstComment()?.message ?? '';
-      if (msg.length === 0) this.editDraft();
+    // Focus the draft comment input to avoid the user having to click into it.
+    if (isNewDraft) {
+      this.editDraft();
     }
   }
 
@@ -750,6 +778,10 @@
     this.draftElement!.edit();
   }
 
+  async autoSave(): Promise<void> {
+    await this.draftElement?.autoSave();
+  }
+
   private async addQuote(quote: string) {
     await waitUntil(
       () => !!this.draftElement,
@@ -826,12 +858,48 @@
 
   // Does not work for patchset level comments
   private getUrlForFileComment() {
-    const id = this.getFirstComment()?.id;
-    if (!id || !this.repoName || !this.changeNum) return undefined;
+    if (this.isPatchsetLevel()) {
+      return undefined;
+    }
+    if (!this.repoName || !this.changeNum) {
+      return undefined;
+    }
+    const comment = this.getFirstComment();
+    if (!comment) {
+      return undefined;
+    }
+    const patchNum = this.thread?.patchNum ?? comment.patch_set;
+    const path = this.thread?.path;
+    if (!patchNum || !path) {
+      if (!comment.id) {
+        return undefined;
+      }
+      return createDiffUrl({
+        changeNum: this.changeNum,
+        repo: this.repoName,
+        commentId: comment.id,
+      });
+    }
+    let line: number | undefined;
+    if (typeof this.thread?.line === 'number') {
+      line = this.thread.line;
+    } else if (this.thread?.range) {
+      line =
+        this.thread.range.end_line < this.thread.range.start_line
+          ? this.thread.range.start_line
+          : this.thread.range.end_line;
+    }
+    const side = this.thread?.commentSide ?? comment.side;
+    const leftSide = side === CommentSide.PARENT;
     return createDiffUrl({
       changeNum: this.changeNum,
       repo: this.repoName,
-      commentId: id,
+      patchNum,
+      diffView: {
+        path,
+        lineNum: line,
+        leftSide,
+      },
     });
   }
 
@@ -957,6 +1025,14 @@
     );
   }
 
+  protected handleCommentDisagree() {
+    this.createReplyComment(
+      'Disagree.',
+      /* userWantsToEdit= */ false,
+      /* unresolved= */ false
+    );
+  }
+
   private handleReplyToComment(e: ReplyToCommentEvent) {
     const {content, userWantsToEdit, unresolved} = e.detail;
     this.createReplyComment(content, userWantsToEdit, unresolved);
@@ -1024,6 +1100,17 @@
     return this.isOwner && !hasUserSuggestion(comment);
   }
 
+  protected shouldShowDisagreeButton(): boolean {
+    return !!(
+      this.thread &&
+      this.account &&
+      this.unresolved &&
+      this.thread.comments.length === 1 &&
+      this.isOwner &&
+      this.thread.comments[0]?.is_ai
+    );
+  }
+
   private handleAppliedFix(fixSuggestion?: FixSuggestionInfo) {
     const message = this.getLastComment()?.message;
     assert(!!message, 'empty message');
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
index 5bd04d8..bf06c20 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_screenshot_test.ts
@@ -55,6 +55,15 @@
   savingState: SavingState.OK,
 };
 
+const c_ai: CommentInfo = {
+  author: createAccountDetailWithId(3),
+  id: 'the-ai-comment' as UrlEncodedCommentId,
+  message: 'I am an AI assistant, here to help!',
+  updated: '2021-11-04 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-reply' as UrlEncodedCommentId,
+  is_ai: true,
+};
+
 suite('gr-comment-thread screenshot tests', () => {
   let element: GrCommentThread;
 
@@ -96,4 +105,33 @@
     await visualDiff(element, 'gr-comment-thread-with-draft');
     await visualDiffDarkTheme(element, 'gr-comment-thread-with-draft');
   });
+
+  test('with AI comment', async () => {
+    element.thread = createThread(c1, c2, c_ai);
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-comment-thread-with-ai');
+    await visualDiffDarkTheme(element, 'gr-comment-thread-with-ai');
+  });
+
+  test('with AI comment author disagree', async () => {
+    element.isOwner = true;
+    element.thread = createThread({...c_ai, unresolved: true});
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-comment-thread-with-ai-disagree');
+    await visualDiffDarkTheme(element, 'gr-comment-thread-with-ai-disagree');
+  });
+
+  test('unresolved inline code review', async () => {
+    // Simulate inline context by setting custom properties
+    element.style.setProperty('--gr-comment-thread-width', '100%');
+    element.style.setProperty('--gr-comment-thread-diff-max-width', '100%');
+
+    element.thread = createThread(c1, {...c2, unresolved: true});
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-comment-thread-unresolved-inline');
+    await visualDiffDarkTheme(element, 'gr-comment-thread-unresolved-inline');
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 28ebd79..67801de 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -40,7 +40,7 @@
 import {SinonStubbedMember} from 'sinon';
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
-import {SpecialFilePath} from '../../../constants/constants';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
 import {GrIcon} from '../gr-icon/gr-icon';
 import {
   CommentsModel,
@@ -123,10 +123,17 @@
           <a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
             test-path-comment-thread
           </a>
-          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+          <gr-copy-clipboard
+            buttontitle="Copy file path to clipboard"
+            copytargetname="File path"
+            hideinput=""
+          >
+          </gr-copy-clipboard>
         </div>
         <div class="pathInfo">
-          <a href="/c/test-repo-name/+/1/comment/the-root/"> #314 </a>
+          <a href="/c/test-repo-name/+/1/1/test-path-comment-thread#314">
+            #314
+          </a>
         </div>
         <div id="container">
           <h3 class="assistive-tech-only">Draft Comment thread by Kermit</h3>
@@ -158,10 +165,17 @@
           <a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
             test-path-comment-thread
           </a>
-          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+          <gr-copy-clipboard
+            buttontitle="Copy file path to clipboard"
+            copytargetname="File path"
+            hideinput=""
+          >
+          </gr-copy-clipboard>
         </div>
         <div class="pathInfo">
-          <span>#314</span>
+          <a href="/c/test-repo-name/+/1/1/test-path-comment-thread#314">
+            #314
+          </a>
         </div>
         <div id="container">
           <h3 class="assistive-tech-only">Draft Comment thread by Yoda</h3>
@@ -173,6 +187,60 @@
     );
   });
 
+  test('focuses commentBox when it is NOT a new draft', async () => {
+    const thread = createThread(c1);
+    const element = await fixture<GrCommentThread>(html`
+      <gr-comment-thread
+        .thread=${thread}
+        .shouldScrollIntoView=${true}
+      ></gr-comment-thread>
+    `);
+    await element.updateComplete;
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    const commentBox = queryAndAssert<HTMLElement>(element, '.comment-box');
+    assert.equal(element.shadowRoot?.activeElement, commentBox);
+  });
+
+  test('does not focus commentBox when it IS a new draft', async () => {
+    const thread = createThread(createNewDraft({message: ''}));
+    const element = await fixture<GrCommentThread>(html`
+      <gr-comment-thread
+        .thread=${thread}
+        .shouldScrollIntoView=${true}
+      ></gr-comment-thread>
+    `);
+    await element.updateComplete;
+    await new Promise(resolve => setTimeout(resolve, 0));
+
+    const commentBox = queryAndAssert<HTMLElement>(element, '.comment-box');
+    assert.notEqual(element.shadowRoot?.activeElement, commentBox);
+  });
+
+  test('comment box spans 100% of container width', async () => {
+    const wrapper = await fixture(html`
+      <div style="width: 500px;">
+        <gr-comment-thread></gr-comment-thread>
+      </div>
+    `);
+    const commentThread = wrapper.querySelector(
+      'gr-comment-thread'
+    ) as GrCommentThread;
+    commentThread.thread = createThread(c1);
+    await commentThread.updateComplete;
+
+    const commentBox = queryAndAssert<HTMLElement>(
+      commentThread,
+      '.comment-box'
+    );
+    const computedWidth = getComputedStyle(commentBox).width;
+    assert.isTrue(
+      computedWidth.endsWith('px') &&
+        Math.abs(parseFloat(computedWidth) - 500) < 30,
+      `Expected comment box width to span container width, but got: ${computedWidth}`
+    );
+  });
+
   test('renders with actions resolved', async () => {
     element.thread = createThread(c1, c2);
     await element.updateComplete;
@@ -316,7 +384,7 @@
           >
           </gr-diff>
           <div class="view-diff-container">
-            <a href="/c/test-repo-name/+/1/comment/the-draft/">
+            <a href="/c/test-repo-name/+/1/1/test-path-comment-thread#314">
               <gr-button
                 aria-disabled="false"
                 class="view-diff-button"
@@ -494,6 +562,85 @@
       // The draft should be discarded completely
       assert.equal(draftElement.messageText, '');
     });
+
+    test('handle Quote with multi-line message', async () => {
+      stubAdd.restore();
+      stubAdd = sinon
+        .stub(testResolver(commentsModelToken), 'addNewDraft')
+        .callsFake(draft => {
+          const newDraft = {
+            ...draft,
+            id: 'new-draft' as UrlEncodedCommentId,
+            __draft: true,
+          };
+          if (element.thread) {
+            element.thread = {
+              ...element.thread,
+              comments: [...element.thread.comments, newDraft],
+            };
+          }
+          return Promise.resolve(newDraft);
+        });
+
+      element.thread = createThread(c1, {
+        ...c2,
+        message: 'first line\nsecond line\nthird line',
+        unresolved: true,
+      });
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#quoteBtn').click();
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.in_reply_to, c2.id);
+      await element.updateComplete;
+
+      const draftElement = queryAndAssert<GrComment>(
+        element,
+        'gr-comment.draft'
+      );
+      await draftElement.updateComplete;
+      await waitUntil(
+        () =>
+          draftElement.messageText ===
+          '> first line\n> second line\n> third line\n\n'
+      );
+      assert.equal(
+        draftElement.messageText,
+        '> first line\n> second line\n> third line\n\n'
+      );
+    });
+
+    test('handle reply-to-comment event from child comment', async () => {
+      element.thread = createThread(c1, {...c2, unresolved: true});
+      await element.updateComplete;
+
+      const commentEl = queryAndAssert<GrComment>(element, 'gr-comment');
+      commentEl.dispatchEvent(
+        new CustomEvent('reply-to-comment', {
+          detail: {
+            content: 'custom response',
+            userWantsToEdit: true,
+            unresolved: true,
+          },
+          bubbles: true,
+          composed: true,
+        })
+      );
+
+      assert.isTrue(stubAdd.called);
+      assert.equal(stubAdd.lastCall.firstArg.in_reply_to, c2.id);
+      assert.equal(stubAdd.lastCall.firstArg.unresolved, true);
+    });
+
+    test('reply sets in_reply_to to the last comment id in thread', async () => {
+      element.thread = createThread(c1, c2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#replyBtn').click();
+      assert.isTrue(stubAdd.called);
+      const newDraft = stubAdd.lastCall.firstArg;
+      assert.equal(newDraft.in_reply_to, c2.id);
+    });
   });
 
   test('comments are sorted correctly', () => {
@@ -739,4 +886,340 @@
       ]);
     });
   });
+
+  suite('Disagree button', () => {
+    setup(async () => {
+      element.isOwner = true;
+      element.account = createAccountDetailWithId(13);
+      element.thread = createThread({...c1, is_ai: true, unresolved: true});
+      await element.updateComplete;
+    });
+
+    test('renders with unresolved AI comment when owner', async () => {
+      assert.isOk(query(element, '#disagreeBtn'));
+    });
+
+    test('does not show disagree button if comment is not AI', async () => {
+      element.thread = createThread({...c1, is_ai: false, unresolved: true});
+      await element.updateComplete;
+      assert.isNotOk(query(element, '#disagreeBtn'));
+    });
+
+    test('does not show disagree button if user is not change owner', async () => {
+      element.isOwner = false;
+      await element.updateComplete;
+      assert.isNotOk(query(element, '#disagreeBtn'));
+    });
+
+    test('does not show disagree button if thread has more than 1 comment', async () => {
+      element.thread = createThread(
+        {...c1, is_ai: true, unresolved: true},
+        createComment()
+      );
+      await element.updateComplete;
+      assert.isNotOk(query(element, '#disagreeBtn'));
+    });
+
+    test('handleCommentDisagree creates a "Disagree." reply', async () => {
+      const createReplyCommentSpy = sinon.spy(
+        element as unknown as {createReplyComment: () => void},
+        'createReplyComment'
+      );
+      queryAndAssert<GrButton>(element, '#disagreeBtn').click();
+      assert.isTrue(createReplyCommentSpy.calledOnce);
+      assert.deepEqual(createReplyCommentSpy.firstCall.args, [
+        'Disagree.',
+        false,
+        false,
+      ]);
+    });
+  });
+
+  suite('getUrlForFileComment direct diff link', () => {
+    setup(async () => {
+      element.repoName = 'test-repo' as RepoName;
+      element.changeNum = 1 as NumericChangeId;
+    });
+
+    test('generates direct diff url for latest patchset comment on right side', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c1' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        line: 15,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 2 as RevisionPatchSetNum,
+        line: 15,
+        commentSide: CommentSide.REVISION,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/2/test-file.ts#15');
+    });
+
+    test('generates direct diff url with #b prefix for parent side comment', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c2' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        line: 20,
+        side: CommentSide.PARENT,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 2 as RevisionPatchSetNum,
+        line: 20,
+        commentSide: CommentSide.PARENT,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/2/test-file.ts#b20');
+    });
+
+    test('generates direct diff url without # line anchor for file-level comment', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c3' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        line: undefined,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 2 as RevisionPatchSetNum,
+        line: undefined,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/2/test-file.ts');
+    });
+
+    test('returns undefined for patchset level comment', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c4' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.isUndefined(url);
+    });
+
+    test('generates direct diff url for comment with range', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c5' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        range: {
+          start_line: 10,
+          start_character: 1,
+          end_line: 15,
+          end_character: 5,
+        },
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 2 as RevisionPatchSetNum,
+        line: undefined,
+        range: {
+          start_line: 10,
+          start_character: 1,
+          end_line: 15,
+          end_character: 5,
+        },
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/2/test-file.ts#15');
+    });
+
+    test('generates direct diff url for comment with inverted range', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c6' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        range: {
+          start_line: 20,
+          start_character: 1,
+          end_line: 10,
+          end_character: 5,
+        },
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 2 as RevisionPatchSetNum,
+        line: undefined,
+        range: {
+          start_line: 20,
+          start_character: 1,
+          end_line: 10,
+          end_character: 5,
+        },
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/2/test-file.ts#20');
+    });
+
+    test('generates direct diff url without # anchor when line is non-number FILE', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c7' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 2 as RevisionPatchSetNum,
+        line: 'FILE' as unknown as number,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/2/test-file.ts');
+    });
+
+    test('generates direct diff url using thread patchNum and line for ported comment', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c8' as UrlEncodedCommentId,
+        patch_set: 1 as RevisionPatchSetNum,
+        line: 10,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 3 as RevisionPatchSetNum,
+        line: 25,
+        commentSide: CommentSide.REVISION,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/3/test-file.ts#25');
+    });
+
+    test('generates direct diff url using thread range end_line when thread line is undefined', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c9' as UrlEncodedCommentId,
+        patch_set: 1 as RevisionPatchSetNum,
+        line: 10,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 3 as RevisionPatchSetNum,
+        line: undefined,
+        range: {
+          start_line: 20,
+          start_character: 1,
+          end_line: 25,
+          end_character: 5,
+        },
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/3/test-file.ts#25');
+    });
+
+    test('falls back to comment url when thread path is undefined', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c10' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        line: 15,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: undefined as unknown as string,
+        patchNum: 2 as RevisionPatchSetNum,
+        line: 15,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/comment/c10/');
+    });
+
+    test('falls back to comment url when patchNum is undefined on both thread and comment', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c11' as UrlEncodedCommentId,
+        patch_set: undefined,
+        line: 15,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: undefined,
+        line: 15,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.equal(url, '/c/test-repo/+/1/comment/c11/');
+    });
+
+    test('returns undefined when path is missing and comment id is undefined', () => {
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: undefined as unknown as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        line: 15,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: undefined as unknown as string,
+        patchNum: 2 as RevisionPatchSetNum,
+        line: 15,
+      };
+
+      // @ts-expect-error (testing private method)
+      const url = element.getUrlForFileComment();
+      assert.isUndefined(url);
+    });
+
+    test('handleCopyLink preserves shareable comment url format', () => {
+      const copyStub = sinon.stub(navigator.clipboard, 'writeText');
+      const comment: CommentInfo = {
+        ...createComment(),
+        id: 'c123' as UrlEncodedCommentId,
+        patch_set: 2 as RevisionPatchSetNum,
+        line: 15,
+      };
+      element.thread = {
+        ...createThread(comment),
+        path: 'test-file.ts',
+        patchNum: 2 as RevisionPatchSetNum,
+        line: 15,
+      };
+
+      // @ts-expect-error (testing private method)
+      element.handleCopyLink();
+
+      assert.isTrue(copyStub.calledOnce);
+      assert.isTrue(
+        copyStub.firstCall.args[0].endsWith('/c/test-repo/+/1/comment/c123/')
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 14187b8..24ca939 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -219,14 +219,14 @@
   @state()
   changeNum?: NumericChangeId;
 
-  @state()
+  @property({type: Boolean})
   editing = false;
 
   @state()
   repoName?: RepoName;
 
   /* The 'dirty' state of the comment.message, which will be saved on demand. */
-  @state()
+  @property({type: String})
   messageText = '';
 
   /**
@@ -242,8 +242,12 @@
 
   readonly autocompleteCache = new AutocompleteCache();
 
+  private autocompletePromise?: Promise<AutocompletionContext | undefined>;
+
+  private generateSuggestionPromise?: Promise<FixSuggestionInfo | undefined>;
+
   /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
-  @state()
+  @property({type: Boolean})
   unresolved = true;
 
   @state()
@@ -762,11 +766,18 @@
   private renderAuthor() {
     if (isDraft(this.comment)) return;
     return html`
-      <gr-account-label .account=${this.comment?.author ?? this.account}>
+      <gr-account-label
+        .account=${this.comment?.author ?? this.account}
+        ?is-ai=${this.isAiComment()}
+      >
       </gr-account-label>
     `;
   }
 
+  private isAiComment(): boolean {
+    return !!this.comment?.is_ai;
+  }
+
   private renderPortedCommentMessage() {
     if (!this.showPortedComment) return;
     if (!this.comment?.patch_set) return;
@@ -1215,7 +1226,7 @@
           ></md-checkbox>
           Attach AI-suggested fix
           ${when(
-            this.suggestionLoading,
+            this.generateSuggestion && this.suggestionLoading,
             () => html`<span class="loadingSpin"></span>`,
             () => html`${this.getNumberOfSuggestions()}`
           )}
@@ -1255,19 +1266,27 @@
       this.wasSuggestionEdited
     )
       return;
+
+    if (this.generateSuggestionPromise) {
+      return;
+    }
+
     this.generatedSuggestionId = uuid();
     this.suggestionLoading = true;
+    this.generateSuggestionPromise =
+      this.getSuggestionsService().generateSuggestedFixForComment(
+        this.comment,
+        this.messageText,
+        this.generatedSuggestionId,
+        ReportSource.FIX_FOR_REVIEWER_COMMENT
+      );
+
     let suggestion: FixSuggestionInfo | undefined;
     try {
-      suggestion =
-        await this.getSuggestionsService().generateSuggestedFixForComment(
-          this.comment,
-          this.messageText,
-          this.generatedSuggestionId,
-          ReportSource.FIX_FOR_REVIEWER_COMMENT
-        );
+      suggestion = await this.generateSuggestionPromise;
     } finally {
       this.suggestionLoading = false;
+      this.generateSuggestionPromise = undefined;
     }
 
     if (!suggestion) return;
@@ -1279,12 +1298,25 @@
   // private but visible for testing
   async autocompleteComment() {
     if (!this.autocompleteEnabled) return;
+
+    if (this.autocompletePromise) {
+      return;
+    }
+
     const commentText = this.messageText;
-    const context = await this.getSuggestionsService().autocompleteComment(
+    this.autocompletePromise = this.getSuggestionsService().autocompleteComment(
       this.comment,
       this.messageText,
       this.comments
     );
+
+    let context: AutocompletionContext | undefined;
+    try {
+      context = await this.autocompletePromise;
+    } finally {
+      this.autocompletePromise = undefined;
+    }
+
     if (!context) return;
     this.reportHintInteraction(
       Interaction.COMMENT_COMPLETION_SUGGESTION_FETCHED,
@@ -1349,11 +1381,9 @@
   override updated(changed: PropertyValues) {
     if (changed.has('editing')) {
       if (this.editing && !this.permanentEditingMode) {
-        // Note that this is a bit fragile, because we are relying on the
-        // comment to become visible soonish. If that does not happen, then we
-        // will be waiting indefinitely and grab focus at some point in the
-        // distant future.
-        whenVisible(this, () => this.textarea?.putCursorAtEnd());
+        this.focusTextarea().catch(() => {
+          // Ignore error since failure to focus is non-fatal.
+        });
       }
     }
     if (changed.has('changeNum') || changed.has('comment')) {
@@ -1368,6 +1398,27 @@
     }
   }
 
+  private async focusTextarea(): Promise<void> {
+    await this.updateComplete;
+    if (!this.textarea) {
+      return;
+    }
+    await this.textarea.updateComplete;
+    if (this.isVisible()) {
+      this.textarea.putCursorAtEnd();
+    } else {
+      // Note that this is a bit fragile, because we are relying on the
+      // comment to become visible soonish. If that does not happen, then we
+      // will be waiting indefinitely and grab focus at some point in the
+      // distant future.
+      whenVisible(this, () => this.textarea?.putCursorAtEnd());
+    }
+  }
+
+  private isVisible(): boolean {
+    return this.offsetWidth > 0 || this.offsetHeight > 0;
+  }
+
   override willUpdate(changed: PropertyValues) {
     this.firstWillUpdate();
     if (changed.has('comment')) {
@@ -1634,15 +1685,19 @@
   private rawSave(options: {showToast: boolean}) {
     assert(isDraft(this.comment), 'only drafts are editable');
     assert(!isSaving(this.comment), 'saving already in progress');
+
     const draft: DraftInfo = {
       ...this.comment,
       message: this.messageText.trimEnd(),
       unresolved: this.unresolved,
     };
+
     if (this.isFixSuggestionChanged()) {
       draft.fix_suggestions = this.getFixSuggestions();
     }
+
     this.reportHintInteractionSaved();
+
     return this.getCommentsModel().saveDraft(draft, options.showToast);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_screenshot_test.ts
new file mode 100644
index 0000000..4c2e825
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_screenshot_test.ts
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {visualDiffDarkTheme} from '../../../test/test-utils';
+import {GrComment} from './gr-comment';
+import './gr-comment';
+import {
+  CommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+  createAccountDetailWithId,
+  createComment,
+} from '../../../test/test-data-generators';
+
+const c_normal: CommentInfo = {
+  ...createComment(),
+  author: createAccountDetailWithId(1),
+  id: 'normal-comment' as UrlEncodedCommentId,
+  message: 'This is a normal comment',
+  updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const c_ai: CommentInfo = {
+  ...createComment(),
+  author: createAccountDetailWithId(2),
+  id: 'ai-comment' as UrlEncodedCommentId,
+  message: 'This is an AI generated comment',
+  updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+  is_ai: true,
+};
+
+suite('gr-comment screenshot tests', () => {
+  let element: GrComment;
+
+  test('normal comment', async () => {
+    element = await fixture(
+      html`<gr-comment .comment=${c_normal}></gr-comment>`
+    );
+    await visualDiff(element, 'gr-comment-normal');
+    await visualDiffDarkTheme(element, 'gr-comment-normal');
+  });
+
+  test('AI comment', async () => {
+    element = await fixture(html`<gr-comment .comment=${c_ai}></gr-comment>`);
+    await visualDiff(element, 'gr-comment-ai');
+    await visualDiffDarkTheme(element, 'gr-comment-ai');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 3d57456..b437a74 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -52,6 +52,9 @@
 import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 import {ResponseCode} from '../../../api/suggestions';
 import {suggestionsServiceToken} from '../../../services/suggestions/suggestions-service';
+import {AutocompletionContext} from '../../../utils/autocomplete-cache';
+import {FixSuggestionInfo} from '../../../api/rest-api';
+import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
 
 suite('gr-comment tests', () => {
   let element: GrComment;
@@ -531,6 +534,24 @@
       assert.isTrue(element.isSaveDisabled());
     });
 
+    test('focuses textarea when editing is set to true', async () => {
+      const spy = sinon.spy(GrSuggestionTextarea.prototype, 'putCursorAtEnd');
+      try {
+        element.comment = createDraft();
+        element.editing = false;
+        await element.updateComplete;
+
+        element.editing = true;
+        await element.updateComplete;
+        // focusTextarea is async, wait for it to complete.
+        await new Promise(resolve => setTimeout(resolve, 0));
+
+        assert.isTrue(spy.called);
+      } finally {
+        spy.restore();
+      }
+    });
+
     test('ctrl+s saves comment', async () => {
       const spy = sinon.stub(element, 'save');
       element.messageText = 'is that the horse from horsing around??';
@@ -974,6 +995,39 @@
       assert.include(label.textContent, '(1)');
     });
 
+    test('does not show spinner when loading but unchecked', async () => {
+      const comment: DraftInfo = {
+        ...createDraft(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        savingState: SavingState.OK,
+        message: 'hello world',
+      };
+      element = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${false}
+        ></gr-comment>`
+      );
+      element.editing = true;
+      sinon.stub(element, 'showGeneratedSuggestion').returns(true);
+      element.generateSuggestion = false;
+      element.suggestionLoading = true;
+      await element.updateComplete;
+
+      const label = queryAndAssert<HTMLLabelElement>(
+        element,
+        'label.suggestEdit'
+      );
+      assert.isFalse(!!query(label, '.loadingSpin'));
+    });
+
     test('renders suggestions in comment', async () => {
       const comment = {
         ...createComment(),
@@ -1213,5 +1267,77 @@
       assert.isFalse(setStub.called);
       assert.isUndefined(element.autocompleteHint);
     });
+
+    test('drops concurrent request', async () => {
+      const suggestionsService = testResolver(suggestionsServiceToken);
+      const promise1 = mockPromise<AutocompletionContext>();
+      const stub = sinon.stub(suggestionsService, 'autocompleteComment');
+      stub.onCall(0).returns(promise1);
+
+      const p1 = element.autocompleteComment();
+      assert.isTrue(stub.calledOnce);
+
+      await element.autocompleteComment();
+      assert.isTrue(stub.calledOnce);
+
+      promise1.resolve({
+        draftContent: 'test',
+        commentCompletion: ' completion 1',
+        responseCode: ResponseCode.OK,
+      });
+      await p1;
+    });
+  });
+
+  suite('generateSuggestEdit', () => {
+    test('drops concurrent request', async () => {
+      const suggestionsService = testResolver(suggestionsServiceToken);
+      const promise1 = mockPromise<FixSuggestionInfo>();
+      const stub = sinon.stub(
+        suggestionsService,
+        'generateSuggestedFixForComment'
+      );
+      stub.onCall(0).returns(promise1);
+
+      element.generateSuggestion = true;
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
+        .returns(true);
+      sinon.stub(element, 'showGeneratedSuggestion').returns(true);
+      element.messageText = 'test message';
+      await element.updateComplete;
+
+      const p1 = element.generateSuggestEdit();
+      assert.isTrue(stub.calledOnce);
+
+      await element.generateSuggestEdit();
+      assert.isTrue(stub.calledOnce);
+
+      promise1.resolve({
+        description: 'suggestion 1',
+        fix_id: '1' as FixId,
+        replacements: [],
+      });
+      await p1;
+    });
+
+    test('calls service even when generateSuggestion is false', async () => {
+      const suggestionsService = testResolver(suggestionsServiceToken);
+      const stub = sinon.stub(
+        suggestionsService,
+        'generateSuggestedFixForComment'
+      );
+
+      element.generateSuggestion = false;
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
+        .returns(true);
+      sinon.stub(element, 'showGeneratedSuggestion').returns(true);
+      element.messageText = 'test message';
+      await element.updateComplete;
+
+      await element.generateSuggestEdit();
+      assert.isTrue(stub.called);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
index 0e0638e..869f9df 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
@@ -4,14 +4,14 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
 import {styleMap} from 'lit/directives/style-map.js';
 
-const SIDEBAR_MIN_WIDTH = 400;
+const SIDEBAR_MIN_WIDTH = 250;
 
 /**
  * A component that displays content in a main area and a resizable sidebar.
- * The sidebar can be toggled between hidden and visible.
+ * The sidebar can be toggled between hidden and visible and positioned on the left or right.
  *
  * slot main - The content to be displayed in the main area.
  * slot side - The content to be displayed in the sidebar.
@@ -21,11 +21,17 @@
   @query('.sidebar-wrapper') sidebarWrapper?: HTMLElement;
 
   @state()
-  private sidebarWidthPx = SIDEBAR_MIN_WIDTH;
+  private sidebarWidthPx = 400;
 
   @property()
   hideSide = true;
 
+  @property()
+  side: 'left' | 'right' = 'right';
+
+  @property({type: Number})
+  minWidth = SIDEBAR_MIN_WIDTH;
+
   private isSidebarResizing = false;
 
   private sidebarResizingStartPosPx = 0;
@@ -43,22 +49,29 @@
         :host {
           display: block;
           position: relative;
-          --sidebar-height: calc(100vh - var(--sidebar-top));
+          --sidebar-height: calc(100vh - var(--sidebar-top, 0px));
         }
         .sidebar-wrapper {
           z-index: 50;
           position: absolute;
           display: flex;
           top: 0;
-          bottom: calc(0px - var(--sidebar-bottom-overflow));
-          right: 0;
-          min-width: 400px;
+          bottom: calc(0px - var(--sidebar-bottom-overflow, 0px));
+          min-width: 250px;
           max-width: 100%;
           background-color: var(--background-color-secondary);
         }
+        .sidebar-wrapper.right {
+          right: 0;
+          left: auto;
+        }
+        .sidebar-wrapper.left {
+          left: 0;
+          right: auto;
+        }
         .sidebar {
           position: sticky;
-          top: var(--sidebar-top);
+          top: var(--sidebar-top, 0px);
           height: var(--sidebar-height);
           box-sizing: border-box;
           overflow: auto;
@@ -67,35 +80,55 @@
         }
         .resizer-wrapper {
           position: sticky;
-          top: var(--sidebar-top);
+          top: var(--sidebar-top, 0px);
           height: var(--sidebar-height);
           z-index: 51;
         }
         .resizer {
           background-color: var(--background-color-secondary);
           width: 7px;
-          border-left: 1px solid var(--border-color);
           cursor: ew-resize;
           position: absolute;
           top: 0;
           bottom: 0;
-          left: -7px;
           box-sizing: border-box;
         }
-        .resizer:hover {
+        .resizer.right-side {
+          left: -7px;
+          border-left: 1px solid var(--border-color);
+        }
+        .resizer.right-side:hover {
           background-color: var(--background-color-tertiary);
           width: 11px;
           left: -9px;
         }
+        .resizer.left-side {
+          right: -7px;
+          border-right: 1px solid var(--border-color);
+        }
+        .resizer.left-side:hover {
+          background-color: var(--background-color-tertiary);
+          width: 11px;
+          right: -9px;
+        }
       `,
     ];
   }
 
   override render() {
     const widthPx = this.hideSide ? 0 : this.sidebarWidthPx;
+    const mainStyle =
+      this.side === 'left'
+        ? styleMap({
+            marginLeft: `${widthPx}px`,
+            width: `calc(100% - ${widthPx}px)`,
+          })
+        : styleMap({
+            width: `calc(100% - ${widthPx}px)`,
+          });
     return html`
       <div>
-        <div style=${styleMap({width: `calc(100% - ${widthPx}px)`})}>
+        <div style=${mainStyle}>
           <slot name="main"></slot>
         </div>
         ${this.renderSidebar()}
@@ -105,25 +138,34 @@
 
   private renderSidebar() {
     if (this.hideSide) return;
+    const sideClass = this.side === 'left' ? 'left' : 'right';
+    const resizerClass = this.side === 'left' ? 'left-side' : 'right-side';
     return html`
       <div
-        class="sidebar-wrapper"
+        class="sidebar-wrapper ${sideClass}"
         style=${styleMap({width: `${this.sidebarWidthPx}px`})}
       >
-        <div class="resizer-wrapper">
-          <div
-            class="resizer"
-            role="separator"
-            aria-orientation="vertical"
-            aria-valuenow=${this.sidebarWidthPx}
-            aria-label="Resize sidebar"
-            tabindex="0"
-            @mousedown=${this.startSidebarResize}
-          ></div>
-        </div>
+        ${this.side === 'right' ? this.renderResizer(resizerClass) : nothing}
         <div class="sidebar">
           <slot name="side"></slot>
         </div>
+        ${this.side === 'left' ? this.renderResizer(resizerClass) : nothing}
+      </div>
+    `;
+  }
+
+  private renderResizer(resizerClass: string) {
+    return html`
+      <div class="resizer-wrapper">
+        <div
+          class="resizer ${resizerClass}"
+          role="separator"
+          aria-orientation="vertical"
+          aria-valuenow=${this.sidebarWidthPx}
+          aria-label="Resize sidebar"
+          tabindex="0"
+          @mousedown=${this.startSidebarResize}
+        ></div>
       </div>
     `;
   }
@@ -157,10 +199,11 @@
     if (!this.isSidebarResizing || event.buttons === 0) return;
 
     const widthDiffPx = event.clientX - this.sidebarResizingStartPosPx;
-    this.sidebarWidthPx = Math.max(
-      this.sidebarResizingStartWidthPx - widthDiffPx,
-      SIDEBAR_MIN_WIDTH
-    );
+    const rawWidth =
+      this.side === 'right'
+        ? this.sidebarResizingStartWidthPx - widthDiffPx
+        : this.sidebarResizingStartWidthPx + widthDiffPx;
+    this.sidebarWidthPx = Math.max(rawWidth, this.minWidth);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
index 52ea31d..7ed19b7 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
@@ -44,8 +44,23 @@
     await element.updateComplete;
   });
 
-  test('screenshot', async () => {
-    await visualDiff(wrapper, 'gr-content-with-sidebar');
-    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar');
+  test('screenshot right sidebar', async () => {
+    const element = wrapper.querySelector<GrContentWithSidebar>(
+      'gr-content-with-sidebar'
+    )!;
+    element.side = 'right';
+    await element.updateComplete;
+    await visualDiff(wrapper, 'gr-content-with-sidebar-right');
+    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar-right');
+  });
+
+  test('screenshot left sidebar', async () => {
+    const element = wrapper.querySelector<GrContentWithSidebar>(
+      'gr-content-with-sidebar'
+    )!;
+    element.side = 'left';
+    await element.updateComplete;
+    await visualDiff(wrapper, 'gr-content-with-sidebar-left');
+    await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar-left');
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
index 7905c0a..626e3dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
@@ -34,8 +34,9 @@
     );
   });
 
-  test('renders sidebar', async () => {
+  test('renders right sidebar', async () => {
     element.hideSide = false;
+    element.side = 'right';
     await element.updateComplete;
 
     assert.shadowDom.equal(
@@ -45,13 +46,13 @@
           <div style="width: calc(100% - 400px);">
             <slot name="main"> </slot>
           </div>
-          <div class="sidebar-wrapper" style="width:400px;">
+          <div class="right sidebar-wrapper" style="width:400px;">
             <div class="resizer-wrapper">
               <div
                 aria-label="Resize sidebar"
                 aria-orientation="vertical"
                 aria-valuenow="400"
-                class="resizer"
+                class="right-side resizer"
                 role="separator"
                 tabindex="0"
               ></div>
@@ -64,4 +65,36 @@
       `
     );
   });
+
+  test('renders left sidebar', async () => {
+    element.hideSide = false;
+    element.side = 'left';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div>
+          <div style="width: calc(100% - 400px); margin-left: 400px;">
+            <slot name="main"> </slot>
+          </div>
+          <div class="left sidebar-wrapper" style="width:400px;">
+            <div class="sidebar">
+              <slot name="side"> </slot>
+            </div>
+            <div class="resizer-wrapper">
+              <div
+                aria-label="Resize sidebar"
+                aria-orientation="vertical"
+                aria-valuenow="400"
+                class="left-side resizer"
+                role="separator"
+                tabindex="0"
+              ></div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 0ee4aa3..8e8ec47 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -52,6 +52,9 @@
   @property({type: Boolean, reflect: true})
   hideInput = false;
 
+  @property({type: Boolean, attribute: 'disable-auto-select', reflect: true})
+  disableAutoSelect = false;
+
   @property({type: String})
   label?: string;
 
@@ -62,7 +65,7 @@
   @property({type: String, reflect: true})
   copyTargetName?: string;
 
-  @property({type: Boolean})
+  @property({type: Boolean, reflect: true})
   multiline = false;
 
   @property({type: Boolean, reflect: true})
@@ -94,6 +97,18 @@
         :host([nowrap]) .text {
           flex-wrap: nowrap;
         }
+        :host([multiline]) .text {
+          align-items: flex-start;
+        }
+        :host([multiline]) .text label {
+          margin-top: var(--spacing-s);
+        }
+        :host([multiline]) .text .shortcut {
+          margin-top: var(--spacing-s);
+        }
+        :host([multiline]) gr-button {
+          margin-top: 0;
+        }
         .text label {
           flex: 0 0 120px;
           color: var(--deemphasized-text-color);
@@ -187,7 +202,7 @@
           () => html`<span class="shortcut">${this.shortcut}</span>`
         )}
         <gr-tooltip-content
-          ?has-tooltip=${this.hasTooltip}
+          ?has-tooltip=${this.hasTooltip || !!this.buttonTitle}
           title=${ifDefined(this.buttonTitle)}
         >
           <gr-button
@@ -195,7 +210,7 @@
             link=""
             class="copyToClipboard"
             @click=${this.copyToClipboard}
-            aria-label="copy"
+            aria-label=${this.buttonTitle ?? 'copy'}
             aria-description="Click to copy to clipboard"
           >
             <div>
@@ -216,6 +231,7 @@
   }
 
   private handleInputClick(e: MouseEvent) {
+    if (this.disableAutoSelect) return;
     e.preventDefault();
     const rootTarget = e.composedPath()[0];
     (rootTarget as HTMLInputElement).select();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index b50ecfd..9ea0ee2 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -128,6 +128,22 @@
     assert.equal(mdOutlinedTextField.selectionEnd, element.text!.length - 1);
   });
 
+  test('handleInputClick with disableAutoSelect', async () => {
+    element.disableAutoSelect = true;
+    await element.updateComplete;
+
+    const mdOutlinedTextField = queryAndAssert<MdOutlinedTextField>(
+      element,
+      'md-outlined-text-field'
+    );
+    mdOutlinedTextField.selectionStart = 0;
+    mdOutlinedTextField.selectionEnd = 0;
+
+    mdOutlinedTextField.click();
+    assert.equal(mdOutlinedTextField.selectionStart, 0);
+    assert.equal(mdOutlinedTextField.selectionEnd, 0);
+  });
+
   test('hideInput', async () => {
     const mdOutlinedTextField = queryAndAssert<MdOutlinedTextField>(
       element,
@@ -149,4 +165,26 @@
     queryAndAssert<GrButton>(element, '.copyToClipboard').click();
     assert.isFalse(clickStub.called);
   });
+
+  test('buttonTitle enables tooltip and sets aria attributes', async () => {
+    element.buttonTitle = 'Copy custom item to clipboard';
+    await element.updateComplete;
+
+    const tooltipContent = queryAndAssert(element, 'gr-tooltip-content');
+    assert.isTrue(tooltipContent.hasAttribute('has-tooltip'));
+    assert.equal(
+      tooltipContent.getAttribute('title'),
+      'Copy custom item to clipboard'
+    );
+
+    const button = queryAndAssert<GrButton>(element, '.copyToClipboard');
+    assert.equal(
+      button.getAttribute('aria-label'),
+      'Copy custom item to clipboard'
+    );
+    assert.equal(
+      button.getAttribute('aria-description'),
+      'Click to copy to clipboard'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 225d730..f516076 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -5,11 +5,16 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
+import {
+  DiffPreferencesInfo,
+  DiffResponsiveMode,
+  IgnoreWhitespaceType,
+} from '../../../types/diff';
 import {subscribe} from '../../lit/subscription-controller';
 import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
+import {when} from 'lit/directives/when.js';
 import {customElement, query, state} from 'lit/decorators.js';
 import {convertToString} from '../../../utils/string-util';
 import {fire} from '../../../utils/event-util';
@@ -122,16 +127,50 @@
             </md-outlined-select>
           </span>
         </section>
-        <section>
-          <label for="lineWrappingInput" class="title">Fit to screen</label>
-          <span class="value">
-            <md-checkbox
-              id="lineWrappingInput"
-              ?checked=${!!this.diffPrefs?.line_wrapping}
-              @change=${this.handleLineWrappingTap}
-            ></md-checkbox>
-          </span>
-        </section>
+        ${
+          // TODO: Remove this conditional logic once backend support is guaranteed.
+          when(
+            this.diffPrefs?.responsive_mode !== undefined,
+            () => html`
+              <section>
+                <label for="lineWrappingSelect" class="title"
+                  >Fit to screen</label
+                >
+                <span class="value">
+                  <md-outlined-select
+                    id="lineWrappingSelect"
+                    value=${this.getResponsiveModeValue()}
+                    @change=${this.handleResponsiveModeChange}
+                  >
+                    <md-select-option value="NONE">
+                      <div slot="headline">None</div>
+                    </md-select-option>
+                    <md-select-option value="SHRINK_ONLY">
+                      <div slot="headline">Shrink only</div>
+                    </md-select-option>
+                    <md-select-option value="FULL_RESPONSIVE">
+                      <div slot="headline">Full responsive</div>
+                    </md-select-option>
+                  </md-outlined-select>
+                </span>
+              </section>
+            `,
+            () => html`
+              <section>
+                <label for="lineWrappingInput" class="title"
+                  >Fit to screen</label
+                >
+                <span class="value">
+                  <md-checkbox
+                    id="lineWrappingInput"
+                    ?checked=${!!this.diffPrefs?.line_wrapping}
+                    @change=${this.handleLineWrappingTap}
+                  ></md-checkbox>
+                </span>
+              </section>
+            `
+          )
+        }
         <section>
           <label for="columnsInput" class="title">Diff width</label>
           <span class="value">
@@ -278,7 +317,27 @@
   }
 
   private readonly handleLineWrappingTap = () => {
-    this.diffPrefs!.line_wrapping = this.lineWrappingInput!.checked;
+    if (!this.diffPrefs) return;
+    this.diffPrefs.line_wrapping = this.lineWrappingInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private getResponsiveModeValue() {
+    if (this.diffPrefs?.responsive_mode) {
+      return this.diffPrefs.responsive_mode;
+    }
+    return this.diffPrefs?.line_wrapping ? 'FULL_RESPONSIVE' : 'NONE';
+  }
+
+  private readonly handleResponsiveModeChange = (e: Event) => {
+    if (!this.diffPrefs) return;
+    const select = e.target as MdOutlinedSelect;
+    this.diffPrefs.responsive_mode = select.value as DiffResponsiveMode;
+    // Keep line_wrapping in sync for backward compatibility.
+    this.diffPrefs.line_wrapping = select.value === 'FULL_RESPONSIVE';
+    this.requestUpdate();
     fire(this, 'has-unsaved-changes-changed', {
       value: this.hasUnsavedChanges(),
     });
@@ -344,6 +403,8 @@
       this.originalDiffPrefs?.context !== this.diffPrefs?.context ||
       Boolean(this.originalDiffPrefs?.line_wrapping) !==
         Boolean(this.diffPrefs?.line_wrapping) ||
+      this.originalDiffPrefs?.responsive_mode !==
+        this.diffPrefs?.responsive_mode ||
       this.originalDiffPrefs?.line_length !== this.diffPrefs?.line_length ||
       this.originalDiffPrefs?.tab_size !== this.diffPrefs?.tab_size ||
       this.originalDiffPrefs?.font_size !== this.diffPrefs?.font_size ||
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
index 092c63f..1ba7900 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -36,264 +36,331 @@
     assert.fail(`element with title ${title} not found`);
   }
 
-  setup(async () => {
-    diffPreferences = createDefaultDiffPrefs();
+  suite('standard preferences', () => {
+    setup(async () => {
+      diffPreferences = createDefaultDiffPrefs();
 
-    stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
+      stubRestApi('getDiffPreferences').returns(
+        Promise.resolve(diffPreferences)
+      );
 
-    element = await fixture(html`<gr-diff-preferences></gr-diff-preferences>`);
+      element = await fixture(
+        html`<gr-diff-preferences></gr-diff-preferences>`
+      );
 
-    await element.updateComplete;
-  });
+      await element.updateComplete;
+    });
 
-  test('renders', () => {
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `<div class="gr-form-styles" id="diffPreferences">
-        <section>
-          <label class="title" for="contextLineSelect"> Context </label>
-          <span class="value">
-            <md-outlined-select id="contextSelect" value="10">
-              <md-select-option md-menu-item="" tabindex="0" value="3">
-                <div slot="headline">3 lines</div>
-              </md-select-option>
-              <md-select-option
-                data-aria-selected="true"
-                md-menu-item=""
-                tabindex="-1"
-                value="10"
-              >
-                <div slot="headline">10 lines</div>
-              </md-select-option>
-              <md-select-option md-menu-item="" tabindex="-1" value="25">
-                <div slot="headline">25 lines</div>
-              </md-select-option>
-              <md-select-option md-menu-item="" tabindex="-1" value="50">
-                <div slot="headline">50 lines</div>
-              </md-select-option>
-              <md-select-option md-menu-item="" tabindex="-1" value="75">
-                <div slot="headline">75 lines</div>
-              </md-select-option>
-              <md-select-option md-menu-item="" tabindex="-1" value="100">
-                <div slot="headline">100 lines</div>
-              </md-select-option>
-              <md-select-option md-menu-item="" tabindex="-1" value="-1">
-                <div slot="headline">Whole file</div>
-              </md-select-option>
-            </md-outlined-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="lineWrappingInput"> Fit to screen </label>
-          <span class="value">
-            <md-checkbox id="lineWrappingInput"> </md-checkbox>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="columnsInput"> Diff width </label>
-          <span class="value">
-            <md-outlined-text-field
-              autocomplete=""
-              class="showBlueFocusBorder"
-              id="columnsInput"
-              inputmode=""
-              step="1"
-              type="number"
-            >
-            </md-outlined-text-field>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="tabSizeInput"> Tab width </label>
-          <span class="value">
-            <md-outlined-text-field
-              autocomplete=""
-              class="showBlueFocusBorder"
-              id="tabSizeInput"
-              inputmode=""
-              step="1"
-              type="number"
-            >
-            </md-outlined-text-field>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="fontSizeInput"> Font size </label>
-          <span class="value">
-            <md-outlined-text-field
-              autocomplete=""
-              class="showBlueFocusBorder"
-              id="fontSizeInput"
-              inputmode=""
-              step="1"
-              type="number"
-            >
-            </md-outlined-text-field>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="showTabsInput"> Show tabs </label>
-          <span class="value">
-            <md-checkbox checked="" id="showTabsInput"> </md-checkbox>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="showTrailingWhitespaceInput">
-            Show trailing whitespace
-          </label>
-          <span class="value">
-            <md-checkbox checked="" id="showTrailingWhitespaceInput">
-            </md-checkbox>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="syntaxHighlightInput">
-            Syntax highlighting
-          </label>
-          <span class="value">
-            <md-checkbox checked="" id="syntaxHighlightInput"> </md-checkbox>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="automaticReviewInput">
-            Automatically mark viewed files reviewed
-          </label>
-          <span class="value">
-            <md-checkbox checked="" id="automaticReviewInput"> </md-checkbox>
-          </span>
-        </section>
-        <section>
-          <div class="pref">
-            <label class="title" for="ignoreWhiteSpace">
-              Ignore Whitespace
-            </label>
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<div class="gr-form-styles" id="diffPreferences">
+          <section>
+            <label class="title" for="contextLineSelect"> Context </label>
             <span class="value">
-              <md-outlined-select id="contextSelect" value="IGNORE_NONE">
+              <md-outlined-select id="contextSelect" value="10">
+                <md-select-option md-menu-item="" tabindex="-1" value="3">
+                  <div slot="headline">3 lines</div>
+                </md-select-option>
                 <md-select-option
                   data-aria-selected="true"
                   md-menu-item=""
                   tabindex="0"
-                  value="IGNORE_NONE"
+                  value="10"
                 >
+                  <div slot="headline">10 lines</div>
+                </md-select-option>
+                <md-select-option md-menu-item="" tabindex="-1" value="25">
+                  <div slot="headline">25 lines</div>
+                </md-select-option>
+                <md-select-option md-menu-item="" tabindex="-1" value="50">
+                  <div slot="headline">50 lines</div>
+                </md-select-option>
+                <md-select-option md-menu-item="" tabindex="-1" value="75">
+                  <div slot="headline">75 lines</div>
+                </md-select-option>
+                <md-select-option md-menu-item="" tabindex="-1" value="100">
+                  <div slot="headline">100 lines</div>
+                </md-select-option>
+                <md-select-option md-menu-item="" tabindex="-1" value="-1">
+                  <div slot="headline">Whole file</div>
+                </md-select-option>
+              </md-outlined-select>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="lineWrappingSelect">
+              Fit to screen
+            </label>
+            <span class="value">
+              <md-outlined-select id="lineWrappingSelect" value="NONE">
+                <md-select-option md-menu-item="" tabindex="0" value="NONE">
                   <div slot="headline">None</div>
                 </md-select-option>
                 <md-select-option
                   md-menu-item=""
                   tabindex="-1"
-                  value="IGNORE_TRAILING"
+                  value="SHRINK_ONLY"
                 >
-                  <div slot="headline">Trailing</div>
+                  <div slot="headline">Shrink only</div>
                 </md-select-option>
                 <md-select-option
                   md-menu-item=""
                   tabindex="-1"
-                  value="IGNORE_LEADING_AND_TRAILING"
+                  value="FULL_RESPONSIVE"
                 >
-                  <div slot="headline">Leading & trailing</div>
-                </md-select-option>
-                <md-select-option
-                  md-menu-item=""
-                  tabindex="-1"
-                  value="IGNORE_ALL"
-                >
-                  <div slot="headline">All</div>
+                  <div slot="headline">Full responsive</div>
                 </md-select-option>
               </md-outlined-select>
             </span>
-          </div>
-        </section>
-      </div>`
-    );
+          </section>
+          <section>
+            <label class="title" for="columnsInput"> Diff width </label>
+            <span class="value">
+              <md-outlined-text-field
+                autocomplete=""
+                class="showBlueFocusBorder"
+                id="columnsInput"
+                inputmode=""
+                step="1"
+                type="number"
+              >
+              </md-outlined-text-field>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="tabSizeInput"> Tab width </label>
+            <span class="value">
+              <md-outlined-text-field
+                autocomplete=""
+                class="showBlueFocusBorder"
+                id="tabSizeInput"
+                inputmode=""
+                step="1"
+                type="number"
+              >
+              </md-outlined-text-field>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="fontSizeInput"> Font size </label>
+            <span class="value">
+              <md-outlined-text-field
+                autocomplete=""
+                class="showBlueFocusBorder"
+                id="fontSizeInput"
+                inputmode=""
+                step="1"
+                type="number"
+              >
+              </md-outlined-text-field>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="showTabsInput"> Show tabs </label>
+            <span class="value">
+              <md-checkbox checked="" id="showTabsInput"> </md-checkbox>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="showTrailingWhitespaceInput">
+              Show trailing whitespace
+            </label>
+            <span class="value">
+              <md-checkbox checked="" id="showTrailingWhitespaceInput">
+              </md-checkbox>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="syntaxHighlightInput">
+              Syntax highlighting
+            </label>
+            <span class="value">
+              <md-checkbox checked="" id="syntaxHighlightInput"> </md-checkbox>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="automaticReviewInput">
+              Automatically mark viewed files reviewed
+            </label>
+            <span class="value">
+              <md-checkbox checked="" id="automaticReviewInput"> </md-checkbox>
+            </span>
+          </section>
+          <section>
+            <div class="pref">
+              <label class="title" for="ignoreWhiteSpace">
+                Ignore Whitespace
+              </label>
+              <span class="value">
+                <md-outlined-select id="contextSelect" value="IGNORE_NONE">
+                  <md-select-option
+                    data-aria-selected="true"
+                    md-menu-item=""
+                    tabindex="0"
+                    value="IGNORE_NONE"
+                  >
+                    <div slot="headline">None</div>
+                  </md-select-option>
+                  <md-select-option
+                    md-menu-item=""
+                    tabindex="-1"
+                    value="IGNORE_TRAILING"
+                  >
+                    <div slot="headline">Trailing</div>
+                  </md-select-option>
+                  <md-select-option
+                    md-menu-item=""
+                    tabindex="-1"
+                    value="IGNORE_LEADING_AND_TRAILING"
+                  >
+                    <div slot="headline">Leading & trailing</div>
+                  </md-select-option>
+                  <md-select-option
+                    md-menu-item=""
+                    tabindex="-1"
+                    value="IGNORE_ALL"
+                  >
+                    <div slot="headline">All</div>
+                  </md-select-option>
+                </md-outlined-select>
+              </span>
+            </div>
+          </section>
+        </div>`
+      );
+    });
+
+    test('renders preferences', () => {
+      // Rendered with the expected preferences selected.
+      const contextInput = valueOf('Context', 'diffPreferences')
+        .firstElementChild as MdOutlinedSelect;
+      assert.equal(contextInput.value, `${diffPreferences.context}`);
+
+      const lineWrappingInput = valueOf('Fit to screen', 'diffPreferences')
+        .firstElementChild as MdOutlinedSelect;
+      assert.equal(lineWrappingInput.value, diffPreferences.responsive_mode);
+
+      const lineLengthInput = valueOf('Diff width', 'diffPreferences')
+        .firstElementChild as MdOutlinedTextField;
+      assert.equal(lineLengthInput.value, `${diffPreferences.line_length}`);
+
+      const tabSizeInput = valueOf('Tab width', 'diffPreferences')
+        .firstElementChild as MdOutlinedTextField;
+      assert.equal(tabSizeInput.value, `${diffPreferences.tab_size}`);
+
+      const fontSizeInput = valueOf('Font size', 'diffPreferences')
+        .firstElementChild as MdOutlinedTextField;
+      assert.equal(fontSizeInput.value, `${diffPreferences.font_size}`);
+
+      const showTabsInput = valueOf('Show tabs', 'diffPreferences')
+        .firstElementChild as HTMLInputElement;
+      assert.equal(showTabsInput.checked, diffPreferences.show_tabs);
+
+      const showWhitespaceErrorsInput = valueOf(
+        'Show trailing whitespace',
+        'diffPreferences'
+      ).firstElementChild as HTMLInputElement;
+      assert.equal(
+        showWhitespaceErrorsInput.checked,
+        diffPreferences.show_whitespace_errors
+      );
+
+      const syntaxHighlightingInput = valueOf(
+        'Syntax highlighting',
+        'diffPreferences'
+      ).firstElementChild as HTMLInputElement;
+      assert.equal(
+        syntaxHighlightingInput.checked,
+        diffPreferences.syntax_highlighting
+      );
+
+      const manualReviewInput = valueOf(
+        'Automatically mark viewed files reviewed',
+        'diffPreferences'
+      ).firstElementChild as HTMLInputElement;
+      assert.equal(manualReviewInput.checked, !diffPreferences.manual_review);
+
+      const ignoreWhitespaceInput = valueOf(
+        'Ignore Whitespace',
+        'diffPreferences'
+      ).firstElementChild as MdOutlinedSelect;
+      assert.equal(
+        ignoreWhitespaceInput.value,
+        diffPreferences.ignore_whitespace
+      );
+
+      assert.isFalse(element.hasUnsavedChanges());
+    });
+
+    test('save changes', async () => {
+      assert.isTrue(element.diffPrefs!.show_whitespace_errors);
+
+      const showTrailingWhitespaceCheckbox = valueOf(
+        'Show trailing whitespace',
+        'diffPreferences'
+      ).firstElementChild as HTMLInputElement;
+      showTrailingWhitespaceCheckbox.checked = false;
+      element.handleShowTrailingWhitespaceTap();
+
+      assert.isTrue(element.hasUnsavedChanges());
+
+      const savePrefStub = stubRestApi('saveDiffPreferences').resolves(
+        new Response(makePrefixedJSON(element.diffPrefs))
+      );
+
+      await element.save();
+      // Wait for model state update, since this is not awaited by element.save()
+      await waitUntil(
+        () =>
+          !element.getUserModel().getState().diffPreferences
+            ?.show_whitespace_errors
+      );
+
+      assert.isTrue(savePrefStub.called);
+      assert.isFalse(element.diffPrefs!.show_whitespace_errors);
+      assert.isFalse(element.hasUnsavedChanges());
+    });
   });
 
-  test('renders preferences', () => {
-    // Rendered with the expected preferences selected.
-    const contextInput = valueOf('Context', 'diffPreferences')
-      .firstElementChild as MdOutlinedSelect;
-    assert.equal(contextInput.value, `${diffPreferences.context}`);
+  suite('legacy preferences', () => {
+    let element: GrDiffPreferences;
+    let diffPreferences: DiffPreferencesInfo;
 
-    const lineWrappingInput = valueOf('Fit to screen', 'diffPreferences')
-      .firstElementChild as HTMLInputElement;
-    assert.equal(lineWrappingInput.checked, diffPreferences.line_wrapping);
+    setup(async () => {
+      diffPreferences = {
+        ...createDefaultDiffPrefs(),
+        responsive_mode: undefined,
+        line_wrapping: true,
+      };
 
-    const lineLengthInput = valueOf('Diff width', 'diffPreferences')
-      .firstElementChild as MdOutlinedTextField;
-    assert.equal(lineLengthInput.value, `${diffPreferences.line_length}`);
+      stubRestApi('getDiffPreferences').returns(
+        Promise.resolve(diffPreferences)
+      );
 
-    const tabSizeInput = valueOf('Tab width', 'diffPreferences')
-      .firstElementChild as MdOutlinedTextField;
-    assert.equal(tabSizeInput.value, `${diffPreferences.tab_size}`);
+      element = await fixture(
+        html`<gr-diff-preferences></gr-diff-preferences>`
+      );
 
-    const fontSizeInput = valueOf('Font size', 'diffPreferences')
-      .firstElementChild as MdOutlinedTextField;
-    assert.equal(fontSizeInput.value, `${diffPreferences.font_size}`);
+      await element.updateComplete;
+    });
 
-    const showTabsInput = valueOf('Show tabs', 'diffPreferences')
-      .firstElementChild as HTMLInputElement;
-    assert.equal(showTabsInput.checked, diffPreferences.show_tabs);
-
-    const showWhitespaceErrorsInput = valueOf(
-      'Show trailing whitespace',
-      'diffPreferences'
-    ).firstElementChild as HTMLInputElement;
-    assert.equal(
-      showWhitespaceErrorsInput.checked,
-      diffPreferences.show_whitespace_errors
-    );
-
-    const syntaxHighlightingInput = valueOf(
-      'Syntax highlighting',
-      'diffPreferences'
-    ).firstElementChild as HTMLInputElement;
-    assert.equal(
-      syntaxHighlightingInput.checked,
-      diffPreferences.syntax_highlighting
-    );
-
-    const manualReviewInput = valueOf(
-      'Automatically mark viewed files reviewed',
-      'diffPreferences'
-    ).firstElementChild as HTMLInputElement;
-    assert.equal(manualReviewInput.checked, !diffPreferences.manual_review);
-
-    const ignoreWhitespaceInput = valueOf(
-      'Ignore Whitespace',
-      'diffPreferences'
-    ).firstElementChild as MdOutlinedSelect;
-    assert.equal(
-      ignoreWhitespaceInput.value,
-      diffPreferences.ignore_whitespace
-    );
-
-    assert.isFalse(element.hasUnsavedChanges());
-  });
-
-  test('save changes', async () => {
-    assert.isTrue(element.diffPrefs!.show_whitespace_errors);
-
-    const showTrailingWhitespaceCheckbox = valueOf(
-      'Show trailing whitespace',
-      'diffPreferences'
-    ).firstElementChild as HTMLInputElement;
-    showTrailingWhitespaceCheckbox.checked = false;
-    element.handleShowTrailingWhitespaceTap();
-
-    assert.isTrue(element.hasUnsavedChanges());
-
-    const savePrefStub = stubRestApi('saveDiffPreferences').resolves(
-      new Response(makePrefixedJSON(element.diffPrefs))
-    );
-
-    await element.save();
-    // Wait for model state update, since this is not awaited by element.save()
-    await waitUntil(
-      () =>
-        !element.getUserModel().getState().diffPreferences
-          ?.show_whitespace_errors
-    );
-
-    assert.isTrue(savePrefStub.called);
-    assert.isFalse(element.diffPrefs!.show_whitespace_errors);
-    assert.isFalse(element.hasUnsavedChanges());
+    test('renders legacy checkbox', () => {
+      const sections = queryAll(element, '#diffPreferences section') ?? [];
+      let lineWrappingSection: Element | undefined;
+      for (let i = 0; i < sections.length; i++) {
+        if (
+          sections[i].querySelector('.title')?.textContent?.trim() ===
+          'Fit to screen'
+        ) {
+          lineWrappingSection = sections[i];
+          break;
+        }
+      }
+      assert.isDefined(lineWrappingSection);
+      const checkbox = lineWrappingSection.querySelector('md-checkbox');
+      assert.isDefined(checkbox);
+      assert.isTrue(checkbox!.checked);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 9a7cabc..30defc5 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -54,6 +54,9 @@
   @property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
   showKeyboardShortcutTooltips = false;
 
+  @property({type: Boolean, attribute: 'disable-auto-select', reflect: true})
+  disableAutoSelect = false;
+
   // Private but used in tests.
   readonly getUserModel = resolve(this, userModelToken);
 
@@ -171,6 +174,7 @@
         .label=${command.title}
         .command=${command.command}
         .tooltip=${this.computeTooltip(index)}
+        .disableAutoSelect=${this.disableAutoSelect}
       ></gr-shell-command>
     `;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 05011f8f..3f3b4bc 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -97,6 +97,16 @@
       assert.isTrue(focusStub.called);
     });
 
+    test('passes disableAutoSelect to gr-shell-command', async () => {
+      element.disableAutoSelect = true;
+      await element.updateComplete;
+      const shellCommand = queryAndAssert<GrShellCommand>(
+        element,
+        'gr-shell-command'
+      );
+      assert.isTrue(shellCommand.disableAutoSelect);
+    });
+
     test('element visibility', async () => {
       assert.isFalse(isHidden(queryAndAssert(element, 'md-tabs')));
       assert.isFalse(isHidden(queryAndAssert(element, '.commands')));
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index c913c8c..352c8fd 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -96,7 +96,7 @@
   @state()
   selectedIndex = 0;
 
-  @state()
+  @property({type: Boolean})
   private opened = false;
 
   @state() private hadKeyboardEvent = false;
@@ -335,6 +335,8 @@
           ?hidden=${!this.showCopyForTriggerText}
           hideInput
           .text=${this.text}
+          buttonTitle="Copy to clipboard"
+          copyTargetName="Text"
         ></gr-copy-clipboard>
       </gr-button>
       <div class="dropdown-menu">
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index 5fb5ffc..a67461f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -68,7 +68,13 @@
           >
             <span id="triggerText" class="desktopText"> Button Text 2 </span>
             <span id="triggerText" class="mobileText"> Button Text 2 </span>
-            <gr-copy-clipboard class="copyClipboard" hidden="" hideinput="">
+            <gr-copy-clipboard
+              buttontitle="Copy to clipboard"
+              class="copyClipboard"
+              copytargetname="Text"
+              hidden=""
+              hideinput=""
+            >
             </gr-copy-clipboard>
           </gr-button>
           <div class="dropdown-menu">
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 65efb57..140f0ff 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -65,6 +65,11 @@
           text-decoration: none;
           width: 100%;
         }
+        /* Allow the dropdown to be positioned correctly near side panels. */
+        .dropdown-menu {
+          position: var(--gr-dropdown-position, static);
+          z-index: var(--gr-dropdown-z-index, auto);
+        }
         .dropdown-content {
           min-width: 112px;
           max-width: 280px;
@@ -167,7 +172,7 @@
   @property({type: Number, attribute: 'vertical-offset'})
   verticalOffset = 0;
 
-  @state()
+  @property({type: Boolean})
   private opened = false;
 
   /**
@@ -268,32 +273,34 @@
       >
         <slot></slot>
       </gr-button>
-      <md-menu
-        default-focus="none"
-        id="dropdown"
-        anchor="trigger"
-        tabindex="-1"
-        .menuCorner=${this.horizontalAlign === 'left'
-          ? 'start-start'
-          : this.horizontalAlign === 'center'
-          ? 'start-end'
-          : 'end-start'}
-        .yOffset=${this.verticalOffset}
-        ?quick=${true}
-        .skipRestoreFocus=${true}
-        @opened=${() => {
-          this.opened = true;
-        }}
-        @closing=${this.handleMenuClosing}
-        @closed=${() => {
-          this.opened = false;
-          this.hadKeyboardEvent = false;
-          // This is an ugly hack but works.
-          this.cursor.target?.removeAttribute('selected');
-        }}
-      >
-        ${this.renderDropdownContent()}
-      </md-menu>
+      <div class="dropdown-menu">
+        <md-menu
+          default-focus="none"
+          id="dropdown"
+          anchor="trigger"
+          tabindex="-1"
+          .menuCorner=${this.horizontalAlign === 'left'
+            ? 'start-start'
+            : this.horizontalAlign === 'center'
+            ? 'start-end'
+            : 'end-start'}
+          .yOffset=${this.verticalOffset}
+          ?quick=${true}
+          .skipRestoreFocus=${true}
+          @opened=${() => {
+            this.opened = true;
+          }}
+          @closing=${this.handleMenuClosing}
+          @closed=${() => {
+            this.opened = false;
+            this.hadKeyboardEvent = false;
+            // This is an ugly hack but works.
+            this.cursor.target?.removeAttribute('selected');
+          }}
+        >
+          ${this.renderDropdownContent()}
+        </md-menu>
+      </div>
     </div>`;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index 3230e48..16757a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -40,41 +40,58 @@
         >
           <slot> </slot>
         </gr-button>
-        <md-menu
-          anchor="trigger"
-          aria-hidden="true"
-          default-focus="none"
-          id="dropdown"
-          quick=""
-          tabindex="-1"
-        >
-          <div class="dropdown-content">
-            <gr-tooltip-content has-tooltip="" title="hello">
-              <span class="itemAction" data-id="foo" tabindex="-1">
-                <md-menu-item
-                  active=""
-                  data-index="0"
-                  md-menu-item=""
-                  selected=""
-                >
-                  item one
-                </md-menu-item>
-              </span>
-            </gr-tooltip-content>
-            <md-divider role="separator" tabindex="-1"> </md-divider>
-            <gr-tooltip-content>
-              <a class="itemAction" href="http://bar" tabindex="-1">
-                <md-menu-item data-index="1" md-menu-item="">
-                  item two
-                </md-menu-item>
-              </a>
-            </gr-tooltip-content>
-          </div>
-        </md-menu>
+        <div class="dropdown-menu">
+          <md-menu
+            anchor="trigger"
+            aria-hidden="true"
+            default-focus="none"
+            id="dropdown"
+            quick=""
+            tabindex="-1"
+          >
+            <div class="dropdown-content">
+              <gr-tooltip-content has-tooltip="" title="hello">
+                <span class="itemAction" data-id="foo" tabindex="-1">
+                  <md-menu-item
+                    active=""
+                    data-index="0"
+                    md-menu-item=""
+                    selected=""
+                  >
+                    item one
+                  </md-menu-item>
+                </span>
+              </gr-tooltip-content>
+              <md-divider role="separator" tabindex="-1"> </md-divider>
+              <gr-tooltip-content>
+                <a class="itemAction" href="http://bar" tabindex="-1">
+                  <md-menu-item data-index="1" md-menu-item="">
+                    item two
+                  </md-menu-item>
+                </a>
+              </gr-tooltip-content>
+            </div>
+          </md-menu>
+        </div>
       </div>`
     );
   });
 
+  test('css variables control positioning', async () => {
+    const dropdownMenu = element.shadowRoot?.querySelector('.dropdown-menu');
+    assert.isOk(dropdownMenu);
+    let styles = window.getComputedStyle(dropdownMenu);
+    assert.equal(styles.position, 'static');
+    assert.equal(styles.zIndex, 'auto');
+
+    element.style.setProperty('--gr-dropdown-position', 'relative');
+    element.style.setProperty('--gr-dropdown-z-index', '120');
+
+    styles = window.getComputedStyle(dropdownMenu);
+    assert.equal(styles.position, 'relative');
+    assert.equal(styles.zIndex, '120');
+  });
+
   test('tap on trigger opens menu, then closes', () => {
     sinon.stub(element, 'dropdownTriggerTapHandler').callsFake(() => {
       assertIsDefined(element.dropdown);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 4b88159..6a6118c 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -109,7 +109,7 @@
   /** If false, then the "Show more" button was used to expand. */
   @state() commitCollapsed = true;
 
-  @state() newContent = '';
+  @property({type: String}) newContent = '';
 
   @state()
   emails: EmailInfo[] = [];
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 6c2661f..e331248 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -170,7 +170,7 @@
 
   override render() {
     this.setAttribute('title', this.computeLabel());
-    return html`<div style="position: relative;">
+    return html`<div style="position: relative;" part="container">
       ${this.renderActivateButton()}
       <md-menu
         id="dropdown"
@@ -337,7 +337,6 @@
       this.value = this.inputText || '';
     }
     this.editing = false;
-    // TODO: This event seems to be unused (no listener). Remove?
     fire(this, 'changed', this.value);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index 3108166..f6e08f2 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -45,7 +45,7 @@
   test('renders', () => {
     assert.shadowDom.equal(
       element,
-      `<div style="position: relative;">
+      `<div style="position: relative;" part="container">
         <label
           aria-label="value text"
           class="editable"
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_screenshot_test.ts
new file mode 100644
index 0000000..ff40c2b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_screenshot_test.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-fix-suggestions';
+import {fixture, html} from '@open-wc/testing';
+// Until https://github.com/modernweb-dev/web/issues/2804 is fixed
+// @ts-ignore
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {GrFixSuggestions} from './gr-fix-suggestions';
+import {
+  createComment,
+  createFixSuggestionInfo,
+} from '../../../test/test-data-generators';
+import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api';
+import {stubFlags, visualDiffDarkTheme} from '../../../test/test-utils';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {testResolver} from '../../../test/common-test-setup';
+import * as sinon from 'sinon';
+import {highlightedStringToRanges} from '../../../utils/syntax-util';
+import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
+import {PatchSetNumber} from '../../../types/common';
+
+suite('gr-fix-suggestions screenshot tests', () => {
+  let element: GrFixSuggestions;
+
+  setup(async () => {
+    stubFlags('isEnabled').returns(true);
+    const highlightService = testResolver(highlightServiceToken);
+    const leftRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+      '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+        '  <span class="keyword">private</span> <span class="title function_">oldMethod</span>() {\n' +
+        '    <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"old"</span>);\n' +
+        '  }\n' +
+        '}'
+    );
+    const rightRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+      '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+        '  <span class="keyword">private</span> <span class="title function_">newMethod</span>() {\n' +
+        '    <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"new"</span>);\n' +
+        '  }\n' +
+        '}'
+    );
+    sinon.stub(highlightService, 'highlight').callsFake(async (_lang, code) => {
+      if (code?.includes('oldMethod')) return leftRanges;
+      if (code?.includes('newMethod')) return rightRanges;
+      return [];
+    });
+
+    element = await fixture<GrFixSuggestions>(
+      html`<gr-fix-suggestions
+        .generated_fix_suggestions=${[createFixSuggestionInfo()]}
+        .comment=${{
+          ...createComment(),
+          id: '1',
+          patch_set: 1 as PatchSetNumber,
+        }}
+      ></gr-fix-suggestions>`
+    );
+    await element.updateComplete;
+  });
+
+  test('ai fix suggestion with syntax highlighting', async () => {
+    // mock preview because it's calculated on backend
+    element.suggestionDiffPreview!.previewLoadedFor = {
+      fixSuggestionInfo: createFixSuggestionInfo(),
+      changeNum: 42 as NumericChangeId,
+      patchSet: 1 as RevisionPatchSetNum,
+    };
+    element.suggestionDiffPreview!.preview = {
+      filepath: 'test.ts',
+      preview: {
+        meta_a: {
+          name: 'test.ts',
+          content_type: 'application/typescript',
+          lines: 6,
+        },
+        meta_b: {
+          name: 'test.ts',
+          content_type: 'application/typescript',
+          lines: 6,
+        },
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [
+          {
+            ab: ['export class Test {'],
+          },
+          {
+            a: ['  private oldMethod() {', '    console.log("old");', '  }'],
+            b: ['  private newMethod() {', '    console.log("new");', '  }'],
+            edit_a: [
+              [24, 2],
+              [23, 2],
+              [27, 2],
+            ],
+            edit_b: [],
+          },
+          {
+            ab: ['}'],
+          },
+        ],
+      },
+    };
+    element.requestUpdate();
+    await element.updateComplete;
+    await element.suggestionDiffPreview!.updateComplete;
+    // Allow syntax worker promise and notify to apply annotations
+    await new Promise(r => setTimeout(r, 100));
+    await document.fonts?.ready;
+
+    await visualDiff(element, 'gr-fix-suggestions');
+    await visualDiffDarkTheme(element, 'gr-fix-suggestions');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 95b6851..acc0d38 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -119,7 +119,7 @@
           white-space: normal;
           /* prose will automatically wrap but inline <code> blocks won't and we
            should overflow in that case rather than wrapping or leaking out */
-          overflow-x: auto;
+          overflow-x: var(--gr-formatted-text-markdown-html-overflow-x, auto);
           overflow-wrap: break-word;
         }
       `,
@@ -255,13 +255,12 @@
         ) {
           href = `https://${href}`;
         }
+        const target = sameOrigin(href)
+          ? ''
+          : ' target="_blank" rel="noopener noreferrer"';
+        const titleAttr = title ? ` title="${title}"` : '';
         /* HTML */
-        return `<a
-          href="${href}"
-          ${sameOrigin(href) ? '' : 'target="_blank" rel="noopener noreferrer"'}
-          ${title ? `title="${title}"` : ''}
-          >${text}</a
-        >`;
+        return `<a href="${href}"${target}${titleAttr}>${text}</a>`;
       };
 
       renderer.image = function (
@@ -322,11 +321,10 @@
         if (token.type === 'text' && token.tokens) {
           return this.parser.parseInline(token.tokens);
         }
-        return boundRewriteText(
-          token.type === 'text' && token.escaped
-            ? token.text
-            : htmlEscape(token.text).toString()
-        );
+        if (token.type === 'text' && token.escaped) {
+          return token.text;
+        }
+        return boundRewriteText(htmlEscape(token.text).toString());
       };
     }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index be5c2ee..7c260be 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+
 import * as sinon from 'sinon';
 import '../../../test/common-test-setup';
 import {assert, fixture, html} from '@open-wc/testing';
@@ -63,7 +64,7 @@
         suffix: '$3',
       },
     });
-    self.CANONICAL_PATH = 'http://localhost';
+    self.CANONICAL_PATH = '';
     element = (
       await fixture(
         wrapInProvider(
@@ -128,7 +129,7 @@
         /* HTML */ `
           <gr-endpoint-decorator name="formatted-text-endpoint">
             <pre class="plaintext">
-          FOO<a href="a.b.c" rel="noopener noreferrer" target="_blank">foo</a>
+          FOO<a href="a.b.c">foo</a>
         </pre>
           </gr-endpoint-decorator>
         `
@@ -159,10 +160,10 @@
           <gr-endpoint-decorator name="formatted-text-endpoint">
             <pre class="plaintext">
             Start:
-            <a href="bug/123" rel="noopener noreferrer" target="_blank">
+            <a href="bug/123">
               bug/123
             </a>
-            <a href="bug/456" rel="noopener noreferrer" target="_blank">
+            <a href="bug/456">
               bug/456
             </a>
           </pre>
@@ -200,13 +201,7 @@
               LinkRewriteMe
             </a>
             text with complex link: A
-            <a
-              href="http://localhost/page?id=12"
-              rel="noopener noreferrer"
-              target="_blank"
-            >
-              Link 12
-            </a>
+            <a href="/page?id=12">Link 12</a>
           </pre>
           </gr-endpoint-decorator>
         `
@@ -372,13 +367,7 @@
                 <p>text without a link: NotA Link 15 cats</p>
                 <p>
                   text with complex link: A
-                  <a
-                    href="http://localhost/page?id=12"
-                    rel="noopener noreferrer"
-                    target="_blank"
-                  >
-                    Link 12
-                  </a>
+                  <a href="/page?id=12">Link 12</a>
                 </p>
               </div>
             </gr-marked-element>
@@ -420,13 +409,7 @@
           </a>
         text without a link: NotA Link 15 cats
         text with complex link: A
-          <a
-            href="http://localhost/page?id=12"
-            rel="noopener noreferrer"
-            target="_blank"
-          >
-            Link 12
-          </a>
+          <a href="/page?id=12">Link 12</a>
         </pre>
           </gr-endpoint-decorator>
         `
@@ -953,6 +936,128 @@
       await checkLinking('google.com.blah/path', false);
     });
 
+    test('reproduce b/519426997: linkification in loose list', async () => {
+      element.content = '1. A Link 1234\n\n2. aaa';
+      await element.updateComplete;
+      await new Promise<void>(resolve => {
+        const listener = () => {
+          element.removeEventListener('marked-render-complete', listener);
+          resolve();
+        };
+        element.addEventListener('marked-render-complete', listener);
+        setTimeout(() => {
+          element.removeEventListener('marked-render-complete', listener);
+          resolve();
+        }, 100);
+      });
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <gr-marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <ol>
+                  <li>
+                    <p>
+                      A
+                      <a href="/page?id=1234">Link 1234</a>
+                    </p>
+                  </li>
+                  <li>
+                    <p>aaa</p>
+                  </li>
+                </ol>
+              </div>
+            </gr-marked-element>
+          </gr-endpoint-decorator>
+        `
+      );
+    });
+
+    test('renders loose list of links without leaking html attributes', async () => {
+      element.content = `
+* [First Link](https://example.com/first)
+
+* [Second Link](https://example.com/second)
+      `;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <gr-marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <ul>
+                  <li>
+                    <p>
+                      <a
+                        href="https://example.com/first"
+                        rel="noopener noreferrer"
+                        target="_blank"
+                        >First Link</a
+                      >
+                    </p>
+                  </li>
+                  <li>
+                    <p>
+                      <a
+                        href="https://example.com/second"
+                        rel="noopener noreferrer"
+                        target="_blank"
+                        >Second Link</a
+                      >
+                    </p>
+                  </li>
+                </ul>
+              </div>
+            </gr-marked-element>
+          </gr-endpoint-decorator>
+        `
+      );
+    });
+
+    test('renders nested loose list with links without leaking html attributes', async () => {
+      element.content = `
+* item 1
+  * [Nested Link](https://example.com/nested)
+
+* item 2
+      `;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="formatted-text-endpoint">
+            <gr-marked-element>
+              <div slot="markdown-html" class="markdown-html">
+                <ul>
+                  <li>
+                    <p>item 1</p>
+                    <ul>
+                      <li>
+                        <a
+                          href="https://example.com/nested"
+                          rel="noopener noreferrer"
+                          target="_blank"
+                          >Nested Link</a
+                        >
+                      </li>
+                    </ul>
+                  </li>
+                  <li>
+                    <p>item 2</p>
+                  </li>
+                </ul>
+              </div>
+            </gr-marked-element>
+          </gr-endpoint-decorator>
+        `
+      );
+    });
+
     suite('user suggest fix', () => {
       setup(async () => {
         const flagsService = getAppContext().flagsService;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
index b185ca2..9b21725 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -5,6 +5,7 @@
  */
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
+import '../gr-copy-clipboard/gr-copy-clipboard';
 import '../gr-icon/gr-icon';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
@@ -137,6 +138,12 @@
         .email {
           color: var(--deemphasized-text-color);
         }
+        .email {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-xs);
+          color: var(--deemphasized-text-color);
+        }
         .action {
           border-top: 1px solid var(--border-color);
           padding: var(--spacing-s) var(--spacing-l);
@@ -189,7 +196,18 @@
         </div>
         <div class="account">
           <h3 class="name heading-3">${this.account.name}</h3>
-          <div class="email">${this.account.email}</div>
+          <div class="email">
+            <span>${this.account.email}</span>
+            ${this.account.email
+              ? html`<gr-copy-clipboard
+                  hideInput
+                  .text=${this.account.email}
+                  copyTargetName="Email"
+                  hasTooltip
+                  buttonTitle="Copy email to clipboard"
+                ></gr-copy-clipboard>`
+              : nothing}
+          </div>
         </div>
       </div>
       ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
index 6f3fe36..925ec17 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -68,7 +68,16 @@
           </div>
           <div class="account">
             <h3 class="heading-3 name">Kermit The Frog</h3>
-            <div class="email">kermit@gmail.com</div>
+            <div class="email">
+              <span>kermit@gmail.com</span>
+              <gr-copy-clipboard
+                buttontitle="Copy email to clipboard"
+                copytargetname="Email"
+                hastooltip=""
+                hideinput=""
+              >
+              </gr-copy-clipboard>
+            </div>
           </div>
         </div>
         <gr-endpoint-decorator name="hovercard-status">
@@ -110,7 +119,16 @@
           </div>
           <div class="account">
             <h3 class="heading-3 name">Kermit The Frog</h3>
-            <div class="email">kermit@gmail.com</div>
+            <div class="email">
+              <span>kermit@gmail.com</span>
+              <gr-copy-clipboard
+                buttontitle="Copy email to clipboard"
+                copytargetname="Email"
+                hastooltip=""
+                hideinput=""
+              >
+              </gr-copy-clipboard>
+            </div>
           </div>
         </div>
         <gr-endpoint-decorator name="hovercard-status">
@@ -213,6 +231,17 @@
     assert.equal(voteableEl.innerText, 'Bar: +1');
   });
 
+  test('copy email clipboard is shown when email exists', () => {
+    const copyClipboard = queryAndAssert(element, 'gr-copy-clipboard');
+    assert.isOk(copyClipboard);
+  });
+
+  test('copy email clipboard is not shown without email', async () => {
+    element.account = {...ACCOUNT, email: undefined};
+    await element.updateComplete;
+    assert.isUndefined(query(element, 'gr-copy-clipboard'));
+  });
+
   test('remove reviewer', async () => {
     element.change = {
       ...createChange(),
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 0250d82..5abf4fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -6,6 +6,7 @@
 import {
   AnnotationPluginApi,
   CoverageProvider,
+  DiffLayerFactory,
   TokenHoverListener,
 } from '../../../api/annotation';
 import {PluginApi} from '../../../api/plugin';
@@ -36,4 +37,12 @@
       listener,
     });
   }
+
+  addDiffLayer(factory: DiffLayerFactory): void {
+    this.reporting.trackApi(this.plugin, 'annotation', 'addDiffLayer');
+    this.pluginsModel.diffLayerRegister({
+      pluginName: this.plugin.getPluginName(),
+      factory,
+    });
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 70952da..5074405 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -30,6 +30,7 @@
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {Finalizable} from '../../../types/types';
 import {materialStyles} from '../../../styles/gr-material-styles';
+import {changeViewIntegrationStyles} from '../../../styles/gr-change-view-integration-shared-styles';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -90,6 +91,7 @@
     subPage: subpageStyles,
     table: tableStyles,
     modal: modalStyles,
+    changeViewIntegration: changeViewIntegrationStyles,
   };
 
   private pluginListLoaded = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/resemble-types.d.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemble-types.d.ts
new file mode 100644
index 0000000..ce03092
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemble-types.d.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Resemble from 'resemblejs';
+
+// @types/resemblejs does not expose a global variable resemble and instead
+// exposes the namespace Resemble. Because Resemble.js should remain an
+// optional dependency, we define a global variable in a separate .d.ts file;
+// otherwise, the TS compiler tries to import the JS library too and fails.
+declare global {
+  interface Window {
+    resemble?: typeof Resemble;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 7de9701..145be54 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -18,6 +18,8 @@
   FetchRequest as FetchRequestBase,
 } from '../../../../types/types';
 import {ErrorCallback} from '../../../../api/rest';
+import {getAppContext} from '../../../../services/app-context';
+import {Timer} from '../../../../services/gr-reporting/gr-reporting';
 import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
 import {RetryError} from '../../../../services/scheduler/retry-scheduler';
 
@@ -214,6 +216,12 @@
   // TODO(kamilm): Consider changing the default to true. It makes more sense to
   //   only skip the check if the caller wants to prosess status themselves.
   reportServerError?: boolean;
+  isHighPriority?: boolean;
+  /**
+   * If true, the request will be routed through the readScheduler even if it
+   * uses a write method (PUT, POST, DELETE).
+   */
+  useReadScheduler?: boolean;
 }
 
 export interface FetchOptionsInit {
@@ -261,11 +269,23 @@
     private readonly writeScheduler: Scheduler<Response>
   ) {}
 
-  private schedule(method: string, task: Task<Response>): Promise<Response> {
-    if (method === 'PUT' || method === 'POST' || method === 'DELETE') {
-      return this.writeScheduler.schedule(task);
+  private isWrite(method: string, useReadScheduler?: boolean): boolean {
+    return (
+      (method === 'PUT' || method === 'POST' || method === 'DELETE') &&
+      !useReadScheduler
+    );
+  }
+
+  private schedule(
+    method: string,
+    task: Task<Response>,
+    name?: string,
+    useReadScheduler?: boolean
+  ): Promise<Response> {
+    if (this.isWrite(method, useReadScheduler)) {
+      return this.writeScheduler.schedule(task, name);
     } else {
-      return this.readScheduler.schedule(task);
+      return this.readScheduler.schedule(task, name);
     }
   }
 
@@ -276,7 +296,44 @@
   private fetchImpl(req: FetchRequest): Promise<Response> {
     const method = req.fetchOptions?.method ?? HttpMethod.GET;
     const startTime = Date.now();
+
+    const isWrite = this.isWrite(method, req.useReadScheduler);
+    const origin = req.fetchOptions?.headers?.get(REQUEST_ORIGIN_HEADER);
+    const pluginName = origin?.startsWith('plugin:') ? origin : undefined;
+    const requestName = `${method} - ${
+      req.anonymizedUrl || pluginName || 'unknown'
+    }`;
+    const debugRequestName = `${method} - ${req.anonymizedUrl || req.url}`;
+    let schedulerTimer: Timer | undefined;
+    let activeCount = 0;
+
+    if (isWrite && !req.isHighPriority) {
+      activeCount = this.writeScheduler.activeCount;
+      console.info(
+        '[SchedulerWait] request:',
+        debugRequestName,
+        'count:',
+        activeCount,
+        'active:',
+        this.writeScheduler.activeRequests
+      );
+      // 5 matches the max in flight limit of the write scheduler.
+      if (activeCount >= 5) {
+        schedulerTimer = getAppContext().reportingService.getTimer(
+          'scheduler-waiting-time'
+        );
+      }
+    }
+
     const task = async () => {
+      if (schedulerTimer) {
+        schedulerTimer.end({
+          count: activeCount,
+          request: requestName,
+          activeRequests: this.writeScheduler.activeRequests,
+        });
+        schedulerTimer = undefined;
+      }
       const res = await this._auth.fetch(req.url, req.fetchOptions);
       // Check for "too many requests" error and throw RetryError to cause a
       // retry in this case, if the scheduler attempts retries.
@@ -284,7 +341,11 @@
       return res;
     };
 
-    const resPromise = this.schedule(method, task).catch((err: unknown) => {
+    const resPromise = (
+      req.isHighPriority
+        ? task()
+        : this.schedule(method, task, requestName, req.useReadScheduler)
+    ).catch((err: unknown) => {
       if (err instanceof RetryError) {
         return err.payload;
       } else {
@@ -367,6 +428,9 @@
       fetchOptions: req.fetchOptions,
       anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
     };
+    if (req.useReadScheduler !== undefined) {
+      fetchReq.useReadScheduler = req.useReadScheduler;
+    }
 
     let resp: Response;
     try {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index ec49dd7..ea79611 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -130,6 +130,33 @@
       });
       assert.equal(readScheduler.scheduled.length, 0);
       await assertWriteRequest();
+      const res = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+
+    test('POST are sent to writeScheduler', async () => {
+      const promise = helper.fetch({
+        fetchOptions: {
+          method: HttpMethod.POST,
+        },
+        url: '/dummy/url',
+      });
+      assert.equal(readScheduler.scheduled.length, 0);
+      await assertWriteRequest();
+      const res = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+
+    test('POST with useReadScheduler are sent to readScheduler', async () => {
+      const promise = helper.fetch({
+        fetchOptions: {
+          method: HttpMethod.POST,
+        },
+        url: '/changes/123/revisions/1/fix:preview',
+        useReadScheduler: true,
+      });
+      assert.equal(writeScheduler.scheduled.length, 0);
+      await assertReadRequest();
       const res: Response = await promise;
       assert.equal(await res.text(), 'Yay');
     });
@@ -157,6 +184,21 @@
         response.status,
       ]);
     });
+
+    test('fetch falls back to requestOrigin for scheduler reporting', async () => {
+      const scheduleStub = sinon
+        .stub(readScheduler, 'schedule')
+        .resolves(new Response());
+
+      await helper.fetch({
+        fetchOptions: getFetchOptions({requestOrigin: 'plugin:my-plugin'}),
+        url: '/dummy/url',
+      });
+
+      assert.isTrue(scheduleStub.calledOnce);
+      const name = scheduleStub.lastCall.args[1];
+      assert.equal(name, 'GET - plugin:my-plugin');
+    });
   });
 
   suite('fetchJSON()', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
deleted file mode 100644
index c64e312..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html, LitElement, PropertyValues} from 'lit';
-import {customElement} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
-import {fire} from '../../../utils/event-util';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-select': GrSelect;
-  }
-  interface HTMLElementEventMap {
-    'bind-value-changed': BindValueChangeEvent;
-  }
-}
-
-/**
- * GrSelect `gr-select` component.
- * TODO: Figure out if this class still has merit over native <select>
- */
-@customElement('gr-select')
-export class GrSelect extends LitElement {
-  private _bindValue?: string | number | boolean;
-
-  get bindValue() {
-    return this._bindValue;
-  }
-
-  set bindValue(bindValue: string | number | boolean | undefined) {
-    if (this._bindValue === bindValue) return;
-    this._bindValue = bindValue;
-    this._updateValue();
-    // It's possible to have a value of 0.
-    if (this.bindValue !== undefined) {
-      // Set for chrome/safari so it happens instantly
-      this.nativeSelect.value = String(this.bindValue);
-      // Async needed for firefox to populate value. It was trying to do it
-      // before options from a dom-repeat were rendered previously.
-      // See https://issues.gerritcodereview.com/issues/40007948
-      setTimeout(() => {
-        this.nativeSelect.value = String(this.bindValue);
-      }, 1);
-    }
-    fire(this, 'bind-value-changed', {value: this.convert(this._bindValue)});
-  }
-
-  get nativeSelect() {
-    return this.querySelector('select')!;
-  }
-
-  constructor() {
-    super();
-    this.addEventListener('change', () => {
-      this.bindValue = this.nativeSelect.value;
-    });
-  }
-
-  override updated(changedProperties: PropertyValues) {
-    super.updated(changedProperties);
-    // If not set via the property, set bind-value to the element value.
-    if (this.bindValue === undefined && this.nativeSelect.options.length > 0) {
-      this.bindValue = this.nativeSelect.value;
-    }
-  }
-
-  override render() {
-    return html`<slot></slot>`;
-  }
-
-  _updateValue() {
-    // It's possible to have a value of 0.
-    if (this.bindValue !== undefined) {
-      // Set for chrome/safari so it happens instantly
-      this.nativeSelect.value = this.convert(this.bindValue) ?? '';
-      // Async needed for firefox to populate value. It was trying to do it
-      // before options from a dom-repeat were rendered previously.
-      // See https://issues.gerritcodereview.com/issues/40007948
-      setTimeout(() => {
-        this.nativeSelect.value = this.convert(this.bindValue) ?? '';
-      }, 1);
-    }
-  }
-
-  private convert(value: string | boolean | number | undefined) {
-    if (value === undefined) return undefined;
-    if (typeof value === 'string') return value;
-    return String(value);
-  }
-
-  override focus() {
-    this.nativeSelect.focus();
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
deleted file mode 100644
index 763e292..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import * as sinon from 'sinon';
-import '../../../test/common-test-setup';
-import './gr-select';
-import {assert, fixture, html} from '@open-wc/testing';
-import {GrSelect} from './gr-select';
-
-suite('gr-select tests', () => {
-  let element: GrSelect;
-
-  setup(async () => {
-    element = await fixture<GrSelect>(html`
-      <gr-select>
-        <select>
-          <option value="1">One</option>
-          <option value="2">Two</option>
-          <option value="3">Three</option>
-        </select>
-      </gr-select>
-    `);
-  });
-
-  test('render', () => {
-    assert.shadowDom.equal(element, /* HTML */ '<slot></slot>');
-  });
-
-  test('bindValue must be set to the first option value', () => {
-    assert.equal(element.bindValue, '1');
-    assert.equal(element.nativeSelect.value, '1');
-  });
-
-  test('value of 0 should still trigger value updates', () => {
-    element.bindValue = '0';
-    assert.equal(element.nativeSelect.value, '');
-  });
-
-  test('bidirectional binding property-to-attribute', () => {
-    const changeStub = sinon.stub();
-    element.addEventListener('bind-value-changed', changeStub);
-
-    // The selected element should be the first one by default.
-    assert.equal(element.nativeSelect.value, '1');
-    assert.equal(element.bindValue, '1');
-    assert.isFalse(changeStub.called);
-
-    // Now change the value.
-    element.bindValue = '2';
-
-    // It should be updated.
-    assert.equal(element.nativeSelect.value, '2');
-    assert.equal(element.bindValue, '2');
-    assert.isTrue(changeStub.called);
-  });
-
-  test('bidirectional binding attribute-to-property', () => {
-    const changeStub = sinon.stub();
-    element.addEventListener('bind-value-changed', changeStub);
-
-    // The selected element should be the first one by default.
-    assert.equal(element.nativeSelect.value, '1');
-    assert.equal(element.bindValue, '1');
-    assert.isFalse(changeStub.called);
-
-    // Now change the value.
-    element.nativeSelect.value = '3';
-    element.dispatchEvent(
-      new CustomEvent('change', {
-        composed: true,
-        bubbles: true,
-      })
-    );
-
-    // It should be updated.
-    assert.equal(element.nativeSelect.value, '3');
-    assert.equal(element.bindValue, '3');
-    assert.isTrue(changeStub.called);
-  });
-
-  suite('gr-select no options tests', () => {
-    let element: GrSelect;
-
-    setup(async () => {
-      element = await fixture<GrSelect>(html`
-        <gr-select>
-          <select></select>
-        </gr-select>
-      `);
-    });
-
-    test('bindValue must not be changed', () => {
-      assert.isUndefined(element.bindValue);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 0c9be74..54aecb3 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -27,6 +27,9 @@
   @property({type: String})
   tooltip = '';
 
+  @property({type: Boolean, attribute: 'disable-auto-select', reflect: true})
+  disableAutoSelect = false;
+
   static override get styles() {
     return [
       sharedStyles,
@@ -73,6 +76,7 @@
           .text=${this.command}
           hasTooltip
           buttonTitle=${this.tooltip}
+          .disableAutoSelect=${this.disableAutoSelect}
         ></gr-copy-clipboard>
       </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index 1e0ff11..8fb66f5 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -44,4 +44,14 @@
     await element.focusOnCopy();
     assert.isTrue(focusStub.called);
   });
+
+  test('passes disableAutoSelect to gr-copy-clipboard', async () => {
+    element.disableAutoSelect = true;
+    await element.updateComplete;
+    const copyClipboard = queryAndAssert<GrCopyClipboard>(
+      element,
+      'gr-copy-clipboard'
+    );
+    assert.isTrue(copyClipboard.disableAutoSelect);
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 72e0905..3f46a76 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -30,11 +30,16 @@
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {fire, fireError} from '../../../utils/event-util';
 import {Timing} from '../../../constants/reporting';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  createApplyFixUrl,
+} from '../../../models/views/change';
+
 import {getFileExtension} from '../../../utils/file-util';
 import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {ReportSource} from '../../../services/suggestions/suggestions-service';
 import {replacementsToString} from '../../../utils/comment-util';
+import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {GrTextarea} from '../../../embed/gr-textarea';
 
 export interface PreviewLoadedDetail {
@@ -80,8 +85,17 @@
   @property({type: Boolean, reflect: true})
   editable = false;
 
+  // visible for testing
+  readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
+
+  // visible for testing
+  readonly tokenHighlightLayer = new TokenHighlightLayer(this);
+
   @state()
-  layers: DiffLayer[] = [];
+  layers: DiffLayer[] = [this.syntaxLayer];
 
   /**
    * The fix suggestion info that the preview is loaded for.
@@ -128,15 +142,23 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
-  private readonly syntaxLayer = new GrSyntaxLayerWorker(
-    resolve(this, highlightServiceToken),
-    () => getAppContext().reportingService
-  );
+  private readonly getViewModel = resolve(this, changeViewModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getUserModel().preferences$,
+      preferences => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
+        if (!preferences?.disable_token_highlighting) {
+          layers.push(this.tokenHighlightLayer);
+        }
+        this.layers = layers;
+      }
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().changeNum$,
       changeNum => (this.changeNum = changeNum)
     );
@@ -391,16 +413,23 @@
     // basePatchNum is from comment patchset and comment cannot be created
     // in EDIT. RevisionPatchset without EDIT is PatchSetNumber
     if (res?.ok && basePatchNum !== undefined && basePatchNum !== EDIT) {
+      const currentChildView = this.getViewModel().getState()?.childView;
+      const filePath =
+        fixSuggestion.replacements[0]?.path ?? this.preview?.filepath;
       this.getNavigation().setUrl(
-        createChangeUrl({
+        createApplyFixUrl({
           changeNum,
           repo: this.repo!,
-          patchNum: EDIT,
           basePatchNum: basePatchNum as PatchSetNumber,
           forceReload: !this.hasEdit,
+          filePath,
+          currentChildView,
         })
       );
-      fire(this, 'reload-diff', {path: fixSuggestion.replacements[0].path});
+
+      if (filePath) {
+        fire(this, 'reload-diff', {path: filePath});
+      }
       fire(this, 'apply-user-suggestion', {
         fixSuggestion: fixSuggestion.description.includes(
           ReportSource.GET_AI_FIX_FOR_COMMENT
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
index 742900f..4519063 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -17,8 +17,23 @@
 } from '../../../test/test-data-generators';
 import {getAppContext} from '../../../services/app-context';
 import {GrSuggestionDiffPreview} from './gr-suggestion-diff-preview';
-import {stubFlags} from '../../../test/test-utils';
-import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api';
+import * as sinon from 'sinon';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {stubFlags, stubRestApi} from '../../../test/test-utils';
+import {
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../../api/rest-api';
+import {changeViewModelToken} from '../../../models/views/change';
+import {
+  createChangeViewState,
+  createDiffViewState,
+  createPreferences,
+  createRange,
+} from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
 
 suite('gr-suggestion-diff-preview tests', () => {
   let element: GrSuggestionDiffPreview;
@@ -121,4 +136,74 @@
       {ignoreAttributes: ['style']}
     );
   });
+
+  test('syntax and token highlight layers', async () => {
+    assert.isTrue(element.layers.includes(element.syntaxLayer));
+
+    const userModel = testResolver(userModelToken);
+    userModel.setPreferences({
+      ...createPreferences(),
+      disable_token_highlighting: true,
+    });
+    await element.updateComplete;
+    assert.equal(element.layers.length, 1);
+    assert.isTrue(element.layers.includes(element.syntaxLayer));
+
+    userModel.setPreferences({
+      ...createPreferences(),
+      disable_token_highlighting: false,
+    });
+    await element.updateComplete;
+    assert.equal(element.layers.length, 2);
+    assert.isTrue(element.layers.includes(element.syntaxLayer));
+  });
+
+  suite('applyFix navigation', () => {
+    let setUrlStub: sinon.SinonStub;
+
+    setup(() => {
+      setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve(new Response(null, {status: 200}))
+      );
+      element.changeNum = 42 as NumericChangeId;
+      element.repo = 'test-project' as RepoName;
+      element.patchSet = 1 as RevisionPatchSetNum;
+
+      element.fixSuggestionInfo = {
+        ...createFixSuggestionInfo(),
+        replacements: [
+          {
+            path: 'foo/bar.ts',
+            replacement: 'new content',
+            range: createRange(),
+          },
+        ],
+      };
+    });
+
+    test('navigates to createDiffUrl when in Diff View', async () => {
+      testResolver(changeViewModelToken).setState(createDiffViewState());
+
+      await element.applyFix();
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1..edit/foo/bar.ts?forceReload=true'
+      );
+    });
+
+    test('navigates to createChangeUrl when in Change View', async () => {
+      testResolver(changeViewModelToken).setState(createChangeViewState());
+
+      await element.applyFix();
+
+      assert.isTrue(setUrlStub.calledOnce);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1..edit?forceReload=true'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index ffdfa25..4c594b9 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -118,7 +118,7 @@
 
   @state() suggestions: (Item | EmojiSuggestion)[] = [];
 
-  @state() private isDragging = false;
+  @property({type: Boolean}) private isDragging = false;
 
   @state() private allowMarkdownBase64ImagesInComments = false;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts
index b80b0a9..ea4fb22 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts
@@ -20,12 +20,38 @@
 import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
 import {stubFlags, visualDiffDarkTheme} from '../../../test/test-utils';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {testResolver} from '../../../test/common-test-setup';
+import * as sinon from 'sinon';
+import {highlightedStringToRanges} from '../../../utils/syntax-util';
+import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
 
 suite('gr-user-suggestion-fix screenshot tests', () => {
   let element: GrUserSuggestionsFix;
 
   setup(async () => {
     stubFlags('isEnabled').returns(true);
+    const highlightService = testResolver(highlightServiceToken);
+    const leftRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+      '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+        '  <span class="keyword">private</span> <span class="title function_">oldMethod</span>() {\n' +
+        '    <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"old"</span>);\n' +
+        '  }\n' +
+        '}'
+    );
+    const rightRanges: SyntaxLayerLine[] = highlightedStringToRanges(
+      '<span class="keyword">export</span> <span class="keyword">class</span> <span class="title">Test</span> {\n' +
+        '  <span class="keyword">private</span> <span class="title function_">newMethod</span>() {\n' +
+        '    <span class="variable">console</span>.<span class="title function_">log</span>(<span class="string">"new"</span>);\n' +
+        '  }\n' +
+        '}'
+    );
+    sinon.stub(highlightService, 'highlight').callsFake(async (_lang, code) => {
+      if (code?.includes('oldMethod')) return leftRanges;
+      if (code?.includes('newMethod')) return rightRanges;
+      return [];
+    });
+
     const commentModel = new CommentModel(getAppContext().restApiService);
     commentModel.updateState({
       comment: createComment(),
@@ -88,6 +114,13 @@
         ],
       },
     };
+    element.requestUpdate();
+    await element.updateComplete;
+    await element.suggestionDiffPreview!.updateComplete;
+    // Allow syntax worker promise and notify to apply annotations
+    await new Promise(r => setTimeout(r, 100));
+    await document.fonts?.ready;
+
     await visualDiff(element, 'gr-user-suggestion-fix');
     await visualDiffDarkTheme(element, 'gr-user-suggestion-fix');
   });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 0380d0c..a449d38 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -5,6 +5,7 @@
  */
 import {html, LitElement, nothing, PropertyValues} from 'lit';
 import {property, state} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createRef, Ref, ref} from 'lit/directives/ref.js';
 import {
@@ -38,7 +39,9 @@
 import {isDefined} from '../../../types/types';
 import {BehaviorSubject, combineLatest} from 'rxjs';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
+import '../../../elements/shared/gr-icon/gr-icon';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {distinctUntilChanged, map} from 'rxjs/operators';
 import {deepEqual} from '../../../utils/deep-util';
 import {subscribe} from '../../../elements/lit/subscription-controller';
@@ -88,6 +91,12 @@
   @property({type: Object})
   layers: DiffLayer[] = [];
 
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Boolean})
+  showRevertButton = false;
+
   /**
    * Semantic DOM diff testing does not work with just table fragments, so when
    * running such tests the render() method has to wrap the DOM in a proper
@@ -209,6 +218,12 @@
     // We have to wait for the <gr-diff-text> child component to finish
     // rendering before we can apply layers, which will re-write the HTML.
     await contentEl?.updateComplete;
+    if (
+      this.contentRef(side).value !== contentEl ||
+      this.lineNumberRef(side).value !== lineNumberEl
+    ) {
+      return;
+    }
     for (const layer of this.layers) {
       if (typeof layer.annotate === 'function') {
         layer.annotate(contentEl, lineNumberEl, line, side);
@@ -446,7 +461,7 @@
           if (lineNumber)
             fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
         }}
-      >${this.renderText(side)}${this.renderLostMessage(side)}${this.renderThreadGroup(side)}</td>
+      >${this.renderText(side)}${this.renderLostMessage(side)}${this.renderThreadGroup(side)}${this.renderRevertButton(side)}</td>
     `;
   }
 
@@ -597,6 +612,46 @@
       ? html`<slot name="post-${side}-line-${lineNumber}"></slot>`
       : nothing;
   }
+
+  @state()
+  private isReverting = false;
+
+  private renderRevertButton(side: Side) {
+    if (!this.showRevertButton) return nothing;
+    if (!this.unifiedDiff && side !== Side.LEFT) return nothing;
+    return html`
+      <div class="revert-container">
+        <button
+          class=${classMap({
+            'revert-btn': true,
+            loading: this.isReverting,
+          })}
+          type="button"
+          ?disabled=${this.isReverting}
+          title=${this.isReverting ? 'Reverting...' : 'Revert this change'}
+          aria-label=${this.isReverting ? 'Reverting...' : 'Revert this change'}
+          @click=${this.handleRevertClick}
+        >
+          ${this.isReverting
+            ? html`<span class="loadingSpin"></span>`
+            : html`<gr-icon icon="arrow_forward"></gr-icon>`}
+        </button>
+      </div>
+    `;
+  }
+
+  private handleRevertClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    if (!this.group || this.isReverting) return;
+    this.isReverting = true;
+    fire(this, 'revert-delta', {
+      group: this.group,
+      onComplete: () => {
+        this.isReverting = false;
+      },
+    });
+  }
 }
 
 customElements.define('gr-diff-row', GrDiffRow);
@@ -605,4 +660,10 @@
   interface HTMLElementTagNameMap {
     'gr-diff-row': GrDiffRow;
   }
+  interface HTMLElementEventMap {
+    'revert-delta': CustomEvent<{
+      group: GrDiffGroup;
+      onComplete?: () => void;
+    }>;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
index 7e87d32..23bd6ef 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -4,14 +4,21 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
+import {LitElement} from 'lit';
 import './gr-diff-row';
 import {GrDiffRow} from './gr-diff-row';
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffViewMode, GrDiffLineType, Side} from '../../../api/diff';
 import {diffModelToken} from '../gr-diff-model/gr-diff-model';
 import {testResolver} from '../../../test/common-test-setup';
 
+interface GrDiffRowPrivate {
+  layersApplied: boolean;
+  updateLayers(side: Side): Promise<void>;
+}
+
 suite('gr-diff-row test', () => {
   let element: GrDiffRow;
 
@@ -239,4 +246,106 @@
       `
     );
   });
+
+  test('renders revert button when showRevertButton is true', async () => {
+    const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = new GrDiffLine(GrDiffLineType.BLANK);
+    element.showRevertButton = true;
+    await element.updateComplete;
+
+    const revertBtn = element.querySelector('.revert-btn');
+    assert.isNotNull(revertBtn);
+  });
+
+  test('does not render revert button when showRevertButton is false', async () => {
+    const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = new GrDiffLine(GrDiffLineType.BLANK);
+    element.showRevertButton = false;
+    await element.updateComplete;
+
+    const revertBtn = element.querySelector('.revert-btn');
+    assert.isNull(revertBtn);
+  });
+
+  test('fires revert-delta event on button click', async () => {
+    const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+    line.text = 'lorem ipsum';
+    const group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [line],
+    });
+    element.left = line;
+    element.right = new GrDiffLine(GrDiffLineType.BLANK);
+    element.group = group;
+    element.showRevertButton = true;
+    await element.updateComplete;
+
+    let eventDetail: {group: GrDiffGroup; onComplete?: () => void} | undefined;
+    element.addEventListener('revert-delta', (e: CustomEvent) => {
+      eventDetail = e.detail;
+    });
+
+    const revertBtn = element.querySelector<HTMLButtonElement>('.revert-btn')!;
+    assert.isNotNull(revertBtn);
+    revertBtn.click();
+    await element.updateComplete;
+
+    assert.isDefined(eventDetail);
+    assert.equal(eventDetail?.group, group);
+    assert.isTrue(revertBtn.classList.contains('loading'));
+    assert.isNotNull(revertBtn.querySelector('.loadingSpin'));
+    assert.isNull(revertBtn.querySelector('gr-icon'));
+
+    eventDetail?.onComplete?.();
+    await element.updateComplete;
+    assert.isFalse(revertBtn.classList.contains('loading'));
+    assert.isNull(revertBtn.querySelector('.loadingSpin'));
+    assert.isNotNull(revertBtn.querySelector('gr-icon'));
+  });
+
+  test('updateLayers aborts when DOM element references change during await', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    let annotateCalled = false;
+    element.layers = [
+      {
+        annotate() {
+          annotateCalled = true;
+        },
+      },
+    ];
+    await element.updateComplete;
+    await new Promise(resolve => setTimeout(resolve, 0));
+    annotateCalled = false;
+
+    // Create a mock content element with a controllable updateComplete promise
+    let resolveUpdate: () => void;
+    const updatePromise = new Promise<boolean>(r => {
+      resolveUpdate = () => r(true);
+    });
+    const oldContentEl = {
+      updateComplete: updatePromise,
+    } as unknown as LitElement;
+    element.contentLeftRef = {value: oldContentEl};
+
+    const privElement = element as unknown as GrDiffRowPrivate;
+    privElement.layersApplied = false;
+    const updateLayersPromise = privElement.updateLayers(Side.LEFT);
+
+    // Swap the ref while updateLayers is awaiting updateComplete
+    element.contentLeftRef = {
+      value: document.createElement('div') as unknown as LitElement,
+    };
+    resolveUpdate!();
+    await updateLayersPromise;
+
+    assert.isFalse(annotateCalled);
+    assert.isFalse(privElement.layersApplied);
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index b9a239a..2d7fbed 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -139,10 +139,13 @@
     const hideFileCommentButton =
       this.diffPrefs?.show_file_comment_button === false ||
       this.renderPrefs?.show_file_comment_button === false;
+    const isDelta =
+      this.group.type === GrDiffGroupType.DELTA && !this.group.dueToRebase;
+    const isEditMode = !!this.renderPrefs?.is_edit_mode;
     const body = html`
       <tbody class=${extras.join(' ')}>
         ${this.renderContextControls()} ${this.renderMoveControls()}
-        ${pairs.map(pair => {
+        ${pairs.map((pair, index) => {
           const leftClass = `left-${pair.left.lineNumber(Side.LEFT)}`;
           const rightClass = `right-${pair.right.lineNumber(Side.RIGHT)}`;
           return html`
@@ -150,6 +153,8 @@
               class="${leftClass} ${rightClass}"
               .left=${pair.left}
               .right=${pair.right}
+              .group=${this.group}
+              .showRevertButton=${isDelta && isEditMode && index === 0}
               .layers=${this.layers}
               .lineLength=${this.diffPrefs?.line_length ?? 80}
               .tabSize=${this.diffPrefs?.tab_size ?? 2}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
index 99b92ca..1134a9e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -259,4 +259,45 @@
       `
     );
   });
+
+  suite('revert button in edit mode', () => {
+    test('passes showRevertButton to first row of delta group in edit mode', async () => {
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+      removeLine.text = 'old line';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = 'new line';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      element.group = group;
+      element.renderPrefs = {is_edit_mode: true};
+      await element.updateComplete;
+
+      const rows = element.querySelectorAll('gr-diff-row');
+      assert.equal(rows.length, 1);
+      assert.isTrue(rows[0].showRevertButton);
+      assert.equal(rows[0].group, group);
+    });
+
+    test('does not pass showRevertButton when not in edit mode', async () => {
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+      removeLine.text = 'old line';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = 'new line';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+
+      element.group = group;
+      element.renderPrefs = {is_edit_mode: false};
+      await element.updateComplete;
+
+      const rows = element.querySelectorAll('gr-diff-row');
+      assert.equal(rows.length, 1);
+      assert.isFalse(rows[0].showRevertButton);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 4fd7354..9b97e5c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -81,6 +81,7 @@
   private sideInternal = Side.RIGHT;
 
   set diffRowTR(diffRowTR: HTMLTableRowElement | undefined) {
+    this.clearTargetRange();
     if (this.diffRowTRInternal) {
       this.diffRowTRInternal.classList.remove(
         LEFT_SIDE_CLASS,
@@ -105,6 +106,8 @@
 
   private diffRowTRInternal?: HTMLTableRowElement;
 
+  private targetRangeElements: HTMLElement[] = [];
+
   private diffs: GrDiffCursorable[] = [];
 
   /**
@@ -117,6 +120,8 @@
    */
   initialLineNumber: number | null = null;
 
+  initialEndLineNumber?: number;
+
   // visible for testing
   cursorManager = new GrCursorManager();
 
@@ -134,11 +139,26 @@
   }
 
   dispose() {
+    this.clearTargetRange();
     this.cursorManager.unsetCursor();
     if (this.targetSubscription) this.targetSubscription.unsubscribe();
     window.removeEventListener('scroll', this.boundHandleWindowScroll);
   }
 
+  private clearTargetRange() {
+    for (const el of this.targetRangeElements) {
+      el.classList.remove(
+        'target-range-row',
+        'target-range-start',
+        'target-range-middle',
+        'target-range-end',
+        LEFT_SIDE_CLASS,
+        RIGHT_SIDE_CLASS
+      );
+    }
+    this.targetRangeElements = [];
+  }
+
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
   isAtStart() {
     return this.cursorManager.isAtStart();
@@ -234,13 +254,112 @@
     number: LineNumber,
     side: Side,
     path?: string,
-    intentionalMove?: boolean
+    intentionalMove?: boolean,
+    endNumber?: LineNumber
   ) {
     const row = this.findRowByNumberAndFile(number, side, path);
-    if (row) {
-      this.side = side;
-      this.cursorManager.setCursor(row, undefined, intentionalMove);
+    if (!row) {
+      return;
     }
+
+    this.side = side;
+    this.cursorManager.setCursor(row, undefined, intentionalMove);
+
+    if (endNumber) {
+      this.applyTargetRange(row, number, endNumber, side, path);
+    }
+  }
+
+  private applyTargetRange(
+    startRow: HTMLElement,
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side: Side,
+    path?: string
+  ) {
+    if (
+      typeof startLine !== 'number' ||
+      typeof endLine !== 'number' ||
+      endLine <= startLine
+    ) {
+      return;
+    }
+
+    const endRow = this.findRowByNumberAndFile(endLine, side, path);
+    if (endRow) {
+      this.setRangeClasses(startRow, endRow, side, path);
+    }
+    this.fireRangeSelection(side, startLine, endLine, endRow);
+  }
+
+  private setRangeClasses(
+    startRow: HTMLElement,
+    endRow: HTMLElement,
+    side: Side,
+    path?: string
+  ) {
+    const stops: Array<HTMLElement | AbortStop> = path
+      ? this.diffs.find(diff => diff.path === path)?.getCursorStops() ??
+        this.cursorManager.stops
+      : this.cursorManager.stops;
+    const targetableStops = stops.filter(isTargetable);
+    const startIndex = targetableStops.indexOf(startRow);
+    const endIndex = targetableStops.indexOf(endRow);
+
+    if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
+      return;
+    }
+
+    startRow.classList.remove('target-row');
+    const sideClass = side === Side.LEFT ? LEFT_SIDE_CLASS : RIGHT_SIDE_CLASS;
+    const rangeRows = targetableStops.slice(startIndex, endIndex + 1);
+
+    for (let i = 0; i < rangeRows.length; i++) {
+      const rangeRow = rangeRows[i];
+      rangeRow.classList.add('target-range-row', sideClass);
+      if (i === 0) {
+        rangeRow.classList.add('target-range-start');
+      } else if (i === rangeRows.length - 1) {
+        rangeRow.classList.add('target-range-end');
+      } else {
+        rangeRow.classList.add('target-range-middle');
+      }
+    }
+    this.targetRangeElements = rangeRows;
+  }
+
+  private fireRangeSelection(
+    side: Side,
+    startLine: number,
+    endLine: number,
+    endRow?: HTMLElement
+  ) {
+    const diff = this.getTargetDiffElement();
+    if (!diff) {
+      return;
+    }
+
+    const endChar =
+      endRow?.querySelector('.contentText')?.textContent?.length ?? 1000;
+    diff.diffModel.fireRangeSelectedEvent(
+      side,
+      {
+        start_line: startLine,
+        start_character: 0,
+        end_line: endLine,
+        end_character: endChar,
+      },
+      true
+    );
+  }
+
+  moveToLineRange(
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side: Side,
+    path?: string
+  ) {
+    this.moveToLineNumber(startLine, side, path, true, endLine);
   }
 
   /**
@@ -308,8 +427,15 @@
         ? ScrollMode.KEEP_VISIBLE
         : ScrollMode.NEVER;
       if (this.initialLineNumber) {
-        this.moveToLineNumber(this.initialLineNumber, this.side);
+        this.moveToLineNumber(
+          this.initialLineNumber,
+          this.side,
+          undefined,
+          undefined,
+          this.initialEndLineNumber
+        );
         this.initialLineNumber = null;
+        this.initialEndLineNumber = undefined;
       } else {
         this.moveToFirstChunk();
       }
@@ -511,8 +637,8 @@
   ): HTMLElement | undefined {
     let stops: Array<HTMLElement | AbortStop>;
     if (path) {
-      const diff = this.diffs.filter(diff => diff.path === path)[0];
-      stops = diff.getCursorStops();
+      const diff = this.diffs.find(diff => diff.path === path);
+      stops = diff ? diff.getCursorStops() : this.cursorManager.stops;
     } else {
       stops = this.cursorManager.stops;
     }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
index 6cbf422..6a046b4 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -229,6 +229,31 @@
     assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
   });
 
+  test('moves to line range and sets target-range classes', () => {
+    cursor.moveToLineRange(1, 3, Side.RIGHT);
+
+    const row1 = cursor.findRowByNumberAndFile(1, Side.RIGHT);
+    const row2 = cursor.findRowByNumberAndFile(2, Side.RIGHT);
+    const row3 = cursor.findRowByNumberAndFile(3, Side.RIGHT);
+
+    assert.isDefined(row1);
+    assert.isDefined(row2);
+    assert.isDefined(row3);
+    assert.isTrue(row1.classList.contains('target-range-start'));
+    assert.isTrue(row1.classList.contains('target-range-row'));
+    assert.isTrue(row2.classList.contains('target-range-middle'));
+    assert.isTrue(row2.classList.contains('target-range-row'));
+    assert.isTrue(row3.classList.contains('target-range-end'));
+    assert.isTrue(row3.classList.contains('target-range-row'));
+
+    // Moving away clears range classes
+    cursor.moveDown();
+    assert.isFalse(row1.classList.contains('target-range-start'));
+    assert.isFalse(row1.classList.contains('target-range-row'));
+    assert.isFalse(row2.classList.contains('target-range-middle'));
+    assert.isFalse(row3.classList.contains('target-range-end'));
+  });
+
   suite('unified diff', () => {
     setup(async () => {
       diffElement.diffModel.updateState({
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index ed218aa..1ade0bc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -53,6 +53,9 @@
 export interface DiffBuilderInterface {
   getContentTdByLineEl(lineEl?: Element): Element | undefined;
   diffModel: DiffModel;
+  diffSelection?: {
+    getSelectedText?: (side: Side) => string;
+  };
 }
 
 /**
@@ -64,6 +67,7 @@
 export class GrDiffHighlight {
   selectedRange?: SidedRange;
 
+  // visible for testing
   private diffBuilder?: DiffBuilderInterface;
 
   private diffTable?: HTMLElement;
@@ -316,18 +320,18 @@
    * positioning the tooltip.
    */
   // visible for testing
-  positionActionBox(
+  async positionActionBox(
     actionBox: GrSelectionActionBox,
     startLine: number,
     range: Text | Element | Range
-  ) {
+  ): Promise<void> {
     if (startLine > 1) {
       actionBox.positionBelow = false;
-      actionBox.placeAbove(range);
+      await actionBox.placeAbove(range);
       return;
     }
     actionBox.positionBelow = true;
-    actionBox.placeBelow(range);
+    await actionBox.placeBelow(range);
   }
 
   private isRangeValid(range: NormalizedRange | null) {
@@ -344,7 +348,10 @@
   }
 
   // visible for testing
-  handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+  async handleSelection(
+    selection: Selection | Range,
+    isMouseUp: boolean
+  ): Promise<void> {
     /* On Safari, the selection events may return a null range that should
        be ignored */
     if (!selection) return;
@@ -405,18 +412,33 @@
     let actionBox = this.diffTable.querySelector('gr-selection-action-box');
     if (!actionBox) {
       actionBox = document.createElement('gr-selection-action-box');
-      this.diffTable.appendChild(actionBox);
     }
+    // eslint-disable-next-line @typescript-eslint/require-await
+    actionBox.getSelectionContext = async () => {
+      const path = this.diffBuilder?.diffModel?.getState()?.path;
+      const diffSelection = this.diffBuilder?.diffSelection;
+      const text =
+        diffSelection?.getSelectedText?.(side) ?? domRange.toString();
+      return {
+        path,
+        side,
+        range,
+        text,
+      };
+    };
     const hoverCardText =
       this.diffBuilder?.diffModel.getState().actionHoverCardText;
     if (hoverCardText) {
-      actionBox.setAttribute('hoverCardText', hoverCardText);
+      actionBox.hoverCardText = hoverCardText;
+    }
+    if (!actionBox.parentElement) {
+      this.diffTable.appendChild(actionBox);
     }
     if (start.line === end.line) {
-      this.positionActionBox(actionBox, start.line, domRange);
+      await this.positionActionBox(actionBox, start.line, domRange);
     } else if (start.node instanceof Text) {
       if (start.column) {
-        this.positionActionBox(
+        await this.positionActionBox(
           actionBox,
           start.line,
           start.node.splitText(start.column)
@@ -429,9 +451,13 @@
       (start.node.firstChild instanceof Element ||
         start.node.firstChild instanceof Text)
     ) {
-      this.positionActionBox(actionBox, start.line, start.node.firstChild);
+      await this.positionActionBox(
+        actionBox,
+        start.line,
+        start.node.firstChild
+      );
     } else if (start.node instanceof Element || start.node instanceof Text) {
-      this.positionActionBox(actionBox, start.line, start.node);
+      await this.positionActionBox(actionBox, start.line, start.node);
     } else {
       console.warn('Failed to position comment action box.');
       this.removeActionBox();
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
index f44a251..2156d7e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -22,7 +22,7 @@
   waitUntil,
 } from '../../../test/test-utils';
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {DiffModel} from '../gr-diff-model/gr-diff-model';
+import {DiffModel, DiffState} from '../gr-diff-model/gr-diff-model';
 
 // Splitting long lines in html into shorter rows breaks tests:
 // zero-length text nodes and new lines are not expected in some places
@@ -319,6 +319,36 @@
       assert.notOk(actionBox.positionBelow);
     });
 
+    test('actionBox properties populated correctly', async () => {
+      const content = stubContent(138, Side.LEFT);
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const diffModel = (element as any).diffBuilder.diffModel;
+      const getStateStub = sinon.stub(diffModel, 'getState').returns({
+        path: 'foo/bar.txt',
+      } as unknown as DiffState);
+
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+
+      const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+        diff,
+        'gr-selection-action-box'
+      );
+
+      assert.isDefined(actionBox.getSelectionContext);
+      const context = await actionBox.getSelectionContext();
+      assert.equal(context.path, 'foo/bar.txt');
+      assert.equal(context.side, Side.LEFT);
+      assert.deepEqual(context.range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(context.text, 'Nam cum');
+      getStateStub.restore();
+    });
+
     test('multiline', () => {
       const startContent = stubContent(119, Side.RIGHT);
       const endContent = stubContent(120, Side.RIGHT);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 843e053..058d490 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -161,6 +161,9 @@
           font-size: var(--font-size-normal);
           --image-border-width: 2px;
         }
+        :host(.fit) {
+          max-height: var(--image-viewer-max-height, 75vh);
+        }
         .imageArea {
           grid-row-start: 1;
           grid-column-start: 1;
@@ -679,6 +682,7 @@
   // We don't want property changes in updateSizes() to trigger infinite update
   // loops, so we perform this in update() instead of updated().
   override update(changedProperties: PropertyValues) {
+    this.classList.toggle('fit', this.scaledSelected);
     if (!this.baseUrl) this.baseSelected = false;
 
     if (!this.revisionUrl) this.baseSelected = true;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
index 894187c..67ecd18 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -280,17 +280,31 @@
     tr.diff-row.target-row.target-side-right
     td.lineNum
     button.lineNumButton.right,
-  gr-diff-row tr.diff-row.target-row.unified td.lineNum button.lineNumButton {
+  gr-diff-row tr.diff-row.target-row.unified td.lineNum button.lineNumButton,
+  gr-diff-row
+    tr.diff-row.target-range-row.target-side-left
+    td.lineNum
+    button.lineNumButton.left,
+  gr-diff-row
+    tr.diff-row.target-range-row.target-side-right
+    td.lineNum
+    button.lineNumButton.right,
+  gr-diff-row
+    tr.diff-row.target-range-row.unified
+    td.lineNum
+    button.lineNumButton {
     color: var(--primary-text-color);
   }
   /* Preparing selected line cells with position relative so it allows a
      positioned overlay with 'position: absolute'. */
-  gr-diff-row tr.target-row td {
+  gr-diff-row tr.target-row td,
+  gr-diff-row tr.target-range-row td {
     position: relative;
   }
   /* Defines an overlay to the selected line for drawing an outline without
      blocking user interaction (e.g. text selection). */
-  gr-diff-row tr.target-row td::before {
+  gr-diff-row tr.target-row td::before,
+  gr-diff-row tr.target-range-row td::before {
     border-width: 0;
     border-style: solid;
     border-color: var(--focused-line-outline-color);
@@ -335,6 +349,84 @@
   gr-diff-row tr.unified.target-row td.right:not(.content)::before {
     border-width: 1px 0;
   }
+  /* Multi-line target range outline: Start row */
+  gr-diff-row tr.target-range-start.target-side-left td.left.content::before,
+  gr-diff-row tr.target-range-start.target-side-right td.right.content::before,
+  gr-diff-row tr.unified.target-range-start td.content::before {
+    border-width: 1px 1px 0 0;
+  }
+  gr-diff-row tr.target-range-start.target-side-left td.left.sign::before,
+  gr-diff-row tr.target-range-start.target-side-right td.right.sign::before {
+    border-width: 1px 0 0 0;
+  }
+  gr-diff-row
+    tr.side-by-side.target-range-start.target-side-left
+    td.left.lineNum::before,
+  gr-diff-row
+    tr.side-by-side.target-range-start.target-side-right
+    td.right.lineNum::before {
+    border-width: 1px 0 0 1px;
+  }
+  gr-diff-row tr.unified.target-range-start td.left:not(.content)::before {
+    border-width: 1px 0 0 1px;
+  }
+  gr-diff-row tr.unified.target-range-start td.right:not(.content)::before {
+    border-width: 1px 0 0 0;
+  }
+  /* Multi-line target range outline: Middle rows */
+  gr-diff-row tr.target-range-middle.target-side-left td.left.content::before,
+  gr-diff-row tr.target-range-middle.target-side-right td.right.content::before,
+  gr-diff-row tr.unified.target-range-middle td.content::before {
+    border-width: 0 1px 0 0;
+  }
+  gr-diff-row tr.target-range-middle.target-side-left td.left.sign::before,
+  gr-diff-row tr.target-range-middle.target-side-right td.right.sign::before {
+    border-width: 0;
+  }
+  gr-diff-row
+    tr.side-by-side.target-range-middle.target-side-left
+    td.left.lineNum::before,
+  gr-diff-row
+    tr.side-by-side.target-range-middle.target-side-right
+    td.right.lineNum::before {
+    border-width: 0 0 0 1px;
+  }
+  gr-diff-row tr.unified.target-range-middle td.left:not(.content)::before {
+    border-width: 0 0 0 1px;
+  }
+  gr-diff-row tr.unified.target-range-middle td.right:not(.content)::before {
+    border-width: 0;
+  }
+  /* Multi-line target range outline: End row */
+  gr-diff-row tr.target-range-end.target-side-left td.left.content::before,
+  gr-diff-row tr.target-range-end.target-side-right td.right.content::before,
+  gr-diff-row tr.unified.target-range-end td.content::before {
+    border-width: 0 1px 1px 0;
+  }
+  gr-diff-row tr.target-range-end.target-side-left td.left.sign::before,
+  gr-diff-row tr.target-range-end.target-side-right td.right.sign::before {
+    border-width: 0 0 1px 0;
+  }
+  gr-diff-row
+    tr.side-by-side.target-range-end.target-side-left
+    td.left.lineNum::before,
+  gr-diff-row
+    tr.side-by-side.target-range-end.target-side-right
+    td.right.lineNum::before {
+    border-width: 0 0 1px 1px;
+  }
+  gr-diff-row tr.unified.target-range-end td.left:not(.content)::before {
+    border-width: 0 0 1px 1px;
+  }
+  gr-diff-row tr.unified.target-range-end td.right:not(.content)::before {
+    border-width: 0 0 1px 0;
+  }
+  /* Target range subtle background tint */
+  gr-diff-row tr.target-range-row.target-side-left td.left.content,
+  gr-diff-row tr.target-range-row.target-side-right td.right.content,
+  gr-diff-row tr.unified.target-range-row td.content {
+    background-color: var(--selection-background-color);
+  }
   gr-diff-row td.content {
     background-color: var(--diff-blank-background-color);
   }
@@ -346,6 +438,10 @@
     display: block;
     margin-top: var(--spacing-xs);
   }
+  ::slotted(.comment-thread) {
+    --gr-comment-thread-width: 100%;
+    --gr-comment-thread-diff-max-width: 100%;
+  }
   gr-diff-row td.content div.contentText {
     background-color: var(--view-background-color);
   }
@@ -370,6 +466,86 @@
     min-width: var(--content-width, 80ch);
     width: var(--content-width, 80ch);
   }
+  gr-diff-row td:has(.revert-container) {
+    position: relative;
+  }
+  gr-diff-row td.left .revert-container {
+    position: absolute;
+    right: -9px;
+    top: 0;
+    bottom: 0;
+    display: flex;
+    align-items: center;
+    z-index: 10;
+    pointer-events: none;
+  }
+  gr-diff-row tr.unified td.content .revert-container {
+    position: absolute;
+    right: 4px;
+    top: 0;
+    bottom: 0;
+    display: flex;
+    align-items: center;
+    z-index: 10;
+    pointer-events: none;
+  }
+  gr-diff-row .revert-btn {
+    pointer-events: auto;
+    width: 18px;
+    height: 18px;
+    border-radius: 3px;
+    border: 1px solid var(--border-color, #dadce0);
+    background-color: var(--background-color-primary, #ffffff);
+    color: var(--deemphasized-text-color, #5f6368);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    padding: 0;
+    margin: 0;
+    box-shadow: var(--elevation-level-1, 0 1px 2px rgba(60, 64, 67, 0.3));
+    transition: background-color 150ms ease, border-color 150ms ease,
+      box-shadow 150ms ease, color 150ms ease;
+  }
+  gr-diff-row .revert-btn gr-icon {
+    font-size: 14px;
+    line-height: 14px;
+    width: 14px;
+    height: 14px;
+    color: inherit;
+  }
+  gr-diff-row .revert-btn:hover {
+    background-color: var(--hover-background-color, #f1f3f4);
+    border-color: var(--primary-button-background-color, #1a73e8);
+    color: var(--primary-button-background-color, #1a73e8);
+    box-shadow: var(--elevation-level-2, 0 1px 3px 1px rgba(60, 64, 67, 0.15));
+  }
+  gr-diff-row .revert-btn:active {
+    background-color: var(--chip-selected-background-color, #e8f0fe);
+  }
+  gr-diff-row .revert-btn.loading {
+    cursor: wait;
+    pointer-events: none;
+    background-color: var(--chip-selected-background-color, #e8f0fe);
+    border-color: var(--primary-button-background-color, #1a73e8);
+  }
+  gr-diff-row .revert-btn .loadingSpin {
+    width: 10px;
+    height: 10px;
+    border: 2px solid var(--disabled-button-background-color, #dadce0);
+    border-top: 2px solid var(--primary-button-background-color, #1a73e8);
+    border-radius: 50%;
+    animation: spin 1s linear infinite;
+    box-sizing: border-box;
+  }
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+    100% {
+      transform: rotate(360deg);
+    }
+  }
   /* If there are no intraline info, consider everything changed */
   gr-diff-row td.content.add div.contentText .intraline,
   gr-diff-row td.content.add.no-intraline-info div.contentText,
@@ -747,9 +923,11 @@
     width: 100%;
     height: 100%;
     max-width: var(--image-viewer-max-width, 95vw);
-    max-height: var(--image-viewer-max-height, 90vh);
     --primary-background-color: var(--background-color-secondary);
   }
+  gr-image-viewer.fit {
+    max-height: var(--image-viewer-max-height, 75vh);
+  }
   tbody.image-diff .gr-diff {
     text-align: center;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 1423efb..3562732 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -3,7 +3,11 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {BlameInfo, CommentRange} from '../../../types/common';
+import {
+  BlameInfo,
+  CommentRange,
+  FixSuggestionInfo,
+} from '../../../types/common';
 import {Side, SpecialFilePath} from '../../../constants/constants';
 import {
   DiffContextExpandedExternalDetail,
@@ -15,7 +19,9 @@
   LOST,
   RenderPreferences,
 } from '../../../api/diff';
-import {GrDiffGroup} from './gr-diff-group';
+import {GrDiffGroup, GrDiffGroupType} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -46,6 +52,9 @@
   if (renderPrefs?.responsive_mode) {
     return renderPrefs.responsive_mode;
   }
+  if (prefs?.responsive_mode) {
+    return prefs.responsive_mode;
+  }
   // Backwards compatibility to the line_wrapping param.
   if (prefs?.line_wrapping) {
     return 'FULL_RESPONSIVE';
@@ -329,3 +338,302 @@
     info.ranges.find(range => range.start <= line && line <= range.end)
   );
 }
+
+export function getContentGroups(groups: GrDiffGroup[]): GrDiffGroup[] {
+  const result: GrDiffGroup[] = [];
+  for (const group of groups) {
+    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+      result.push(...getContentGroups(group.contextGroups));
+    } else {
+      const isSpecialFileOrLostGroup =
+        group.lines.length === 1 &&
+        (group.lines[0].beforeNumber === FILE ||
+          group.lines[0].beforeNumber === LOST ||
+          group.lines[0].afterNumber === FILE ||
+          group.lines[0].afterNumber === LOST);
+      if (!isSpecialFileOrLostGroup) {
+        result.push(group);
+      }
+    }
+  }
+  return result;
+}
+
+function findPrevLineOnRight(
+  contentGroups: GrDiffGroup[],
+  group: GrDiffGroup
+): GrDiffLine | undefined {
+  const groupIdx = contentGroups.indexOf(group);
+  if (groupIdx === -1) return undefined;
+  for (let i = groupIdx - 1; i >= 0; i--) {
+    const lines = contentGroups[i].lines;
+    for (let j = lines.length - 1; j >= 0; j--) {
+      const line = lines[j];
+      if (typeof line.afterNumber === 'number' && line.afterNumber > 0) {
+        return line;
+      }
+    }
+  }
+  return undefined;
+}
+
+function findNextLineOnRight(
+  contentGroups: GrDiffGroup[],
+  group: GrDiffGroup
+): GrDiffLine | undefined {
+  const groupIdx = contentGroups.indexOf(group);
+  if (groupIdx === -1) return undefined;
+  for (let i = groupIdx + 1; i < contentGroups.length; i++) {
+    const lines = contentGroups[i].lines;
+    for (const line of lines) {
+      if (typeof line.afterNumber === 'number' && line.afterNumber > 0) {
+        return line;
+      }
+    }
+  }
+  return undefined;
+}
+
+export function getRevertedFileContent(
+  group: GrDiffGroup,
+  allGroups: GrDiffGroup[]
+): string | undefined {
+  if (group.type !== GrDiffGroupType.DELTA) return undefined;
+  const contentGroups = getContentGroups(allGroups);
+  if (contentGroups.length === 0 || !contentGroups.includes(group)) {
+    return undefined;
+  }
+  if (contentGroups.some(g => g.skip !== undefined)) {
+    return undefined;
+  }
+
+  const lines: string[] = [];
+  for (const g of contentGroups) {
+    if (g === group) {
+      for (const line of g.removes) {
+        lines.push(line.text);
+      }
+    } else if (g.type === GrDiffGroupType.BOTH) {
+      for (const line of g.lines) {
+        lines.push(line.text);
+      }
+    } else if (g.type === GrDiffGroupType.DELTA) {
+      for (const line of g.adds) {
+        lines.push(line.text);
+      }
+    }
+  }
+  return lines.join('\n');
+}
+
+export function createRevertFixSuggestion(
+  path: string,
+  group: GrDiffGroup,
+  allGroups: GrDiffGroup[] = []
+): FixSuggestionInfo | undefined {
+  if (group.type !== GrDiffGroupType.DELTA) return undefined;
+
+  const contentGroups = getContentGroups(allGroups);
+  const groupIdx = contentGroups.indexOf(group);
+  if (contentGroups.length > 0 && groupIdx === -1) {
+    return undefined;
+  }
+
+  const removes = group.removes ?? [];
+  const adds = group.adds ?? [];
+
+  // Case 1: Modification (lines removed in base AND lines added in edit)
+  if (removes.length > 0 && adds.length > 0) {
+    const startLine = adds[0].afterNumber;
+    const endLine = adds[adds.length - 1].afterNumber;
+    if (typeof startLine !== 'number' || typeof endLine !== 'number') {
+      return undefined;
+    }
+    const lastLineText = adds[adds.length - 1].text;
+    const replacement = removes.map(l => l.text).join('\n');
+    return {
+      fix_id: PROVIDED_FIX_ID,
+      description: 'Revert change',
+      replacements: [
+        {
+          path,
+          range: {
+            start_line: startLine,
+            start_character: 0,
+            end_line: endLine,
+            end_character: lastLineText.length,
+          },
+          replacement,
+        },
+      ],
+    };
+  }
+
+  // Case 2: Pure Addition in Edit (removes is empty, adds has lines)
+  if (removes.length === 0 && adds.length > 0) {
+    const startLine = adds[0].afterNumber;
+    const endLine = adds[adds.length - 1].afterNumber;
+    if (typeof startLine !== 'number' || typeof endLine !== 'number') {
+      return undefined;
+    }
+    const prevLine = findPrevLineOnRight(contentGroups, group);
+    const nextLine = findNextLineOnRight(contentGroups, group);
+
+    if (startLine > 1 && prevLine && typeof prevLine.afterNumber === 'number') {
+      // Include the line above: replace from start of line above
+      if (nextLine && typeof nextLine.afterNumber === 'number') {
+        return {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'Revert change',
+          replacements: [
+            {
+              path,
+              range: {
+                start_line: prevLine.afterNumber,
+                start_character: 0,
+                end_line: nextLine.afterNumber,
+                end_character: 0,
+              },
+              replacement: prevLine.text + '\n',
+            },
+          ],
+        };
+      } else {
+        // Addition at EOF: replace from start of line above to end of last added line
+        const lastLineText = adds[adds.length - 1].text;
+        return {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'Revert change',
+          replacements: [
+            {
+              path,
+              range: {
+                start_line: prevLine.afterNumber,
+                start_character: 0,
+                end_line: endLine,
+                end_character: lastLineText.length,
+              },
+              replacement: prevLine.text + '\n',
+            },
+          ],
+        };
+      }
+    } else if (
+      startLine === 1 &&
+      nextLine &&
+      typeof nextLine.afterNumber === 'number'
+    ) {
+      // Addition at the top of the file (line 1, no line above):
+      // Include line below: replace from (1, 0) to end of next line
+      return {
+        fix_id: PROVIDED_FIX_ID,
+        description: 'Revert change',
+        replacements: [
+          {
+            path,
+            range: {
+              start_line: 1,
+              start_character: 0,
+              end_line: nextLine.afterNumber,
+              end_character: nextLine.text.length,
+            },
+            replacement: nextLine.text,
+          },
+        ],
+      };
+    } else if (
+      startLine === 1 &&
+      !prevLine &&
+      !nextLine &&
+      (contentGroups.length === 0 || contentGroups.length === 1)
+    ) {
+      // Entire file was added (no line above and no line below)
+      const lastLineText = adds[adds.length - 1].text;
+      return {
+        fix_id: PROVIDED_FIX_ID,
+        description: 'Revert change',
+        replacements: [
+          {
+            path,
+            range: {
+              start_line: 1,
+              start_character: 0,
+              end_line: endLine,
+              end_character: lastLineText.length,
+            },
+            replacement: '',
+          },
+        ],
+      };
+    } else {
+      return undefined;
+    }
+  }
+
+  // Case 3: Pure Deletion in Edit (removes has lines, adds is empty)
+  if (removes.length > 0 && adds.length === 0) {
+    const nextLine = findNextLineOnRight(contentGroups, group);
+    if (nextLine && typeof nextLine.afterNumber === 'number') {
+      return {
+        fix_id: PROVIDED_FIX_ID,
+        description: 'Revert change',
+        replacements: [
+          {
+            path,
+            range: {
+              start_line: nextLine.afterNumber,
+              start_character: 0,
+              end_line: nextLine.afterNumber,
+              end_character: 0,
+            },
+            replacement: removes.map(l => l.text).join('\n') + '\n',
+          },
+        ],
+      };
+    } else {
+      // Deletion at the end of the file
+      const prevLine = findPrevLineOnRight(contentGroups, group);
+      if (prevLine && typeof prevLine.afterNumber === 'number') {
+        const prevLineNumber = prevLine.afterNumber;
+        return {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'Revert change',
+          replacements: [
+            {
+              path,
+              range: {
+                start_line: prevLineNumber,
+                start_character: prevLine.text.length,
+                end_line: prevLineNumber,
+                end_character: prevLine.text.length,
+              },
+              replacement: '\n' + removes.map(l => l.text).join('\n'),
+            },
+          ],
+        };
+      } else if (contentGroups.length === 0 || contentGroups.length === 1) {
+        // File in Edit was completely empty
+        return {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'Revert change',
+          replacements: [
+            {
+              path,
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 1,
+                end_character: 0,
+              },
+              replacement: removes.map(l => l.text).join('\n') + '\n',
+            },
+          ],
+        };
+      } else {
+        return undefined;
+      }
+    }
+  }
+
+  return undefined;
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 2f5b077..9501f62 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -10,15 +10,21 @@
   computeContext,
   computeKeyLocations,
   computeLineLength,
+  createRevertFixSuggestion,
   FULL_CONTEXT,
   FullContext,
+  getContentGroups,
   getDataFromCommentThreadEl,
   getRange,
+  getRevertedFileContent,
   GrDiffCommentThread,
   GrDiffThreadElement,
 } from './gr-diff-utils';
-import {FILE, LOST, Side} from '../../../api/diff';
+import {FILE, GrDiffLineType, LOST, Side} from '../../../api/diff';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffGroup, GrDiffGroupType} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
 
 suite('gr-diff-utils tests', () => {
   test('getRange returns undefined with start_line = 0', () => {
@@ -238,4 +244,512 @@
       );
     });
   });
+
+  suite('createRevertFixSuggestion', () => {
+    test('returns undefined for non-delta group', () => {
+      const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+      line.text = 'common line';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [line],
+      });
+      assert.isUndefined(createRevertFixSuggestion('foo.ts', group));
+    });
+
+    test('creates fix for modification', () => {
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 10, 0);
+      removeLine.text = 'const a = 1;';
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 10);
+      addLine.text = 'const a = 2;';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine, addLine],
+      });
+      const fix = createRevertFixSuggestion('foo.ts', group);
+      assert.isDefined(fix);
+      assert.equal(fix.fix_id, PROVIDED_FIX_ID);
+      assert.equal(fix.description, 'Revert change');
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 10,
+            start_character: 0,
+            end_line: 10,
+            end_character: 12,
+          },
+          replacement: 'const a = 1;',
+        },
+      ]);
+    });
+
+    test('creates fix for pure addition in middle of file', () => {
+      const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 4, 4);
+      prevLine.text = 'common line 4';
+      const prevGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [prevLine],
+      });
+
+      const addLine1 = new GrDiffLine(GrDiffLineType.ADD, 0, 5);
+      addLine1.text = 'new line 5';
+      const addLine2 = new GrDiffLine(GrDiffLineType.ADD, 0, 6);
+      addLine2.text = 'new line 6';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine1, addLine2],
+      });
+
+      const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 5, 7);
+      nextLine.text = 'common line 7';
+      const nextGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [nextLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        prevGroup,
+        group,
+        nextGroup,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 4,
+            start_character: 0,
+            end_line: 7,
+            end_character: 0,
+          },
+          replacement: 'common line 4\n',
+        },
+      ]);
+    });
+
+    test('creates fix for pure addition of empty line in middle of file', () => {
+      const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+      prevLine.text = 'first line';
+      const prevGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [prevLine],
+      });
+
+      const emptyLine = new GrDiffLine(GrDiffLineType.ADD, 0, 2);
+      emptyLine.text = '';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [emptyLine],
+      });
+
+      const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 2, 3);
+      nextLine.text = 'second line';
+      const nextGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [nextLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        prevGroup,
+        group,
+        nextGroup,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 3,
+            end_character: 0,
+          },
+          replacement: 'first line\n',
+        },
+      ]);
+    });
+
+    test('creates fix for pure addition at beginning of file', () => {
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = '';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+
+      const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 1, 2);
+      nextLine.text = 'existing line';
+      const nextGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [nextLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        group,
+        nextGroup,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 2,
+            end_character: 13,
+          },
+          replacement: 'existing line',
+        },
+      ]);
+    });
+
+    test('creates fix for pure addition at end of file', () => {
+      const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 10, 10);
+      prevLine.text = 'prev line 10';
+      const prevGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [prevLine],
+      });
+
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 11);
+      addLine.text = 'end addition';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        prevGroup,
+        group,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 10,
+            start_character: 0,
+            end_line: 11,
+            end_character: 12,
+          },
+          replacement: 'prev line 10\n',
+        },
+      ]);
+    });
+
+    test('creates fix for pure addition of whole file', () => {
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = 'whole file content';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [group]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 1,
+            end_character: 18,
+          },
+          replacement: '',
+        },
+      ]);
+    });
+
+    test('creates fix for pure deletion in middle of file', () => {
+      const removeLine1 = new GrDiffLine(GrDiffLineType.REMOVE, 5, 0);
+      removeLine1.text = 'deleted line 5';
+      const removeLine2 = new GrDiffLine(GrDiffLineType.REMOVE, 6, 0);
+      removeLine2.text = 'deleted line 6';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine1, removeLine2],
+      });
+
+      const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 7, 5);
+      nextLine.text = 'common line';
+      const nextGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [nextLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        group,
+        nextGroup,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 5,
+            start_character: 0,
+            end_line: 5,
+            end_character: 0,
+          },
+          replacement: 'deleted line 5\ndeleted line 6\n',
+        },
+      ]);
+    });
+
+    test('creates fix for pure deletion at end of file', () => {
+      const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 4, 4);
+      prevLine.text = 'prev line 4';
+      const prevGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [prevLine],
+      });
+
+      const removeLine = new GrDiffLine(GrDiffLineType.REMOVE, 5, 0);
+      removeLine.text = 'deleted last line';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [removeLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        prevGroup,
+        group,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 4,
+            start_character: 11,
+            end_line: 4,
+            end_character: 11,
+          },
+          replacement: '\ndeleted last line',
+        },
+      ]);
+    });
+
+    test('returns undefined when group is not found in non-empty allGroups', () => {
+      const otherGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)],
+      });
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [new GrDiffLine(GrDiffLineType.ADD, 0, 5)],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [otherGroup]);
+      assert.isUndefined(fix);
+    });
+
+    test('returns undefined when pure addition has startLine > 1 without surrounding context', () => {
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [new GrDiffLine(GrDiffLineType.ADD, 0, 10)],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [group]);
+      assert.isUndefined(fix);
+    });
+
+    test('returns undefined when pure addition has startLine > 1 without prevLine even if nextLine exists', () => {
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 5);
+      addLine.text = 'added line';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+
+      const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 6, 6);
+      nextLine.text = 'next line';
+      const nextGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [nextLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        group,
+        nextGroup,
+      ]);
+      assert.isUndefined(fix);
+    });
+
+    test('creates fix for whole file addition when LOST and FILE groups are present', () => {
+      const lostLine = new GrDiffLine(GrDiffLineType.BOTH, LOST, LOST);
+      const lostGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [lostLine],
+      });
+      const fileLine = new GrDiffLine(GrDiffLineType.BOTH, FILE, FILE);
+      const fileGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [fileLine],
+      });
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = 'whole file content';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        lostGroup,
+        fileGroup,
+        group,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 1,
+            end_character: 18,
+          },
+          replacement: '',
+        },
+      ]);
+    });
+
+    test('creates fix when surrounding lines are inside CONTEXT_CONTROL groups', () => {
+      const prevLine = new GrDiffLine(GrDiffLineType.BOTH, 4, 4);
+      prevLine.text = 'hidden line 4';
+      const hiddenPrevGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [prevLine],
+      });
+      const contextControlBefore = new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [hiddenPrevGroup],
+      });
+
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 5);
+      addLine.text = 'added line 5';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+
+      const nextLine = new GrDiffLine(GrDiffLineType.BOTH, 5, 6);
+      nextLine.text = 'hidden line 6';
+      const hiddenNextGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [nextLine],
+      });
+      const contextControlAfter = new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [hiddenNextGroup],
+      });
+
+      const fix = createRevertFixSuggestion('foo.ts', group, [
+        contextControlBefore,
+        group,
+        contextControlAfter,
+      ]);
+      assert.isDefined(fix);
+      assert.deepEqual(fix.replacements, [
+        {
+          path: 'foo.ts',
+          range: {
+            start_line: 4,
+            start_character: 0,
+            end_line: 6,
+            end_character: 0,
+          },
+          replacement: 'hidden line 4\n',
+        },
+      ]);
+    });
+  });
+
+  suite('getRevertedFileContent', () => {
+    test('reconstructs file content when reverting second hunk in multi-hunk file', () => {
+      const lostLine = new GrDiffLine(GrDiffLineType.BOTH, LOST, LOST);
+      const lostGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [lostLine],
+      });
+      const fileLine = new GrDiffLine(GrDiffLineType.BOTH, FILE, FILE);
+      const fileGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [fileLine],
+      });
+
+      const hunk1Remove = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+      hunk1Remove.text = 'const a = 1;';
+      const hunk1Add = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      hunk1Add.text = 'const a = 2;';
+      const hunk1Group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [hunk1Remove, hunk1Add],
+      });
+
+      const middleLine = new GrDiffLine(GrDiffLineType.BOTH, 2, 2);
+      middleLine.text = 'const middle = true;';
+      const hiddenMiddleGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [middleLine],
+      });
+      const contextControl = new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [hiddenMiddleGroup],
+      });
+
+      const hunk2Remove = new GrDiffLine(GrDiffLineType.REMOVE, 3, 0);
+      hunk2Remove.text = 'const b = 1;';
+      const hunk2Add = new GrDiffLine(GrDiffLineType.ADD, 0, 3);
+      hunk2Add.text = 'const b = 2;';
+      const hunk2Group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [hunk2Remove, hunk2Add],
+      });
+
+      const eofLine = new GrDiffLine(GrDiffLineType.BOTH, 4, 4);
+      eofLine.text = '';
+      const eofGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines: [eofLine],
+      });
+
+      const allGroups = [
+        lostGroup,
+        fileGroup,
+        hunk1Group,
+        contextControl,
+        hunk2Group,
+        eofGroup,
+      ];
+
+      assert.equal(getContentGroups(allGroups).length, 4);
+      const reverted = getRevertedFileContent(hunk2Group, allGroups);
+      assert.equal(
+        reverted,
+        'const a = 2;\nconst middle = true;\nconst b = 1;\n'
+      );
+    });
+
+    test('returns undefined when allGroups is empty or has skipped chunks', () => {
+      const addLine = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+      addLine.text = 'added';
+      const deltaGroup = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines: [addLine],
+      });
+      assert.isUndefined(getRevertedFileContent(deltaGroup, []));
+
+      const skippedGroup = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        skip: 100,
+        offsetLeft: 2,
+        offsetRight: 2,
+      });
+      assert.isUndefined(
+        getRevertedFileContent(deltaGroup, [deltaGroup, skippedGroup])
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 502d239..3277633 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -65,6 +65,13 @@
     element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
   });
 
+  test('embedder isolation: scroll-padding-top is not set on root', () => {
+    assert.equal(
+      document.documentElement.style.getPropertyValue('scroll-padding-top'),
+      ''
+    );
+  });
+
   suite('selectionchange event handling', () => {
     let handleSelectionChangeStub: sinon.SinonSpy;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index 2e73565..a803d5f 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -8,6 +8,19 @@
 import {fire} from '../../../utils/event-util';
 import {html, LitElement} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
+import {Side} from '../../../api/diff';
+import {CommentRange} from '../../../api/rest-api';
+
+export interface SelectionContext {
+  path?: string;
+  side?: Side;
+  range?: CommentRange;
+  text?: string;
+}
+
+export interface SelectionActionBoxVisibleEventDetail {
+  getSelectionContext?: () => Promise<SelectionContext>;
+}
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -18,7 +31,7 @@
     'create-comment-requested': CustomEvent<{}>;
 
     /** Fired when the selection action box is visible. */
-    'selection-action-box-visible': CustomEvent<{}>;
+    'selection-action-box-visible': CustomEvent<SelectionActionBoxVisibleEventDetail>;
   }
 }
 
@@ -37,6 +50,9 @@
   @property({type: String})
   hoverCardText = 'Press c to comment';
 
+  @property({type: Object})
+  getSelectionContext?: () => Promise<SelectionContext>;
+
   /**
    * We need to absolutely position the element before we can show it. So
    * initially the tooltip must be invisible.
@@ -86,6 +102,7 @@
   // TODO(b/315277651): This is very similar in purpose to gr-tooltip-content.
   //   We should figure out a way to reuse as much of the logic as possible.
   async placeAbove(el: Text | Element | Range) {
+    await this.updateComplete;
     if (!this.tooltip) return;
     await this.tooltip.updateComplete;
     const rect = this.getTargetBoundingRect(el);
@@ -99,10 +116,13 @@
       rect.left - parentRect.left + (rect.width - boxRect.width) / 2
     }px`;
     this.invisible = false;
-    fire(this, 'selection-action-box-visible', {});
+    fire(this, 'selection-action-box-visible', {
+      getSelectionContext: this.getSelectionContext,
+    });
   }
 
   async placeBelow(el: Text | Element | Range) {
+    await this.updateComplete;
     if (!this.tooltip) return;
     await this.tooltip.updateComplete;
     const rect = this.getTargetBoundingRect(el);
@@ -116,7 +136,9 @@
       rect.left - parentRect.left + (rect.width - boxRect.width) / 2
     }px`;
     this.invisible = false;
-    fire(this, 'selection-action-box-visible', {});
+    fire(this, 'selection-action-box-visible', {
+      getSelectionContext: this.getSelectionContext,
+    });
   }
 
   private getParentBoundingClientRect() {
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
index bd42135..4b0d4dd 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -7,8 +7,9 @@
 import '../../../test/common-test-setup';
 import './gr-selection-action-box';
 import {GrSelectionActionBox} from './gr-selection-action-box';
-import {queryAndAssert} from '../../../test/test-utils';
+import {listenOnce, queryAndAssert} from '../../../test/test-utils';
 import {assert, fixture, html} from '@open-wc/testing';
+import {Side} from '../../../api/diff';
 
 suite('gr-selection-action-box', () => {
   let container: HTMLDivElement;
@@ -122,6 +123,138 @@
       );
     });
 
+    test('fires selection-action-box-visible event with correct target and properties', async () => {
+      dispatchEventStub.restore();
+      const visibleEventPromise = listenOnce<CustomEvent>(
+        document,
+        'selection-action-box-visible'
+      );
+
+      element.getSelectionContext = () =>
+        Promise.resolve({
+          path: 'test-path.txt',
+          side: Side.LEFT,
+          range: {
+            start_line: 1,
+            start_character: 2,
+            end_line: 3,
+            end_character: 4,
+          },
+          text: 'selected text',
+        });
+
+      await element.placeAbove(target);
+
+      const ev = await visibleEventPromise;
+
+      const targetEl = ev.target as HTMLElement;
+      assert.equal(targetEl, element);
+      assert.isDefined(ev.detail.getSelectionContext);
+      const context = await ev.detail.getSelectionContext!();
+      assert.equal(context.path, 'test-path.txt');
+      assert.equal(context.side, Side.LEFT);
+      assert.deepEqual(context.range, {
+        start_line: 1,
+        start_character: 2,
+        end_line: 3,
+        end_character: 4,
+      });
+      assert.equal(context.text, 'selected text');
+    });
+
+    test('event retargeting in shadow DOM', async () => {
+      dispatchEventStub.restore();
+
+      // Create a host element with shadow DOM
+      const host = document.createElement('div');
+      const shadow = host.attachShadow({mode: 'open'});
+
+      // Put the action box and a target inside shadow DOM
+      const box = document.createElement('gr-selection-action-box');
+      const shadowTarget = document.createElement('div');
+      shadowTarget.textContent = 'shadow text';
+      shadow.appendChild(box);
+      shadow.appendChild(shadowTarget);
+
+      // Append host to body so it is in the document
+      document.body.appendChild(host);
+      try {
+        await box.updateComplete;
+
+        // Stub necessary methods on the new box instance
+        sinon
+          .stub(box, 'tooltip' as keyof GrSelectionActionBox)
+          .value(element.tooltip);
+        sinon.stub(box, 'getTargetBoundingRect').returns({
+          top: 42,
+          bottom: 20,
+          left: 30,
+          right: 40,
+          width: 100,
+          height: 60,
+        } as DOMRect);
+
+        let capturedPath: EventTarget[] = [];
+        const visibleEventPromise = new Promise<CustomEvent>(resolve => {
+          const listener = (e: Event) => {
+            capturedPath = e.composedPath();
+            document.removeEventListener(
+              'selection-action-box-visible',
+              listener
+            );
+            resolve(e as CustomEvent);
+          };
+          document.addEventListener('selection-action-box-visible', listener);
+        });
+
+        box.getSelectionContext = () =>
+          Promise.resolve({
+            path: 'shadow-path.txt',
+            side: Side.RIGHT,
+            range: {
+              start_line: 5,
+              start_character: 6,
+              end_line: 7,
+              end_character: 8,
+            },
+            text: 'shadow selected text',
+          });
+
+        await box.placeAbove(shadowTarget);
+
+        const ev = await visibleEventPromise;
+
+        // ev.target should be the host 'div' because of retargeting!
+        assert.equal(ev.target, host);
+        assert.isUndefined(
+          (ev.target as unknown as Record<string, unknown>).path
+        );
+        assert.isUndefined(
+          (ev.target as unknown as Record<string, unknown>).side
+        );
+
+        // Assert on event detail (should be preserved despite retargeting!)
+        assert.isDefined(ev.detail.getSelectionContext);
+        const context = await ev.detail.getSelectionContext!();
+        assert.equal(context.path, 'shadow-path.txt');
+        assert.equal(context.side, Side.RIGHT);
+        assert.deepEqual(context.range, {
+          start_line: 5,
+          start_character: 6,
+          end_line: 7,
+          end_character: 8,
+        });
+        assert.equal(context.text, 'shadow selected text');
+
+        // The actual target can be found in capturedPath
+        assert.isAbove(capturedPath.length, 0);
+        const actualTarget = capturedPath[0] as HTMLElement;
+        assert.equal(actualTarget, box);
+      } finally {
+        host.remove();
+      }
+    });
+
     test('placeAbove for Element argument', async () => {
       await element.placeAbove(target);
       assert.equal(element.style.top, '25px');
diff --git a/polygerrit-ui/app/embed/gr-diff-entry-point.ts b/polygerrit-ui/app/embed/gr-diff-entry-point.ts
new file mode 100644
index 0000000..ab02a0f
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-entry-point.ts
@@ -0,0 +1,8 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// DO NOT EXPORT ANYTHING FROM THIS FILE!
+import './gr-diff';
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 68293f4..c7966a9 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../api/embed';
-import '../scripts/bundled-polymer';
 import './diff/gr-diff/gr-diff';
 import './gr-textarea';
 import './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/eslint-bazel.config.js b/polygerrit-ui/app/eslint-bazel.config.js
index 9724124..53167ae 100644
--- a/polygerrit-ui/app/eslint-bazel.config.js
+++ b/polygerrit-ui/app/eslint-bazel.config.js
@@ -76,6 +76,11 @@
 }
 
 module.exports = defineConfig([
+  {
+    linterOptions: {
+      reportUnusedDisableDirectives: 'error',
+    },
+  },
   globalIgnores([
     '**/node_modules',
     '**/rollup.config.js',
diff --git a/polygerrit-ui/app/models/accounts/accounts-model.ts b/polygerrit-ui/app/models/accounts/accounts-model.ts
index 6eedcbe..9aef9ce 100644
--- a/polygerrit-ui/app/models/accounts/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts/accounts-model.ts
@@ -21,6 +21,11 @@
 export const accountsModelToken = define<AccountsModel>('accounts-model');
 
 export class AccountsModel extends Model<AccountsState> {
+  private inFlight = new Map<
+    UserId,
+    Promise<AccountDetailInfo | AccountInfo>
+  >();
+
   constructor(readonly restApiService: RestApiService) {
     super({
       accounts: {},
@@ -32,32 +37,48 @@
     account: AccountDetailInfo | AccountInfo
   ) {
     if (!account) return;
-    const current = {...this.getState()};
-    current.accounts = {...current.accounts, [id]: account};
-    this.setState(current);
+    this.updateState({
+      accounts: {...this.getState().accounts, [id]: account},
+    });
   }
 
-  async getAccount(
+  getAccount(
     partialAccount: AccountInfo
   ): Promise<AccountDetailInfo | AccountInfo> {
     const current = this.getState();
     const id = getUserId(partialAccount);
-    if (hasOwnProperty(current.accounts, id)) return {...current.accounts[id]};
+    if (hasOwnProperty(current.accounts, id)) {
+      return Promise.resolve({...current.accounts[id]});
+    }
+    if (this.inFlight.has(id)) {
+      return this.inFlight.get(id)!;
+    }
+
     // It is possible to add emails to CC when they don't have a Gerrit
     // account. In this case getAccountDetails will return a 404 error then
     // we at least use what is in partialAccount.
-    const account = await this.restApiService.getAccountDetails(id, () => {
-      this.updateStateAccount(id, partialAccount);
-      return;
-    });
-    if (account) this.updateStateAccount(id, account);
-    return account ?? partialAccount;
+    const promise = this.restApiService
+      .getAccountDetails(id, () => {
+        this.updateStateAccount(id, partialAccount);
+      })
+      .then(account => {
+        this.inFlight.delete(id);
+        if (account) this.updateStateAccount(id, account);
+        return account ?? partialAccount;
+      })
+      .catch(err => {
+        this.inFlight.delete(id);
+        throw err;
+      });
+
+    this.inFlight.set(id, promise);
+    return promise;
   }
 
-  async fillDetails(account: AccountInfo) {
+  fillDetails(account: AccountInfo): Promise<AccountDetailInfo | AccountInfo> {
     if (!isDetailedAccount(account)) {
-      return await this.getAccount(account);
+      return this.getAccount(account);
     }
-    return account;
+    return Promise.resolve(account);
   }
 }
diff --git a/polygerrit-ui/app/models/accounts/accounts-model_test.ts b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
index e84723c..fce016b 100644
--- a/polygerrit-ui/app/models/accounts/accounts-model_test.ts
+++ b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
@@ -68,4 +68,21 @@
     model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
     assert.equal(getAccountDetails.callCount, 1);
   });
+
+  test('concurrent lookups for same account deduplicate requests', async () => {
+    const stub = stubRestApi('getAccountDetails').returns(
+      Promise.resolve(KERMIT)
+    );
+
+    const [a1, a2, a3] = await Promise.all([
+      model.fillDetails({_account_id: 1 as AccountId}),
+      model.fillDetails({_account_id: 1 as AccountId}),
+      model.fillDetails({_account_id: 1 as AccountId}),
+    ]);
+
+    assert.equal(a1.name, 'Kermit');
+    assert.equal(a2.name, 'Kermit');
+    assert.equal(a3.name, 'Kermit');
+    assert.equal(stub.callCount, 1);
+  });
 });
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index 52bb4d8..f241926 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -148,6 +148,30 @@
     });
   }
 
+  restoreChanges(
+    reason?: string,
+    // errorFn is needed to avoid showing an error dialog
+    errFn?: (changeNum: NumericChangeId) => void
+  ): Promise<Response>[] {
+    const current = this.getState();
+    return current.selectedChangeNums.map(changeNum => {
+      if (!current.allChanges.get(changeNum))
+        throw new Error('invalid change id');
+      const change = current.allChanges.get(changeNum)!;
+      if (change.status !== ChangeStatus.ABANDONED) {
+        return Promise.resolve(new Response());
+      }
+      return this.restApiService.executeChangeAction(
+        getChangeNumber(change),
+        change.actions!.restore!.method,
+        '/restore',
+        undefined,
+        {message: reason ?? ''},
+        () => errFn && errFn(getChangeNumber(change))
+      );
+    });
+  }
+
   voteChanges(reviewInput: ReviewInput) {
     const current = this.getState();
     return current.selectedChangeNums.map(changeNum => {
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index 5169305..aef87e7 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -213,7 +213,11 @@
       detailedActionsStub.returns(
         Promise.resolve([
           {...c1, actions: {abandon: {method: HttpMethod.POST}}},
-          {...c2, status: ChangeStatus.ABANDONED},
+          {
+            ...c2,
+            actions: {restore: {method: HttpMethod.POST}},
+            status: ChangeStatus.ABANDONED,
+          },
         ])
       );
 
@@ -235,6 +239,19 @@
         {message: ''},
       ]);
     });
+
+    test('restore only calls executeChangeAction for abandoned changes', () => {
+      const actionStub = stubRestApi('executeChangeAction').resolves();
+      bulkActionsModel.restoreChanges();
+      assert.equal(actionStub.callCount, 1);
+      assert.deepEqual(actionStub.lastCall.args.slice(0, 5), [
+        2 as NumericChangeId,
+        HttpMethod.POST,
+        '/restore',
+        undefined,
+        {message: ''},
+      ]);
+    });
   });
 
   suite('add reviewers', () => {
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 9bbd23c..bc45fcb 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {
+  AUTO_MERGE,
   BasePatchSetNum,
   ChangeInfo,
   ChangeViewChangeInfo,
@@ -11,6 +12,7 @@
   EDIT,
   EditInfo,
   FileInfo,
+  FIRST_PARENT,
   ListChangesOption,
   NumericChangeId,
   PARENT,
@@ -20,7 +22,11 @@
   RevisionInfo,
   RevisionPatchSetNum,
 } from '../../types/common';
-import {ChangeStatus, DefaultBase} from '../../constants/constants';
+import {
+  ChangeStatus,
+  createDefaultPreferences,
+  DefaultBase,
+} from '../../constants/constants';
 import {
   BehaviorSubject,
   combineLatest,
@@ -77,6 +83,7 @@
 import {Timing} from '../../constants/reporting';
 import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {throttleWrap} from '../../utils/async-util';
+import {RevisionInfo as RevisionInfoClass} from '../../elements/shared/revision-info/revision-info';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 
@@ -301,8 +308,10 @@
  * influence it. Mostly just returns `viewModelBasePatchNum` or PARENT, but has
  * some special logic when looking at merge commits.
  *
- * NOTE: At the moment this returns just `viewModelBasePatchNum ?? PARENT`, see
- * TODO below.
+ * `AUTO_MERGE` is normalized to `PARENT` here, so that it never escapes into
+ * the rest of the app. It only exists for distinguishing "the user has chosen
+ * the auto-merge base" from "the URL does not say anything about the base", so
+ * that only the latter is subject to the `default_base_for_merges` preference.
  */
 function computeBase(
   viewModelBasePatchNum: BasePatchSetNum | undefined,
@@ -310,7 +319,9 @@
   change: ParsedChangeInfo | undefined,
   preferences: PreferencesInfo
 ): BasePatchSetNum {
-  if (viewModelBasePatchNum && viewModelBasePatchNum !== PARENT) {
+  // Must be checked before the truthiness check below.
+  if (viewModelBasePatchNum === AUTO_MERGE) return PARENT;
+  if (viewModelBasePatchNum) {
     return viewModelBasePatchNum;
   }
   if (!change || !patchNum) return PARENT;
@@ -319,19 +330,30 @@
     preferences.default_base_for_merges === DefaultBase.FIRST_PARENT;
   if (!preferFirst) return PARENT;
 
-  // TODO: Re-enable respecting the default_base_for_merges preference.
-  // For the Polygerrit UI this was originally implemented in change 214432,
-  // but we are not sure whether this was ever 100% working correctly. A
-  // major challenge is being able to select PARENT explicitly even if your
-  // preference for the default choice is FIRST_PARENT. <gr-file-list-header>
-  // just uses `navigation.setUrl()` and the view model does not have any
-  // way of forcing the basePatchSetNum to stick to PARENT without being
-  // altered back to FIRST_PARENT here.
-  // See also corresponding TODO in gr-settings-view.
-  return PARENT;
-  // const revisionInfo = new RevisionInfo(change);
-  // const isMergeCommit = revisionInfo.isMergeCommit(patchNum);
-  // return isMergeCommit ? (-1 as PatchSetNumber) : PARENT;
+  const revisionInfo = new RevisionInfoClass(change);
+  const isMergeCommit = revisionInfo.isMergeCommit(patchNum);
+  return isMergeCommit ? FIRST_PARENT : PARENT;
+}
+
+/**
+ * The base to put into the URL of a change other than the current one, e.g.
+ * for the links of the relation chain. There is no user choice of base to
+ * preserve there, so this is only about spelling out what the
+ * `default_base_for_merges` preference picks anyway: an explicit base makes
+ * the link resolve to the same diff for everyone, regardless of what the
+ * recipient of the link has configured.
+ *
+ * Only merge commits have a choice of base, so `undefined` is returned for
+ * everything else, which leaves the base out of the URL.
+ */
+export function urlBaseForCommit(
+  isMergeCommit: boolean,
+  preferences: PreferencesInfo
+): BasePatchSetNum | undefined {
+  if (!isMergeCommit) return undefined;
+  return preferences.default_base_for_merges === DefaultBase.FIRST_PARENT
+    ? FIRST_PARENT
+    : AUTO_MERGE;
 }
 
 // TODO: Figure out how to best enforce immutability of all states. Use Immer?
@@ -954,13 +976,44 @@
     return this.getState().change;
   }
 
+  /**
+   * Whether leaving the base out of the URL would result in `PARENT`, i.e.
+   * whether the `default_base_for_merges` preference would not choose a
+   * different base.
+   */
+  private wouldDefaultToParent(patchNum = this.patchNum): boolean {
+    const preferences =
+      this.userModel.getState().preferences ?? createDefaultPreferences();
+    return (
+      computeBase(undefined, patchNum, this.change, preferences) === PARENT
+    );
+  }
+
+  /**
+   * Converts a base patchset number for putting it into a URL: `PARENT` must be
+   * encoded as `AUTO_MERGE`, if leaving it out of the URL would let the
+   * `default_base_for_merges` preference choose a different base. Otherwise the
+   * user's choice of the auto-merge base would not survive a page load.
+   */
+  urlBasePatchNum(
+    basePatchNum = this.basePatchNum,
+    patchNum = this.patchNum
+  ): BasePatchSetNum | undefined {
+    if (basePatchNum !== PARENT) return basePatchNum;
+    return this.wouldDefaultToParent(patchNum) ? PARENT : AUTO_MERGE;
+  }
+
   navigateToDiff(
     diffView: {path: string; lineNum?: number},
     patchNum = this.patchNum,
     basePatchNum = this.basePatchNum
   ) {
     if (!patchNum) return;
-    const url = this.viewModel.diffUrl({diffView, patchNum, basePatchNum});
+    const url = this.viewModel.diffUrl({
+      diffView,
+      patchNum,
+      basePatchNum: this.urlBasePatchNum(basePatchNum, patchNum),
+    });
     if (!url) return;
     this.navigation.setUrl(url);
   }
@@ -968,11 +1021,16 @@
   changeUrl(openReplyDialog = false) {
     if (!this.change) return;
     const isLatest = this.latestPatchNum === this.patchNum;
+    const basePatchNum = this.urlBasePatchNum();
+    // Numbered bases must be accompanied by a patchset, while `PARENT` is left
+    // out of the URL entirely and `AUTO_MERGE` is encoded as a lone `0`, so
+    // both of them can express "latest" by omitting the patchset.
+    const canOmitPatchNum =
+      isLatest && (basePatchNum === PARENT || basePatchNum === AUTO_MERGE);
     return createChangeUrl({
       change: this.change,
-      patchNum:
-        isLatest && this.basePatchNum === PARENT ? undefined : this.patchNum,
-      basePatchNum: this.basePatchNum,
+      patchNum: canOmitPatchNum ? undefined : this.patchNum,
+      basePatchNum,
       openReplyDialog,
     });
   }
@@ -1071,7 +1129,8 @@
         ? listChangesOptionsToHex(
             ListChangesOption.MESSAGES,
             ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.REVIEWER_UPDATES
+            ListChangesOption.REVIEWER_UPDATES,
+            ListChangesOption.SKIP_DIFFSTAT
           )
         : undefined
     )) as ChangeViewChangeInfo | undefined;
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index e415729..aa79b80 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -5,7 +5,11 @@
  */
 import * as sinon from 'sinon';
 import {Subject} from 'rxjs';
-import {ChangeStatus} from '../../constants/constants';
+import {
+  ChangeStatus,
+  createDefaultPreferences,
+  DefaultBase,
+} from '../../constants/constants';
 import '../../test/common-test-setup';
 import {
   createChange,
@@ -26,10 +30,12 @@
   waitUntilObserved,
 } from '../../test/test-utils';
 import {
+  AUTO_MERGE,
   BasePatchSetNum,
   ChangeInfo,
   CommitId,
   EDIT,
+  FIRST_PARENT,
   NumericChangeId,
   PARENT,
   PatchSetNum,
@@ -47,6 +53,7 @@
   RevisionFileUpdateStatus,
   updateChangeWithEdit,
   updateRevisionsWithCommitShas,
+  urlBaseForCommit,
 } from './change-model';
 import {ChangeModel} from './change-model';
 import {assert} from '@open-wc/testing';
@@ -711,7 +718,7 @@
     assert.deepEqual(getChangeStub.lastCall.args, [
       42 as NumericChangeId,
       undefined,
-      '80204',
+      '880204',
     ]);
     assert.deepEqual(result.newMessages, {
       ...createChangeMessageInfo(),
@@ -746,6 +753,127 @@
     assert.equal(spy.callCount, 2);
   });
 
+  suite('auto merge base', () => {
+    // `AUTO_MERGE` only exists for encoding an explicitly chosen auto-merge
+    // base in the URL, it must not reach the rest of the app.
+    test('basePatchNum$ normalizes AUTO_MERGE to PARENT', async () => {
+      changeViewModel.setState({
+        ...createChangeViewState(),
+        basePatchNum: 2 as BasePatchSetNum,
+        patchNum: 3 as PatchSetNumber,
+      });
+      await waitUntilObserved(
+        changeModel.basePatchNum$,
+        x => x === (2 as BasePatchSetNum)
+      );
+
+      changeViewModel.updateState({basePatchNum: AUTO_MERGE});
+      await waitUntilObserved(changeModel.basePatchNum$, x => x === PARENT);
+    });
+  });
+
+  suite('default base for merges', () => {
+    const ps1 = 1 as PatchSetNumber;
+    let mergeChange: ParsedChangeInfo;
+
+    setup(() => {
+      const revision = createRevision(1);
+      mergeChange = {
+        ...createParsedChange(),
+        revisions: {
+          sha1: {
+            ...revision,
+            commit: {
+              ...revision.commit!,
+              parents: [
+                {commit: 'p1' as CommitId, subject: 'parent 1'},
+                {commit: 'p2' as CommitId, subject: 'parent 2'},
+              ],
+            },
+          },
+        },
+      };
+      testResolver(userModelToken).setPreferences({
+        ...createDefaultPreferences(),
+        default_base_for_merges: DefaultBase.FIRST_PARENT,
+      });
+      // `basePatchNum$` only emits while the view model and the change agree on
+      // the change number.
+      changeViewModel.setState({
+        ...createChangeViewState(),
+        changeNum: mergeChange._number,
+        basePatchNum: PARENT,
+        patchNum: ps1,
+      });
+    });
+
+    test('basePatchNum$ applies the preference for merges', async () => {
+      changeViewModel.updateState({basePatchNum: undefined});
+      changeModel.updateStateChange(mergeChange);
+      await waitUntilObserved(
+        changeModel.basePatchNum$,
+        x => x === FIRST_PARENT
+      );
+    });
+
+    test('basePatchNum$ keeps an explicitly chosen PARENT', async () => {
+      changeModel.updateStateChange(mergeChange);
+      await waitUntilObserved(changeModel.basePatchNum$, x => x === PARENT);
+    });
+
+    test('urlBasePatchNum encodes PARENT as AUTO_MERGE for merges', () => {
+      changeModel.updateStateChange(mergeChange);
+      assert.equal(changeModel.urlBasePatchNum(PARENT, ps1), AUTO_MERGE);
+      // Other bases are never overridden, so they are not affected.
+      assert.equal(
+        changeModel.urlBasePatchNum(FIRST_PARENT, ps1),
+        FIRST_PARENT
+      );
+      assert.equal(
+        changeModel.urlBasePatchNum(2 as BasePatchSetNum, ps1),
+        2 as BasePatchSetNum
+      );
+    });
+
+    test('urlBasePatchNum keeps PARENT for non-merges', () => {
+      changeModel.updateStateChange(createParsedChange());
+      assert.equal(changeModel.urlBasePatchNum(PARENT, ps1), PARENT);
+    });
+
+    // A lone `0` says "auto merge against the latest patchset", so the patchset
+    // does not have to be pinned for the base to survive a page load.
+    test('changeUrl encodes an auto-merge base as 0', () => {
+      changeModel.updateStateChange(mergeChange);
+      assert.equal(changeModel.changeUrl(), '/c/test-project/+/42/0');
+    });
+
+    test('changeUrl omits the base for non-merges', () => {
+      changeModel.updateStateChange(createParsedChange());
+      assert.equal(changeModel.changeUrl(), '/c/test-project/+/42');
+    });
+  });
+
+  suite('urlBaseForCommit', () => {
+    const firstParent = {
+      ...createDefaultPreferences(),
+      default_base_for_merges: DefaultBase.FIRST_PARENT,
+    };
+    const autoMerge = {
+      ...createDefaultPreferences(),
+      default_base_for_merges: DefaultBase.AUTO_MERGE,
+    };
+
+    test('spells out the base of a merge commit', () => {
+      assert.equal(urlBaseForCommit(true, firstParent), FIRST_PARENT);
+      assert.equal(urlBaseForCommit(true, autoMerge), AUTO_MERGE);
+    });
+
+    test('leaves the base out for a single parent commit', () => {
+      assert.isUndefined(urlBaseForCommit(false, firstParent));
+      assert.isUndefined(urlBaseForCommit(false, autoMerge));
+    });
+  });
+
   test('revision$ selector latest', async () => {
     changeViewModel.updateState({patchNum: undefined});
     changeModel.updateState({change: knownChange});
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 4e0af0a..e20f353 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -5,6 +5,7 @@
  */
 import {
   BasePatchSetNum,
+  EDIT,
   FileInfo,
   FileNameToFileInfoMap,
   PARENT,
@@ -12,12 +13,13 @@
   PatchSetNumber,
   RevisionPatchSetNum,
 } from '../../types/common';
-import {combineLatest, from, Observable, of} from 'rxjs';
+import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
 import {map, switchMap} from 'rxjs/operators';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {select} from '../../utils/observable-util';
 import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
 import {specialFilePathCompare} from '../../utils/path-list-util';
+import {RevisionInfo as RevisionInfoClass} from '../../elements/shared/revision-info/revision-info';
 import {Model} from '../base/model';
 import {define} from '../dependency';
 import {ChangeModel} from './change-model';
@@ -133,12 +135,24 @@
    * Empty if the left chosen patchset is PARENT.
    */
   filesRightBase: NormalizedFileInfo[];
+
+  /**
+   * For merge commits vs Auto Merge, paths of files that merged cleanly.
+   */
+  cleanlyMergedPaths: string[];
+
+  /**
+   * Old paths of cleanly merged files (for renamed files).
+   */
+  cleanlyMergedOldPaths: string[];
 }
 
 const initialState: FilesState = {
   files: [],
   filesLeftBase: [],
   filesRightBase: [],
+  cleanlyMergedPaths: [],
+  cleanlyMergedOldPaths: [],
 };
 
 export const filesModelToken = define<FilesModel>('files-model');
@@ -162,6 +176,16 @@
 
   public readonly filesRightBase$;
 
+  public readonly cleanlyMergedPaths$ = select(
+    this.state$,
+    state => state.cleanlyMergedPaths
+  );
+
+  public readonly cleanlyMergedOldPaths$ = select(
+    this.state$,
+    state => state.cleanlyMergedOldPaths
+  );
+
   constructor(
     readonly changeModel: ChangeModel,
     readonly commentsModel: CommentsModel,
@@ -214,6 +238,7 @@
           return {filesRightBase: [...files]};
         }
       ),
+      this.subscribeToCleanlyMergedPaths(),
     ];
   }
 
@@ -265,4 +290,60 @@
         this.updateState(state);
       });
   }
+
+  private subscribeToCleanlyMergedPaths() {
+    return combineLatest([
+      this.changeModel.change$,
+      this.changeModel.changeNum$,
+      this.changeModel.basePatchNum$,
+      this.changeModel.patchNum$,
+    ])
+      .pipe(
+        switchMap(([change, changeNum, basePatchNum, patchNum]) => {
+          if (
+            !change ||
+            !changeNum ||
+            !patchNum ||
+            !new RevisionInfoClass(change).isMergeCommit(patchNum) ||
+            basePatchNum !== PARENT ||
+            patchNum === EDIT
+          ) {
+            return of({cleanlyMergedPaths: [], cleanlyMergedOldPaths: []});
+          }
+          return forkJoin([
+            from(
+              this.restApiService.getChangeOrEditFiles(changeNum, {
+                basePatchNum: -1 as BasePatchSetNum,
+                patchNum,
+              })
+            ),
+            from(
+              this.restApiService.getChangeOrEditFiles(changeNum, {
+                basePatchNum: PARENT,
+                patchNum,
+              })
+            ),
+          ]).pipe(
+            map(([allFilesByPath, conflictingFilesByPath]) => {
+              if (!allFilesByPath) {
+                return {cleanlyMergedPaths: [], cleanlyMergedOldPaths: []};
+              }
+              const conflictingPaths = Object.keys(
+                conflictingFilesByPath ?? {}
+              );
+              const cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
+                path => !conflictingPaths.includes(path)
+              );
+              const cleanlyMergedOldPaths = cleanlyMergedPaths
+                .map(path => allFilesByPath[path].old_path)
+                .filter((oldPath): oldPath is string => !!oldPath);
+              return {cleanlyMergedPaths, cleanlyMergedOldPaths};
+            })
+          );
+        })
+      )
+      .subscribe(state => {
+        this.updateState(state);
+      });
+  }
 }
diff --git a/polygerrit-ui/app/models/change/files-model_test.ts b/polygerrit-ui/app/models/change/files-model_test.ts
new file mode 100644
index 0000000..054c5f5
--- /dev/null
+++ b/polygerrit-ui/app/models/change/files-model_test.ts
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import * as sinon from 'sinon';
+import '../../test/common-test-setup';
+import {assert} from '@open-wc/testing';
+import {FilesModel} from './files-model';
+import {ChangeModel} from './change-model';
+import {createDefaultPreferences} from '../../constants/constants';
+import {
+  createChangeViewState,
+  createParsedChange,
+  createRevision,
+  TEST_NUMERIC_CHANGE_ID,
+} from '../../test/test-data-generators';
+import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
+import {
+  CommitId,
+  FileNameToFileInfoMap,
+  PARENT,
+  RevisionPatchSetNum,
+} from '../../types/common';
+import {getAppContext} from '../../services/app-context';
+import {testResolver} from '../../test/common-test-setup';
+import {ChangeViewModel, changeViewModelToken} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import {userModelToken} from '../user/user-model';
+import {commentsModelToken} from '../comments/comments-model';
+import {checksModelToken} from '../checks/checks-model';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+
+suite('files-model tests', () => {
+  let changeModel: ChangeModel;
+  let changeViewModel: ChangeViewModel;
+  let filesModel: FilesModel;
+
+  setup(() => {
+    stubRestApi('getAllRevisionFiles').resolves({});
+    stubRestApi('getChangeDetail').callsFake(() => new Promise(() => {}));
+    stubRestApi('getChangeEdit').resolves(undefined);
+    testResolver(userModelToken).setPreferences(createDefaultPreferences());
+    changeViewModel = testResolver(changeViewModelToken);
+    changeModel = new ChangeModel(
+      testResolver(navigationToken),
+      changeViewModel,
+      getAppContext().restApiService,
+      testResolver(userModelToken),
+      testResolver(pluginLoaderToken),
+      getAppContext().reportingService
+    );
+    filesModel = new FilesModel(
+      changeModel,
+      testResolver(commentsModelToken),
+      testResolver(checksModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+  });
+
+  teardown(() => {
+    filesModel.finalize();
+    changeModel.finalize();
+  });
+
+  test('cleanly merged paths for merge commit', async () => {
+    stubRestApi('getChangeOrEditFiles').callsFake((_changeNum, range) => {
+      if (range?.basePatchNum === -1) {
+        return Promise.resolve({
+          'conflict.txt': {},
+          'cleanlyMerged.txt': {old_path: 'cleanlyMergedOld.txt'},
+        } as FileNameToFileInfoMap);
+      }
+      return Promise.resolve({
+        'conflict.txt': {},
+      } as FileNameToFileInfoMap);
+    });
+
+    const revision = createRevision(1);
+    const mergeCommit = {
+      ...revision.commit!,
+      parents: [
+        {commit: 'p1' as CommitId, subject: 'parent 1'},
+        {commit: 'p2' as CommitId, subject: 'parent 2'},
+      ],
+    };
+
+    const change = {
+      ...createParsedChange(),
+      _number: TEST_NUMERIC_CHANGE_ID,
+      revisions: {
+        sha1: {
+          ...revision,
+          commit: mergeCommit,
+        },
+      },
+      current_revision: 'sha1' as CommitId,
+    };
+
+    changeViewModel.setState({
+      ...createChangeViewState(),
+      changeNum: TEST_NUMERIC_CHANGE_ID,
+      patchNum: 1 as RevisionPatchSetNum,
+      basePatchNum: PARENT,
+    });
+    changeModel.updateStateChange(change);
+
+    const cleanlyMergedPaths = await waitUntilObserved(
+      filesModel.cleanlyMergedPaths$,
+      paths => paths.length > 0
+    );
+    assert.deepEqual(cleanlyMergedPaths, ['cleanlyMerged.txt']);
+
+    const cleanlyMergedOldPaths = await waitUntilObserved(
+      filesModel.cleanlyMergedOldPaths$,
+      paths => paths.length > 0
+    );
+    assert.deepEqual(cleanlyMergedOldPaths, ['cleanlyMergedOld.txt']);
+  });
+
+  test('non-merge commit does not query -1 base', async () => {
+    const getChangeOrEditFilesStub = stubRestApi(
+      'getChangeOrEditFiles'
+    ).resolves({
+      'file1.txt': {},
+    } as FileNameToFileInfoMap);
+
+    const revision = createRevision(1);
+    const singleParentCommit = {
+      ...revision.commit!,
+      parents: [{commit: 'p1' as CommitId, subject: 'parent 1'}],
+    };
+
+    const change = {
+      ...createParsedChange(),
+      _number: TEST_NUMERIC_CHANGE_ID,
+      revisions: {
+        sha1: {
+          ...revision,
+          commit: singleParentCommit,
+        },
+      },
+      current_revision: 'sha1' as CommitId,
+    };
+
+    changeViewModel.setState({
+      ...createChangeViewState(),
+      changeNum: TEST_NUMERIC_CHANGE_ID,
+      patchNum: 1 as RevisionPatchSetNum,
+      basePatchNum: PARENT,
+    });
+    changeModel.updateStateChange(change);
+
+    const files = await waitUntilObserved(filesModel.files$, f => f.length > 0);
+    assert.equal(files.length, 1);
+
+    // Verify getChangeOrEditFiles was not called with basePatchNum: -1
+    assert.isFalse(
+      getChangeOrEditFilesStub.calledWith(
+        TEST_NUMERIC_CHANGE_ID,
+        sinon.match({
+          basePatchNum: -1,
+        })
+      )
+    );
+
+    const cleanlyMergedPaths = await waitUntilObserved(
+      filesModel.cleanlyMergedPaths$,
+      paths => paths.length === 0
+    );
+    assert.deepEqual(cleanlyMergedPaths, []);
+  });
+});
diff --git a/polygerrit-ui/app/models/chat/chat-model.ts b/polygerrit-ui/app/models/chat/chat-model.ts
index 619424b..781f67d 100644
--- a/polygerrit-ui/app/models/chat/chat-model.ts
+++ b/polygerrit-ui/app/models/chat/chat-model.ts
@@ -37,6 +37,8 @@
 import {contextItemEquals} from './context-item-util';
 import {FilesModel, NormalizedFileInfo} from '../change/files-model';
 import {isMagicPath} from '../../utils/path-list-util';
+import {getAppContext} from '../../services/app-context';
+import {Interaction, Timing} from '../../constants/reporting';
 
 /** The available display modes in the chat panel. */
 export enum ChatPanelMode {
@@ -228,6 +230,10 @@
   },
 };
 
+export interface ChatTriggerParams {
+  prompt?: string;
+}
+
 export const chatModelToken = define<ChatModel>('chat-model');
 
 export class ChatModel extends Model<ChatState> {
@@ -616,6 +622,19 @@
         });
       },
       emitError: (errorMessage: string) => {
+        getAppContext().reportingService.timeEnd(Timing.AI_CHAT_REQUEST, {
+          modelName: request.model_name,
+          actionId: action.id,
+          error: errorMessage,
+        });
+        getAppContext().reportingService.reportInteraction(
+          Interaction.AI_CHAT_FAILURE,
+          {
+            modelName: request.model_name,
+            actionId: action.id,
+            error: errorMessage,
+          }
+        );
         const state = this.getState();
         if (state.id !== conversationId) return;
         const turns: readonly Turn[] = state.turns;
@@ -630,6 +649,10 @@
         });
       },
       done: () => {
+        getAppContext().reportingService.timeEnd(Timing.AI_CHAT_REQUEST, {
+          modelName: request.model_name,
+          actionId: action.id,
+        });
         const state = this.getState();
         if (state.id !== conversationId) return;
         assert(turnIndex < state.turns.length, 'turn index out of bounds');
@@ -642,6 +665,7 @@
         });
       },
     };
+    getAppContext().reportingService.time(Timing.AI_CHAT_REQUEST);
     this.plugin?.chat?.(request, listener);
   }
 
@@ -700,6 +724,12 @@
     if (userInput) this.sendChatRequest(0);
   }
 
+  processChatRequest(params: ChatTriggerParams) {
+    if (params.prompt) {
+      this.startNewChatWithUserInput(params.prompt, undefined, [], false);
+    }
+  }
+
   addContextItem(contextItem: ContextItem) {
     const state = this.getState();
     const currentItems = state.draftUserMessage.contextItems;
diff --git a/polygerrit-ui/app/models/chat/chat-model_test.ts b/polygerrit-ui/app/models/chat/chat-model_test.ts
index 9f70830..c8d4fc0 100644
--- a/polygerrit-ui/app/models/chat/chat-model_test.ts
+++ b/polygerrit-ui/app/models/chat/chat-model_test.ts
@@ -16,6 +16,8 @@
 
 import sinon from 'sinon';
 import {ParsedChangeInfo} from '../../types/types';
+import {getAppContext} from '../../services/app-context';
+import {Interaction, Timing} from '../../constants/reporting';
 
 suite('chat-model tests', () => {
   let model: ChatModel;
@@ -345,4 +347,107 @@
     const state = model.getState();
     assert.equal(state.turns[0].geminiMessage.regenerationIndex, 0);
   });
+
+  test('processChatRequest delegates to startNewChatWithUserInput', () => {
+    const startNewChatStub = sinon.stub(model, 'startNewChatWithUserInput');
+    model.processChatRequest({prompt: 'Explain this code'});
+    assert.isTrue(
+      startNewChatStub.calledOnceWith('Explain this code', undefined, [], false)
+    );
+  });
+
+  suite('telemetry reporting', () => {
+    let timeStub: sinon.SinonStub;
+    let timeEndStub: sinon.SinonStub;
+    let reportInteractionStub: sinon.SinonStub;
+
+    setup(() => {
+      timeStub = sinon.stub(getAppContext().reportingService, 'time');
+      timeEndStub = sinon.stub(getAppContext().reportingService, 'timeEnd');
+      reportInteractionStub = sinon.stub(
+        getAppContext().reportingService,
+        'reportInteraction'
+      );
+
+      // Set up a change, models, and actions
+      const models = {
+        models: [
+          {
+            model_id: 'test-model',
+            full_display_text: 'Test Model',
+            short_text: 'Test',
+          },
+        ],
+        default_model_id: 'test-model',
+      };
+      const actions = {
+        actions: [
+          {
+            id: 'test-action',
+            display_text: 'Test Action',
+            initial_user_prompt: 'Test Prompt',
+          },
+        ],
+        default_action_id: 'test-action',
+      };
+      (provider.getActions as sinon.SinonStub).resolves(actions);
+      (provider.getModels as sinon.SinonStub).resolves(models);
+
+      changeModel.updateStateChange(createParsedChange());
+    });
+
+    test('chat request starts a timer', async () => {
+      await new Promise(resolve => setTimeout(resolve, 0));
+
+      model.updateUserInput('hello');
+      model.chat('hello', 'test-action', 0);
+
+      assert.isTrue(timeStub.calledOnceWith(Timing.AI_CHAT_REQUEST));
+    });
+
+    test('chat request success stops the timer', async () => {
+      await new Promise(resolve => setTimeout(resolve, 0));
+
+      (provider.chat as sinon.SinonStub).callsFake((_, listener) => {
+        listener.done();
+      });
+
+      model.updateUserInput('hello');
+      model.chat('hello', 'test-action', 0);
+
+      assert.isTrue(
+        timeEndStub.calledOnceWith(Timing.AI_CHAT_REQUEST, {
+          modelName: 'test-model',
+          actionId: 'test-action',
+        })
+      );
+    });
+
+    test('chat request failure stops the timer and logs interaction', async () => {
+      await new Promise(resolve => setTimeout(resolve, 0));
+
+      (provider.chat as sinon.SinonStub).callsFake((_, listener) => {
+        listener.emitError('some error');
+      });
+
+      model.updateUserInput('hello');
+      model.chat('hello', 'test-action', 0);
+
+      assert.isTrue(
+        timeEndStub.calledOnceWith(Timing.AI_CHAT_REQUEST, {
+          modelName: 'test-model',
+          actionId: 'test-action',
+          error: 'some error',
+        })
+      );
+
+      assert.isTrue(
+        reportInteractionStub.calledOnceWith(Interaction.AI_CHAT_FAILURE, {
+          modelName: 'test-model',
+          actionId: 'test-action',
+          error: 'some error',
+        })
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index c986efe..c05265e 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -321,10 +321,19 @@
     );
     this.checksLatest$ = select(this.state$, state => state.pluginStateLatest);
     this.checksSelected$ = select(
-      combineLatest([this.state$, this.changeViewModel.checksPatchset$]),
-      ([state, ps]) => {
-        const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST;
-        return this.getPluginState(state, checksPs);
+      combineLatest([
+        this.state$,
+        this.changeViewModel.checksPatchset$,
+        this.changeModel.latestPatchNum$,
+      ]),
+      ([state, ps, latestPs]) => {
+        // When no distinct patchset is selected SELECTED fetch is skipped
+        // (see initFetchingOfData), so fall back to the LATEST state here.
+        const checksPs =
+          ps && ps !== latestPs
+            ? ChecksPatchset.SELECTED
+            : ChecksPatchset.LATEST;
+        return this.readPluginState(state, checksPs);
       }
     );
     this.aPluginHasRegistered$ = select(
@@ -579,6 +588,19 @@
     this.setState(nextState);
   }
 
+  // Read only. Use in reactive selectors where state must not be modified
+  // between emissions.
+  private readPluginState(
+    state: ChecksState,
+    patchset: ChecksPatchset = ChecksPatchset.LATEST
+  ) {
+    return patchset === ChecksPatchset.LATEST
+      ? state.pluginStateLatest
+      : state.pluginStateSelected;
+  }
+
+  // Shallow-copies the inner plugin map onto state and returns it, so callers
+  // can safely assign new entries before passing state to setState().
   getPluginState(
     state: ChecksState,
     patchset: ChecksPatchset = ChecksPatchset.LATEST
@@ -631,7 +653,7 @@
     pluginState[pluginName] = {
       ...pluginState[pluginName],
       loading: false,
-      firstTimeLoad: false,
+      firstTimeLoad: true,
       errorMessage: undefined,
       loginCallback,
       runs: [],
@@ -865,29 +887,54 @@
         this.reloadSubjects[pluginName],
         pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
         this.documentVisibilityChange$,
+        // Only the SELECTED subscription needs the latest patchset here, to
+        // detect when it coincides with the latest (see below). The LATEST
+        // subscription already has it as its second element, so feeding it in
+        // again would make combineLatest emit twice per change.
+        patchset === ChecksPatchset.SELECTED
+          ? this.changeModel.latestPatchNum$
+          : of(undefined),
       ])
         .pipe(
           takeWhile(_ => !!this.providers[pluginName]),
           filter(_ => document.visibilityState !== 'hidden'),
           throttleTime(500, undefined, {leading: true, trailing: true}),
-          switchMap(([change, patchNum]): Observable<FetchResponse> => {
-            if (!change || !patchNum) return of(this.empty());
-            if (typeof patchNum !== 'number') return of(this.empty());
-            assertIsDefined(change.revisions, 'change.revisions');
-            const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
-            // Sometimes patchNum is updated earlier than change, so change
-            // revisions don't have patchNum yet
-            if (!patchsetSha) return of(this.empty());
-            const data: ChangeData = {
-              changeNumber: change?._number,
-              patchsetNumber: patchNum,
-              patchsetSha,
-              repo: change.project,
-              commitMessage: getCurrentRevision(change)?.commit?.message,
-              changeInfo: change as ChangeInfo,
-            };
-            return this.fetchResults(pluginName, data, patchset);
-          }),
+          switchMap(
+            ([
+              change,
+              patchNum,
+              ,
+              ,
+              ,
+              latestPatchNum,
+            ]): Observable<FetchResponse> => {
+              if (!change || !patchNum) return of(this.empty());
+              if (typeof patchNum !== 'number') return of(this.empty());
+              // Skip the duplicate fetch when the selected patchset is the
+              // latest: the LATEST subscription already fetches it and
+              // checksSelected$ falls back to that state.
+              if (
+                patchset === ChecksPatchset.SELECTED &&
+                patchNum === latestPatchNum
+              ) {
+                return of(this.empty());
+              }
+              assertIsDefined(change.revisions, 'change.revisions');
+              const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+              // Sometimes patchNum is updated earlier than change, so change
+              // revisions don't have patchNum yet
+              if (!patchsetSha) return of(this.empty());
+              const data: ChangeData = {
+                changeNumber: change?._number,
+                patchsetNumber: patchNum,
+                patchsetSha,
+                repo: change.project,
+                commitMessage: getCurrentRevision(change)?.commit?.message,
+                changeInfo: change as ChangeInfo,
+              };
+              return this.fetchResults(pluginName, data, patchset);
+            }
+          ),
           catchError(e => {
             // This should not happen and is really severe, because it means that
             // the Observable has terminated and we won't recover from that. No
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 94b3d0f..294be41 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -17,6 +17,7 @@
 import {
   Action,
   Category,
+  ChangeData,
   CheckRun,
   ChecksApiConfig,
   ChecksProvider,
@@ -27,9 +28,11 @@
 import {
   createCheckResult,
   createParsedChange,
+  createRevisions,
   createRun,
+  getCurrentRevision,
 } from '../../test/test-data-generators';
-import {waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {waitEventLoop, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {ParsedChangeInfo} from '../../types/types';
 import {
   changeModelToken,
@@ -38,7 +41,11 @@
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
 import {changeViewModelToken} from '../views/change';
-import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
+import {
+  NumericChangeId,
+  PatchSetNumber,
+  RevisionPatchSetNum,
+} from '../../api/rest-api';
 import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {deepEqual} from '../../utils/deep-util';
 
@@ -79,6 +86,29 @@
   };
 }
 
+/**
+ * A provider that echoes back the patchset it was asked to fetch, so that tests
+ * can assert which patchset a tab is populated with.
+ */
+function createPatchsetTaggingProvider(): ChecksProvider {
+  return {
+    fetch: (data: ChangeData) =>
+      Promise.resolve({
+        responseCode: ResponseCode.OK,
+        runs: [createRun({patchset: data.patchsetNumber})],
+      }),
+  };
+}
+
+/** A change with two patchsets, so latest (2) and older (1) are distinct. */
+function createTwoPatchsetChange(): ParsedChangeInfo {
+  return updateRevisionsWithCommitShas({
+    ...createParsedChange(),
+    revisions: createRevisions(2),
+    current_revision: getCurrentRevision(1),
+  })!;
+}
+
 suite('checks-model tests', () => {
   let model: ChecksModel;
 
@@ -130,6 +160,130 @@
     clock.restore();
   });
 
+  test('no duplicate fetch when viewing latest patchset (no selection)', async () => {
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    let latestRuns: CheckRun[] = [];
+    let selectedRuns: CheckRun[] = [];
+    model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+    model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+    const provider = createPatchsetTaggingProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: PLUGIN_NAME,
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    // Viewing the latest patchset (2), no explicit checks patchset override.
+    testResolver(changeViewModelToken).updateState({
+      patchNum: 2 as RevisionPatchSetNum,
+    });
+    const testChange = createTwoPatchsetChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => deepEqual(change, testChange));
+
+    // Fire the throttled emission, then flush the fetch promise into state.
+    clock.tick(600);
+    await waitEventLoop();
+
+    // The SELECTED patchset equals LATEST, so only a single fetch is needed.
+    assert.equal(fetchSpy.callCount, 1);
+    // Both tabs show data for the latest patchset (2).
+    assert.isNotEmpty(latestRuns);
+    assert.isNotEmpty(selectedRuns);
+    assert.isTrue(latestRuns.every(r => r.patchset === 2));
+    assert.isTrue(selectedRuns.every(r => r.patchset === 2));
+
+    clock.restore();
+  });
+
+  test('no duplicate fetch when latest patchset is explicitly selected', async () => {
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    let latestRuns: CheckRun[] = [];
+    let selectedRuns: CheckRun[] = [];
+    model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+    model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+    const provider = createPatchsetTaggingProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: PLUGIN_NAME,
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    // Viewing patchset 1 but explicitly selecting the latest patchset (2) in the
+    // checks tab. checksPatchset differs from patchNum, so it is not reset.
+    testResolver(changeViewModelToken).updateState({
+      patchNum: 1 as RevisionPatchSetNum,
+      checksPatchset: 2 as PatchSetNumber,
+    });
+    const testChange = createTwoPatchsetChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => deepEqual(change, testChange));
+
+    // Fire the throttled emission, then flush the fetch promise into state.
+    clock.tick(600);
+    await waitEventLoop();
+
+    // Selected patchset (2) equals latest, so still only a single fetch.
+    assert.equal(fetchSpy.callCount, 1);
+    assert.isNotEmpty(latestRuns);
+    assert.isNotEmpty(selectedRuns);
+    assert.isTrue(latestRuns.every(r => r.patchset === 2));
+    assert.isTrue(selectedRuns.every(r => r.patchset === 2));
+
+    clock.restore();
+  });
+
+  test('fetches both patchsets when an older one is selected', async () => {
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    let latestRuns: CheckRun[] = [];
+    let selectedRuns: CheckRun[] = [];
+    model.allRunsLatestPatchset$.subscribe(r => (latestRuns = r));
+    model.allRunsSelectedPatchset$.subscribe(r => (selectedRuns = r));
+    const provider = createPatchsetTaggingProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: PLUGIN_NAME,
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    // Explicitly selecting the older patchset (1); latest is 2.
+    testResolver(changeViewModelToken).updateState({
+      checksPatchset: 1 as PatchSetNumber,
+    });
+    const testChange = createTwoPatchsetChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => deepEqual(change, testChange));
+
+    // Fire the throttled emission, then flush the fetch promise into state.
+    clock.tick(600);
+    await waitEventLoop();
+
+    // Distinct patchsets require two fetches: one for latest, one for selected.
+    assert.equal(fetchSpy.callCount, 2);
+    // The latest tab shows patchset 2, the selected tab shows the older 1.
+    assert.isNotEmpty(latestRuns);
+    assert.isNotEmpty(selectedRuns);
+    assert.isTrue(latestRuns.every(r => r.patchset === 2));
+    assert.isTrue(selectedRuns.every(r => r.patchset === 1));
+
+    clock.restore();
+  });
+
   test('fetch throttle', async () => {
     const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     let change: ParsedChangeInfo | undefined = undefined;
@@ -200,6 +354,20 @@
     });
   });
 
+  test('checksSelected$ selector does not mutate state', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    const pluginStateLatestRef = model.getState().pluginStateLatest;
+
+    // Fires the selector synchronously via BehaviorSubject replay.
+    model.checksSelected$.subscribe(() => {});
+
+    assert.strictEqual(
+      model.getState().pluginStateLatest,
+      pluginStateLatestRef,
+      'checksSelected$ must not mutate state.pluginStateLatest'
+    );
+  });
+
   test('loading and first time load', () => {
     model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
     assert.isFalse(current.loading);
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index f997b11..239059b 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -23,6 +23,13 @@
 import {DraftInfo, FixSuggestionInfo} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
+import {
+  AiAgentChatEventDetails,
+  AiAgentCheckEventDetails,
+  AiAgentEventDetails,
+  Interaction,
+} from '../../constants/reporting';
+import {ReportingService as Reporting} from '../../services/gr-reporting/gr-reporting';
 import {createNew, PROVIDED_FIX_ID} from '../../utils/comment-util';
 import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
@@ -93,10 +100,12 @@
   }
 }
 
-function pleaseFixMessage(result: RunResult) {
-  return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
-
-${result.message}`;
+export function pleaseFixMessage(result: RunResult) {
+  const message =
+    result.summary === result.message
+      ? result.message
+      : `${result.summary}\n\n${result.message}`;
+  return `Please fix this ${result.category} reported by ${result.checkName}: ${message}`;
 }
 
 /**
@@ -592,3 +601,116 @@
   const hasFixes = (result?.fixes ?? []).length > 0;
   return hasMessage || hasMultipleLinks || hasPointers || hasFixes;
 }
+
+function getAiAgentChatEventDetails(
+  runResult: RunResult,
+  commentId?: string
+): AiAgentChatEventDetails | undefined {
+  const externalId = runResult.externalId;
+  if (!externalId) return;
+  // Use JSON.parse. We expect agentId, conversationId, turnIndex.
+  try {
+    const parsed = JSON.parse(externalId);
+    const agentId = parsed['agentId'];
+    const conversationId = parsed['conversationId'];
+    const turnIndex = parsed['turnIndex'];
+    const suggestionId = parsed['suggestionId'];
+    if (
+      !agentId ||
+      !conversationId ||
+      turnIndex === null ||
+      turnIndex === undefined
+    ) {
+      return;
+    }
+    /* eslint-disable object-shorthand */
+    // prettier-ignore
+    const eventDetails: AiAgentChatEventDetails = {
+      'agentId': agentId,
+      'conversationId': conversationId,
+      'turnIndex': Number(turnIndex),
+      'suggestionId': suggestionId,
+      'commentId': commentId,
+    };
+    return eventDetails;
+    /* eslint-enable object-shorthand */
+  } catch (e) {
+    return undefined;
+  }
+}
+
+function getAiAgentCheckEventDetails(
+  runResult: RunResult,
+  commentId?: string
+): AiAgentCheckEventDetails | undefined {
+  if (!runResult.externalId) return;
+  if (!runResult.isAiPowered) return;
+  if (!runResult.checkName) return;
+  if (!runResult.checkDescription) return;
+
+  /* eslint-disable object-shorthand */
+  // prettier-ignore
+  const eventDetails: AiAgentCheckEventDetails = {
+    'checkName': runResult.checkName,
+    'checkDescription': runResult.checkDescription,
+    'externalId': runResult.externalId,
+    'commentId': commentId,
+  };
+  return eventDetails;
+  /* eslint-enable object-shorthand */
+}
+
+function getAiAgentEventDetails(
+  runResult: RunResult,
+  commentId?: string
+): AiAgentEventDetails | undefined {
+  const chatDetails = getAiAgentChatEventDetails(runResult, commentId);
+  if (chatDetails) return chatDetails;
+  return getAiAgentCheckEventDetails(runResult, commentId);
+}
+
+/**
+ * Reports a "Get AI Fix" click interaction on a Code Review Agent check.
+ */
+export function reportAiAgentGetAIFix(
+  reporting: Reporting,
+  runResult: RunResult
+) {
+  const eventDetails = getAiAgentEventDetails(runResult);
+  if (!eventDetails) return;
+  reporting.reportInteraction(
+    Interaction.AI_AGENT_GET_FIX_CLICKED,
+    eventDetails
+  );
+}
+
+/**
+ * Reports a "Please Fix" click interaction on a Code Review Agent check.
+ */
+export function reportAiAgentCommentDraft(
+  reporting: Reporting,
+  runResult: RunResult,
+  commentId?: string
+) {
+  const eventDetails = getAiAgentEventDetails(runResult, commentId);
+  if (!eventDetails) return;
+  reporting.reportInteraction(
+    Interaction.AI_AGENT_SUGGESTION_TO_COMMENT,
+    eventDetails
+  );
+}
+
+/**
+ * Reports a "manual copy" interaction on a Code Review Agent check.
+ */
+export function reportAiAgentSuggestionCopy(
+  reporting: Reporting,
+  runResult: RunResult
+) {
+  const eventDetails = getAiAgentEventDetails(runResult);
+  if (!eventDetails) return;
+  reporting.reportInteraction(
+    Interaction.AI_AGENT_SUGGESTION_CONTENT_COPIED,
+    eventDetails
+  );
+}
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index e88721a..7359381 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -11,14 +11,19 @@
   AttemptChoice,
   computeIsExpandable,
   LATEST_ATTEMPT,
+  pleaseFixMessage,
   rectifyFix,
+  reportAiAgentCommentDraft,
+  reportAiAgentGetAIFix,
   sortAttemptChoices,
   stringToAttemptChoice,
   toComment,
 } from './checks-util';
-import {Fix, Replacement} from '../../api/checks';
+import {Interaction} from '../../constants/reporting';
+import {Category, Fix, Replacement} from '../../api/checks';
 import {PROVIDED_FIX_ID} from '../../utils/comment-util';
 import {CommentRange, RevisionPatchSetNum} from '../../api/rest-api';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {
   createCheckFix,
   createCheckLink,
@@ -245,4 +250,206 @@
       assert.isUndefined(comment.line);
     });
   });
+
+  suite('AI agent reporting', () => {
+    let reportInteractionStub: sinon.SinonStub;
+
+    setup(() => {
+      reportInteractionStub = sinon.stub();
+    });
+
+    test('reportAiAgentGetAIFix', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: JSON.stringify({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 1,
+          suggestionId: 1,
+        }),
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
+
+      assert.isTrue(reportInteractionStub.calledOnce);
+      assert.equal(
+        reportInteractionStub.lastCall.args[0],
+        Interaction.AI_AGENT_GET_FIX_CLICKED
+      );
+      assert.deepEqual(reportInteractionStub.lastCall.args[1], {
+        agentId: 'test-agent',
+        conversationId: 'test-conv',
+        turnIndex: 1,
+        suggestionId: 1,
+        commentId: undefined,
+      });
+    });
+
+    test('reportAiAgentCommentDraft', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: JSON.stringify({
+          agentId: 'test-agent',
+          conversationId: 'test-conv',
+          turnIndex: 2,
+        }),
+      };
+
+      reportAiAgentCommentDraft(reporting, runResult, 'test-comment-id');
+
+      assert.isTrue(reportInteractionStub.calledOnce);
+      assert.equal(
+        reportInteractionStub.lastCall.args[0],
+        Interaction.AI_AGENT_SUGGESTION_TO_COMMENT
+      );
+      assert.deepEqual(reportInteractionStub.lastCall.args[1], {
+        agentId: 'test-agent',
+        conversationId: 'test-conv',
+        turnIndex: 2,
+        suggestionId: undefined,
+        commentId: 'test-comment-id',
+      });
+    });
+
+    test('does not report if externalId is missing', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
+      assert.isFalse(reportInteractionStub.called);
+
+      reportAiAgentCommentDraft(reporting, runResult);
+      assert.isFalse(reportInteractionStub.called);
+    });
+
+    test('does not report if isAiPowered is false or missing', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: false,
+        externalId: 'some-id',
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
+      assert.isFalse(reportInteractionStub.called);
+
+      const runResultMissing = {
+        ...createRunResult(),
+        isAiPowered: undefined,
+        externalId: 'some-id',
+      };
+
+      reportAiAgentCommentDraft(reporting, runResultMissing);
+      assert.isFalse(reportInteractionStub.called);
+    });
+
+    test('reports check event details if externalId is plain string (not chat JSON)', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: 'plain-string-external-id',
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
+      assert.isTrue(reportInteractionStub.calledOnce);
+      assert.deepEqual(reportInteractionStub.lastCall.args[1], {
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: 'plain-string-external-id',
+        commentId: undefined,
+      });
+    });
+
+    test('reports check event details if externalId is JSON but missing chat fields', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: JSON.stringify({agentId: 'test-agent'}),
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
+      assert.isTrue(reportInteractionStub.calledOnce);
+      assert.deepEqual(reportInteractionStub.lastCall.args[1], {
+        checkName: 'test-check-name',
+        checkDescription: 'test-description',
+        externalId: runResult.externalId,
+        commentId: undefined,
+      });
+    });
+
+    test('does not report check event if checkDescription is missing', () => {
+      const reporting = {
+        reportInteraction: reportInteractionStub,
+      } as unknown as ReportingService;
+      const runResult = {
+        ...createRunResult(),
+        isAiPowered: true,
+        checkName: 'test-check-name',
+        checkDescription: undefined,
+        externalId: 'plain-string-external-id',
+      };
+
+      reportAiAgentGetAIFix(reporting, runResult);
+      assert.isFalse(reportInteractionStub.called);
+    });
+  });
+
+  suite('pleaseFixMessage', () => {
+    test('when summary and message are the same', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        category: Category.WARNING,
+        checkName: 'test-check-name',
+        summary: 'this is the warning text',
+        message: 'this is the warning text',
+      };
+      assert.equal(
+        pleaseFixMessage(result),
+        'Please fix this WARNING reported by test-check-name: this is the warning text'
+      );
+    });
+
+    test('when summary and message are not the same', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        category: Category.ERROR,
+        checkName: 'test-check-name',
+        summary: 'this is the summary text',
+        message: 'this is the message body text',
+      };
+      assert.equal(
+        pleaseFixMessage(result),
+        'Please fix this ERROR reported by test-check-name: this is the summary text\n\nthis is the message body text'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index f180a8e..db15121 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -67,7 +67,7 @@
 import {isDefined} from '../../types/types';
 import {ChangeViewModel} from '../views/change';
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
-import {readJSONResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {parsePrefixedJSON} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export interface CommentState {
   /** undefined means 'still loading' */
@@ -747,22 +747,38 @@
     if (showToast) this.showStartRequest();
     const timing = isNew(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
     const timer = this.reporting.getTimer(timing);
-
     let savedComment;
     try {
+      const networkTimer = this.reporting.getTimer(`${timing} - network`);
       const result = await this.restApiService.saveDiffDraft(
         changeNum,
         draft.patch_set,
         convertToCommentInput(draft)
       );
+      networkTimer.end();
       if (changeNum !== this.changeNum) return draft;
       if (!result.ok) throw new Error('request failed');
-      savedComment = (await readJSONResponsePayload(result))
-        .parsed as unknown as CommentInfo;
+
+      const parseTimer = this.reporting.getTimer(`${timing} - parse`);
+      const textTimer = this.reporting.getTimer(`${timing} - parse - text`);
+      const text = await result.text();
+      textTimer.end();
+
+      const jsonTimer = this.reporting.getTimer(`${timing} - parse - json`);
+      try {
+        savedComment = parsePrefixedJSON(text) as unknown as CommentInfo;
+      } catch (_) {
+        throw new Error(
+          `Response payload is not prefixed json. Payload: ${text}`
+        );
+      }
+      jsonTimer.end();
+      parseTimer.end();
     } catch (error) {
       if (showToast) this.handleFailedDraftRequest();
       const draftError: DraftInfo = {...draft, savingState: SavingState.ERROR};
       this.modifyState(s => setDraft(s, draftError));
+      timer.end({error: true});
       return draftError;
     }
 
diff --git a/polygerrit-ui/app/models/flows/flows-model.ts b/polygerrit-ui/app/models/flows/flows-model.ts
index 300a1b7..f2e7802 100644
--- a/polygerrit-ui/app/models/flows/flows-model.ts
+++ b/polygerrit-ui/app/models/flows/flows-model.ts
@@ -8,6 +8,7 @@
 import {ChangeModel} from '../change/change-model';
 import {fireServerError} from '../../utils/event-util';
 import {FlowInfo, FlowInput} from '../../api/rest-api';
+import {ErrorCallback} from '../../api/rest';
 import {Model} from '../base/model';
 import {define} from '../dependency';
 import {PluginsModel} from '../plugins/plugins-model';
@@ -31,12 +32,26 @@
 
 export const SUBMIT_ACTION_NAME = 'submit';
 
+/**
+ * Matches the base change path prefix up to the change number (e.g. `/c/project/+/123`),
+ * ignoring any trailing patchsets, diff ranges, comment IDs, or file paths.
+ *
+ * @see RoutePattern.CHANGE in `gr-router.ts` for corresponding route pattern.
+ */
+const CHANGE_PREFIX_PATTERN = /^(.*?\/(?:c\/.+?\/\+)\/\d+)/;
+
 export function getSubmitCondition() {
   return getChangePrefix() + ' is is:submittable';
 }
 
+/**
+ * Returns the change URL prefix (e.g. `http://host/c/project/+/123`), stripping
+ * patchset numbers, diff ranges, comment IDs, and file paths from pathname.
+ */
 export function getChangePrefix() {
-  return window.location.origin + window.location.pathname;
+  const match = window.location.pathname.match(CHANGE_PREFIX_PATTERN);
+  const pathname = match ? match[1] : window.location.pathname;
+  return window.location.origin + pathname;
 }
 
 export class FlowsModel extends Model<FlowsState> {
@@ -208,10 +223,10 @@
     this.reload();
   }
 
-  async createFlow(flowInput: FlowInput) {
+  async createFlow(flowInput: FlowInput, errFn?: ErrorCallback) {
     if (!this.changeNum) return;
     if (!this.getState().isEnabled) return;
-    await this.restApiService.createFlow(this.changeNum, flowInput);
+    await this.restApiService.createFlow(this.changeNum, flowInput, errFn);
     this.reload();
   }
 }
diff --git a/polygerrit-ui/app/models/flows/flows-model_test.ts b/polygerrit-ui/app/models/flows/flows-model_test.ts
index 2dfe5a7..18c9538 100644
--- a/polygerrit-ui/app/models/flows/flows-model_test.ts
+++ b/polygerrit-ui/app/models/flows/flows-model_test.ts
@@ -200,4 +200,69 @@
     await waitUntil(() => !flowsModel.hasAutosubmitFlowAlready());
     assert.isFalse(flowsModel.hasAutosubmitFlowAlready());
   });
+
+  suite('getChangePrefix', () => {
+    let originalPath: string;
+
+    setup(() => {
+      originalPath = window.location.pathname;
+    });
+
+    teardown(() => {
+      window.history.replaceState(null, '', originalPath);
+    });
+
+    test('strips patchset, diff range, and file path subpaths', () => {
+      const origin = window.location.origin;
+
+      window.history.replaceState(null, '', '/c/my-repo/+/123');
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+
+      window.history.replaceState(null, '', '/c/my-repo/+/123/4');
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+
+      window.history.replaceState(null, '', '/c/my-repo/+/123/1..4');
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+
+      window.history.replaceState(
+        null,
+        '',
+        '/c/my-repo/+/123/1..4/src/file.ts'
+      );
+      assert.equal(getChangePrefix(), `${origin}/c/my-repo/+/123`);
+    });
+  });
+
+  test('hasAutosubmitFlowAlready detects flows when navigating between patchsets/diff ranges', async () => {
+    const originalPath = window.location.pathname;
+    try {
+      window.history.replaceState(null, '', '/c/my-repo/+/123/4..5/src/foo.ts');
+      stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true});
+      const expectedPrefix = `${window.location.origin}/c/my-repo/+/123`;
+      stubRestApi('listFlows').resolves([
+        createFlow({
+          uuid: 'flow1',
+          stages: [
+            {
+              expression: {
+                condition: `${expectedPrefix} is is:submittable`,
+                action: {name: SUBMIT_ACTION_NAME},
+              },
+              state: FlowStageState.DONE,
+            },
+          ],
+        }),
+      ]);
+
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        _number: 123 as NumericChangeId,
+      });
+      await waitUntil(() => flowsModel.getState().flows.length > 0);
+
+      assert.isTrue(flowsModel.hasAutosubmitFlowAlready());
+    } finally {
+      window.history.replaceState(null, '', originalPath);
+    }
+  });
 });
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 8ed7cfe..198ab2b 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -12,7 +12,11 @@
 } from '../../api/checks';
 import {Model} from '../base/model';
 import {select} from '../../utils/observable-util';
-import {CoverageProvider, TokenHoverListener} from '../../api/annotation';
+import {
+  CoverageProvider,
+  DiffLayerFactory,
+  TokenHoverListener,
+} from '../../api/annotation';
 import {SuggestionsProvider} from '../../api/suggestions';
 import {ChangeUpdatesPublisher} from '../../api/change-updates';
 import {AiCodeReviewProvider} from '../../api/ai-code-review';
@@ -59,6 +63,11 @@
   listener: TokenHoverListener;
 }
 
+export interface DiffLayerPlugin {
+  pluginName: string;
+  factory: DiffLayerFactory;
+}
+
 export interface ChecksUpdate {
   pluginName: string;
   run: CheckRun;
@@ -111,6 +120,11 @@
    * annotationApi().addTokenHoverListener().
    */
   tokenHighlightPlugins: TokenHoverListenerPlugin[];
+
+  /**
+   * List of plugins that have registered a diff layer factory.
+   */
+  diffLayerPlugins: DiffLayerPlugin[];
 }
 
 export class PluginsModel extends Model<PluginsState> {
@@ -154,6 +168,11 @@
     state => state.suggestionsPlugins
   );
 
+  public diffLayerPlugins$ = select(
+    this.state$,
+    state => state.diffLayerPlugins
+  );
+
   public pluginsLoaded$ = select(this.state$, state => state.pluginsLoaded);
 
   constructor() {
@@ -167,23 +186,32 @@
       flowsAutosubmitPlugins: [],
       suggestionsPlugins: [],
       tokenHighlightPlugins: [],
+      diffLayerPlugins: [],
     });
   }
 
-  coverageRegister(plugin: CoveragePlugin) {
-    const nextState = {...this.getState()};
-    nextState.coveragePlugins = [...nextState.coveragePlugins];
-    const alreadyRegistered = nextState.coveragePlugins.some(
+  private registerPlugin<K extends keyof Omit<PluginsState, 'pluginsLoaded'>>(
+    key: K,
+    plugin: PluginsState[K][number],
+    typeDescription: string
+  ) {
+    const list = this.getState()[key] as Array<{pluginName: string}>;
+    const alreadyRegistered = list.some(
       p => p.pluginName === plugin.pluginName
     );
     if (alreadyRegistered) {
       console.warn(
-        `${plugin.pluginName} tried to register twice as a coverage provider. Ignored.`
+        `${plugin.pluginName} tried to register twice as a ${typeDescription}. Ignored.`
       );
       return;
     }
-    nextState.coveragePlugins.push(plugin);
-    this.setState(nextState);
+    this.updateState({
+      [key]: [...list, plugin],
+    } as unknown as Partial<PluginsState>);
+  }
+
+  coverageRegister(plugin: CoveragePlugin) {
+    this.registerPlugin('coveragePlugins', plugin, 'coverage provider');
   }
 
   getChangeUpdatesPlugins() {
@@ -191,115 +219,43 @@
   }
 
   changeUpdatesRegister(plugin: ChangeUpdatesPlugin) {
-    const nextState = {...this.getState()};
-    nextState.changeUpdatesPlugins = [...nextState.changeUpdatesPlugins];
-    const alreadyRegistered = nextState.changeUpdatesPlugins.some(
-      p => p.pluginName === plugin.pluginName
+    this.registerPlugin(
+      'changeUpdatesPlugins',
+      plugin,
+      'change updates provider'
     );
-    if (alreadyRegistered) {
-      console.warn(
-        `${plugin.pluginName} tried to register twice as a change updates provider. Ignored.`
-      );
-      return;
-    }
-    nextState.changeUpdatesPlugins.push(plugin);
-    this.setState(nextState);
   }
 
   checksRegister(plugin: ChecksPlugin) {
-    const nextState = {...this.getState()};
-    nextState.checksPlugins = [...nextState.checksPlugins];
-    const alreadyRegistered = nextState.checksPlugins.some(
-      p => p.pluginName === plugin.pluginName
-    );
-    if (alreadyRegistered) {
-      console.warn(
-        `${plugin.pluginName} tried to register twice as a checks provider. Ignored.`
-      );
-      return;
-    }
-    nextState.checksPlugins.push(plugin);
-    this.setState(nextState);
+    this.registerPlugin('checksPlugins', plugin, 'checks provider');
   }
 
   aiCodeReviewRegister(plugin: AiCodeReviewPlugin) {
-    const nextState = {...this.getState()};
-    nextState.aiCodeReviewPlugins = [...nextState.aiCodeReviewPlugins];
-    const alreadyRegistered = nextState.aiCodeReviewPlugins.some(
-      p => p.pluginName === plugin.pluginName
+    this.registerPlugin(
+      'aiCodeReviewPlugins',
+      plugin,
+      'AI Code Review provider'
     );
-    if (alreadyRegistered) {
-      console.warn(
-        `${plugin.pluginName} tried to register twice as a AI Code Review provider. Ignored.`
-      );
-      return;
-    }
-    nextState.aiCodeReviewPlugins.push(plugin);
-    this.setState(nextState);
   }
 
   registerFlowsProvider(plugin: FlowsPlugin) {
-    const nextState = {...this.getState()};
-    nextState.flowsPlugins = [...nextState.flowsPlugins];
-    const alreadyRegistered = nextState.flowsPlugins.some(
-      p => p.pluginName === plugin.pluginName
-    );
-    if (alreadyRegistered) {
-      console.warn(
-        `${plugin.pluginName} tried to register twice as a flows provider. Ignored.`
-      );
-      return;
-    }
-    nextState.flowsPlugins.push(plugin);
-    this.setState(nextState);
+    this.registerPlugin('flowsPlugins', plugin, 'flows provider');
   }
 
   registerFlowsAutosubmitProvider(plugin: FlowsAutosubmitPlugin) {
-    const nextState = {...this.getState()};
-    nextState.flowsAutosubmitPlugins = [...nextState.flowsAutosubmitPlugins];
-    const alreadyRegistered = nextState.flowsAutosubmitPlugins.some(
-      p => p.pluginName === plugin.pluginName
-    );
-    if (alreadyRegistered) {
-      console.warn(
-        `${plugin.pluginName} tried to register twice as a flows provider. Ignored.`
-      );
-      return;
-    }
-    nextState.flowsAutosubmitPlugins.push(plugin);
-    this.setState(nextState);
+    this.registerPlugin('flowsAutosubmitPlugins', plugin, 'flows provider');
   }
 
   suggestionsRegister(plugin: SuggestionPlugin) {
-    const nextState = {...this.getState()};
-    nextState.suggestionsPlugins = [...nextState.suggestionsPlugins];
-    const alreadyRegistered = nextState.suggestionsPlugins.some(
-      p => p.pluginName === plugin.pluginName
-    );
-    if (alreadyRegistered) {
-      console.warn(
-        `${plugin.pluginName} tried to register twice as a suggestion provider. Ignored.`
-      );
-      return;
-    }
-    nextState.suggestionsPlugins.push(plugin);
-    this.setState(nextState);
+    this.registerPlugin('suggestionsPlugins', plugin, 'suggestion provider');
   }
 
   tokenHoverListenerRegister(plugin: TokenHoverListenerPlugin) {
-    const nextState = {...this.getState()};
-    nextState.tokenHighlightPlugins = [...nextState.tokenHighlightPlugins];
-    const alreadyRegistered = nextState.tokenHighlightPlugins.some(
-      p => p.pluginName === plugin.pluginName
-    );
-    if (alreadyRegistered) {
-      console.warn(
-        `${plugin.pluginName} tried to register twice as a hover callback. Ignored.`
-      );
-      return;
-    }
-    nextState.tokenHighlightPlugins.push(plugin);
-    this.setState(nextState);
+    this.registerPlugin('tokenHighlightPlugins', plugin, 'hover callback');
+  }
+
+  diffLayerRegister(plugin: DiffLayerPlugin) {
+    this.registerPlugin('diffLayerPlugins', plugin, 'diff layer provider');
   }
 
   checksUpdate(update: ChecksUpdate) {
diff --git a/polygerrit-ui/app/models/plugins/plugins-model_test.ts b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
index 7aabae7..bdcb8b1 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model_test.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
@@ -4,9 +4,15 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../test/common-test-setup';
+import * as sinon from 'sinon';
 import './plugins-model';
 import {ChecksApiConfig, ChecksProvider, ResponseCode} from '../../api/checks';
-import {ChecksPlugin, ChecksUpdate, PluginsModel} from './plugins-model';
+import {
+  ChecksPlugin,
+  ChecksUpdate,
+  DiffLayerPlugin,
+  PluginsModel,
+} from './plugins-model';
 import {createRun, createRunResult} from '../../test/test-data-generators';
 import {assert} from '@open-wc/testing';
 
@@ -29,6 +35,7 @@
 suite('plugins-model tests', () => {
   let model: PluginsModel;
   let checksPlugins: ChecksPlugin[] = [];
+  let diffLayerPlugins: DiffLayerPlugin[] = [];
   const register = function () {
     model.checksRegister({
       pluginName: PLUGIN_NAME,
@@ -41,6 +48,7 @@
     model = new PluginsModel();
     model.state$.subscribe(s => {
       checksPlugins = s.checksPlugins;
+      diffLayerPlugins = s.diffLayerPlugins;
     });
   });
 
@@ -81,4 +89,33 @@
 
     assert.equal(update?.pluginName, PLUGIN_NAME);
   });
+
+  test('diffLayerRegister', async () => {
+    assert.isFalse(diffLayerPlugins.some(p => p.pluginName === PLUGIN_NAME));
+
+    const factory = () => {
+      return {
+        annotate: () => {},
+      };
+    };
+    model.diffLayerRegister({
+      pluginName: PLUGIN_NAME,
+      factory,
+    });
+
+    assert.isTrue(diffLayerPlugins.some(p => p.pluginName === PLUGIN_NAME));
+    assert.equal(
+      diffLayerPlugins.find(p => p.pluginName === PLUGIN_NAME)?.factory,
+      factory
+    );
+
+    // Try to register again
+    const consoleWarnStub = sinon.stub(console, 'warn');
+    model.diffLayerRegister({
+      pluginName: PLUGIN_NAME,
+      factory,
+    });
+    assert.isTrue(consoleWarnStub.calledOnce);
+    consoleWarnStub.restore();
+  });
 });
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index c8ec819..cc157ea 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -23,6 +23,7 @@
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
   createDefaultPreferences,
+  DEFAULT_VISIBLE_COLUMNS,
 } from '../../constants/constants';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {DiffPreferencesInfo} from '../../types/diff';
@@ -34,7 +35,9 @@
 
 export function changeTablePrefs(prefs: Partial<PreferencesInfo>) {
   const cols = prefs.change_table ?? [];
-  if (cols.length === 0) return Object.values(ColumnNames);
+  // An empty pref means "the defaults", which intentionally excludes opt-in
+  // columns such as Hashtags.
+  if (cols.length === 0) return [...DEFAULT_VISIBLE_COLUMNS];
   return cols
     .map(column => (column === 'Project' ? ColumnNames.REPO : column))
     .map(column => (column === ' Status ' ? ColumnNames.STATUS : column));
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 03c944f..d780ba6 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -99,6 +99,7 @@
     path: string;
     // TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
     lineNum?: number;
+    endLineNum?: number;
     leftSide?: boolean;
   };
 
@@ -114,6 +115,7 @@
   diffView: {
     path: string;
     lineNum?: number;
+    endLineNum?: number;
     leftSide?: boolean;
   };
 };
@@ -253,6 +255,9 @@
   ) {
     params.push(`checksPatchset=${state.checksPatchset}`);
   }
+  if (state.forceReload) {
+    params.push('forceReload=true');
+  }
   if (params.length > 0) {
     queryParams = '?' + params.join('&');
   }
@@ -264,6 +269,12 @@
       hash += 'b';
     }
     hash += state.diffView.lineNum;
+    if (
+      state.diffView.endLineNum &&
+      state.diffView.endLineNum > state.diffView.lineNum
+    ) {
+      hash += `-${state.diffView.endLineNum}`;
+    }
   }
 
   return `${createChangeUrlCommon(state)}${path}${queryParams}${hash}`;
@@ -285,6 +296,26 @@
   return `${createChangeUrlCommon(state)}${path},edit${suffix}`;
 }
 
+export function createApplyFixUrl(
+  obj: (CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>) & {
+    filePath?: string;
+    currentChildView?: ChangeChildView;
+  }
+): string {
+  const {filePath, currentChildView, ...restObj} = obj;
+  if (currentChildView === ChangeChildView.DIFF && filePath) {
+    return createDiffUrl({
+      ...restObj,
+      patchNum: EDIT,
+      diffView: {path: filePath},
+    });
+  }
+  return createChangeUrl({
+    ...restObj,
+    patchNum: EDIT,
+  });
+}
+
 /**
  * The shared part of creating a change URL between OVERVIEW, DIFF and EDIT
  * child views.
@@ -336,6 +367,11 @@
     state => state?.diffView?.lineNum
   );
 
+  readonly diffEndLine$ = select(
+    this.state$,
+    state => state?.diffView?.endLineNum
+  );
+
   public readonly diffLeftSide$ = select(
     this.state$,
     state => state?.diffView?.leftSide ?? false
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index e05adb6..8bebe21 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -6,10 +6,13 @@
 import {assert} from '@open-wc/testing';
 import {
   BasePatchSetNum,
+  EDIT,
+  NumericChangeId,
   PatchSetNumber,
   RepoName,
   RevisionPatchSetNum,
 } from '../../api/rest-api';
+
 import '../../test/common-test-setup';
 import {
   createChangeViewState,
@@ -17,7 +20,9 @@
   createEditViewState,
 } from '../../test/test-data-generators';
 import {
+  ChangeChildView,
   ChangeViewState,
+  createApplyFixUrl,
   createChangeUrl,
   createDiffUrl,
   createEditUrl,
@@ -114,6 +119,14 @@
       );
     });
 
+    test('forceReload', () => {
+      params.forceReload = true;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/x%252By/path.cpp?forceReload=true'
+      );
+    });
+
     test('base patchset', () => {
       params.basePatchNum = 6 as BasePatchSetNum;
       assert.equal(
@@ -186,4 +199,36 @@
     assert.equal(createEditUrl(params).substring(0, 5), '/base');
     window.CANONICAL_PATH = undefined;
   });
+
+  suite('createApplyFixUrl', () => {
+    test('Diff View context', () => {
+      assert.equal(
+        createApplyFixUrl({
+          changeNum: 42 as NumericChangeId,
+          repo: 'test-project' as RepoName,
+          patchNum: EDIT,
+          basePatchNum: 1 as BasePatchSetNum,
+          forceReload: true,
+          filePath: 'foo/bar.ts',
+          currentChildView: ChangeChildView.DIFF,
+        }),
+        '/c/test-project/+/42/1..edit/foo/bar.ts?forceReload=true'
+      );
+    });
+
+    test('Overview context', () => {
+      assert.equal(
+        createApplyFixUrl({
+          changeNum: 42 as NumericChangeId,
+          repo: 'test-project' as RepoName,
+          patchNum: EDIT,
+          basePatchNum: 1 as BasePatchSetNum,
+          forceReload: true,
+          filePath: 'foo/bar.ts',
+          currentChildView: ChangeChildView.OVERVIEW,
+        }),
+        '/c/test-project/+/42/1..edit?forceReload=true'
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index 77400c6..dc85b23 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -1,23 +1,17 @@
-load("@npm//@bazel/concatjs:index.bzl", "ts_library")
-load("//tools/node_tools/node_modules_licenses:node_modules_licenses.bzl", "node_modules_licenses")
+load("@aspect_rules_js//js:defs.bzl", "js_run_binary")
+load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
 
-filegroup(
-    name = "licenses-texts",
-    srcs = glob(["licenses/*.txt"]),
-)
-
-# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
-# We would prefer to not depend on @bazel/concatjs ...
-ts_library(
+ts_project(
     name = "licenses-config",
     srcs = [
         "licenses.ts",
     ],
-    compiler = "//tools/node_tools:tsc_wrapped-bin",
+    transpiler = "tsc",
     tsconfig = "tsconfig.json",
     deps = [
+        "//tools/node_tools:node_modules",
         "//tools/node_tools/node_modules_licenses:licenses-map",
-        "@tools_npm//:node_modules",
     ],
 )
 
@@ -42,20 +36,50 @@
     cmd = "cp $< $@",
 )
 
-# filegroup is enough (instead of rollup-bundle), because we are not going to run licenses.ts file
 filegroup(
     name = "licenses-config-js",
     srcs = [":licenses-config"],
-    output_group = "es5_sources",
+)
+
+genrule2(
+    name = "node_modules_files",
+    srcs = ["//polygerrit-ui/app:node_modules"],
+    outs = ["package_json.properties"],
+    cmd = "find -L $(locations //polygerrit-ui/app:node_modules) -type f -print | grep '.aspect_rules_js' > $$ROOT/$@",
+)
+
+filegroup(
+    name = "licenses-texts",
+    srcs = glob(["licenses/*.txt"]),
+)
+
+genrule2(
+    name = "licenses-text-properties",
+    srcs = [":licenses-texts"],
+    outs = ["licenses.properties"],
+    cmd = "find ./polygerrit-ui/app/node_modules_licenses/licenses -name '*.txt' > $$ROOT/$@",
 )
 
 # Generate polygerrit-licenses.json for files in @ui_npm workspace.
 # For details - see comments for node_modules_licenses rule and
 # tools/node_tools/node_modules_licenses/license-map-generator.ts file
-node_modules_licenses(
+js_run_binary(
     name = "polygerrit-licenses",
-    licenses_config = "licenses-config-js",
-    licenses_texts = [":licenses-texts"],
-    node_modules = "@ui_npm//:node_modules",
+    srcs = [
+        "//polygerrit-ui/app:node_modules",
+        "//polygerrit-ui/app/node_modules_licenses:licenses-config-js",
+        "//polygerrit-ui/app/node_modules_licenses:licenses-text-properties",
+        "//polygerrit-ui/app/node_modules_licenses:licenses-texts",
+        "//polygerrit-ui/app/node_modules_licenses:node_modules_files",
+    ],
+    outs = ["polygerrit-licenses.json"],
+    args = [
+        "$(location //polygerrit-ui/app/node_modules_licenses:licenses-config-js)",
+        "$(location //polygerrit-ui/app/node_modules_licenses:node_modules_files)",
+        "$(location //polygerrit-ui/app/node_modules_licenses:licenses-text-properties)",
+        "$@",
+    ],
+    chdir = "",
+    tool = "//tools/node_tools/node_modules_licenses:license-map-generator-bin",
     visibility = ["//visibility:public"],
 )
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 3189bcb..c62ceed 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -44,30 +44,12 @@
     sharedLicenseFile: 'lit.txt',
   };
 
-  public static Polymer2014: LicenseInfo = {
-    name: 'Polymer-2014',
-    type: LicenseTypes.Bsd3,
-    sharedLicenseFile: 'polymer-2014.txt',
-  };
-
   public static Polymer2015: LicenseInfo = {
     name: 'Polymer-2015',
     type: LicenseTypes.Bsd3,
     sharedLicenseFile: 'polymer-2015.txt',
   };
 
-  public static Polymer2017: LicenseInfo = {
-    name: 'Polymer-2017',
-    type: LicenseTypes.Bsd3,
-    sharedLicenseFile: 'polymer-2017.txt',
-  };
-
-  public static Polymer2018: LicenseInfo = {
-    name: 'Polymer-2018',
-    type: LicenseTypes.Bsd3,
-    sharedLicenseFile: 'polymer-2018.txt',
-  };
-
   public static IsArray: LicenseInfo = {
     name: 'isarray',
     type: LicenseTypes.Mit,
@@ -91,6 +73,10 @@
 
 const packages: PackageInfo[] = [
   {
+    name: '@lit/context',
+    license: SharedLicenses.Lit,
+  },
+  {
     name: '@lit/reactive-element',
     license: SharedLicenses.Lit,
   },
@@ -99,17 +85,9 @@
     license: SharedLicenses.Lit,
   },
   {
-    name: '@polymer/decorators',
-    license: SharedLicenses.Polymer2017,
-  },
-  {
-    name: '@polymer/font-roboto',
-    license: SharedLicenses.Polymer2015,
-  },
-  {
     name: '@polymer/font-roboto-local',
     license: SharedLicenses.Polymer2015,
-    filesFilter: fileName =>
+    filesFilter: (fileName: string) =>
       !fontsRobotoFilter(fileName) && !fontsRobotomonoFilter(fileName),
   },
   {
@@ -131,10 +109,6 @@
     filesFilter: fontsRobotomonoFilter,
   },
   {
-    name: '@polymer/polymer',
-    license: SharedLicenses.Polymer2017,
-  },
-  {
     name: '@material/web',
     license: {
       name: '@material/web',
@@ -168,11 +142,7 @@
   },
   {
     name: '@webcomponents/shadycss',
-    license: SharedLicenses.Polymer2017,
-  },
-  {
-    name: '@webcomponents/webcomponentsjs',
-    license: SharedLicenses.Polymer2018,
+    license: SharedLicenses.Polymer2015,
   },
   {
     name: 'isarray',
@@ -199,14 +169,6 @@
     },
   },
   {
-    name: 'polymer-resin',
-    license: SharedLicenses.Polymer2018,
-  },
-  {
-    name: 'polymer-bridges',
-    license: SharedLicenses.Polymer2018,
-  },
-  {
     name: 'polygerrit-gr-page',
     license: SharedLicenses.Page,
   },
@@ -332,7 +294,7 @@
     license: {
       name: 'marked',
       type: LicenseTypes.Mit,
-      packageLicenseFile: 'LICENSE.md',
+      packageLicenseFile: 'LICENSE',
     },
   },
   {
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2014.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2014.txt
deleted file mode 100644
index 9a35430..0000000
--- a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2014.txt
+++ /dev/null
@@ -1,34 +0,0 @@
-Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2017.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2017.txt
deleted file mode 100644
index 440f70f..0000000
--- a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2017.txt
+++ /dev/null
@@ -1,34 +0,0 @@
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2018.txt b/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2018.txt
deleted file mode 100644
index 9381974..0000000
--- a/polygerrit-ui/app/node_modules_licenses/licenses/polymer-2018.txt
+++ /dev/null
@@ -1,34 +0,0 @@
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/polygerrit-ui/app/node_modules_licenses/tsconfig.json b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
index de1eb24..eb5ee35 100644
--- a/polygerrit-ui/app/node_modules_licenses/tsconfig.json
+++ b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
@@ -6,8 +6,11 @@
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "../../../.ts-out/polygerrit-ui/node_modules_licenses", // Not used in bazel
-    "types": ["node"]
+    "types": ["node"],
+    "typeRoots": [
+      "../../../tools/node_tools/node_modules/@types",
+      "../../../tools/node_tools/node_modules_licenses"
+    ]
   },
   "include": ["**/*.ts"]
 }
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index d456a58..babc44f 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -3,15 +3,12 @@
   "description": "Gerrit Code Review - Polygerrit dependencies",
   "browser": true,
   "dependencies": {
-    "@material/web": "^2.4.1",
-    "@polymer/decorators": "^3.0.0",
+    "@material/web": "^2.5.0",
     "@polymer/font-roboto-local": "^3.0.2",
-    "@polymer/polymer": "3.5.2",
     "@types/resemblejs": "^4.1.3",
     "@types/resize-observer-browser": "^0.1.11",
     "@webcomponents/shadycss": "^1.11.2",
-    "@webcomponents/webcomponentsjs": "^2.8.0",
-    "highlight.js": "^11.11.1",
+    "highlight.js": "^11.12.0",
     "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba",
     "highlightjs-epp": "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9",
     "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e",
@@ -19,25 +16,12 @@
     "highlightjs-vue": "https://github.com/paladox/highlightjs-vue#44eed074ea0110d1ad03d2cbd77d27027cf7bb04",
     "immer": "^9.0.21",
     "lit": "^3.3.1",
-    "marked": "^17.0.1",
-    "polymer-bridges": "file:../../polymer-bridges",
-    "polymer-resin": "^2.0.1",
+    "marked": "^18.0.3",
     "resemblejs": "rsmbl/Resemble.js#66a55c5bfc3bda2303ad632ee8ce3c727b415917",
     "rxjs": "^6.6.7",
     "safevalues": "^1.2.0",
     "web-vitals": "^5.1.0"
   },
-  "dependencies // comments": {
-    "@polymer/polymer": [
-      "There is a an issue with release 3.5.2. Tests are failing with:",
-      "NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':",
-      "the name 'dom-module' has already been used with this registry at ...",
-      "We fix this by forcing all packages that use polymer to use 3.5.2."
-    ]
-  },
-  "resolutions": {
-    "@polymer/polymer": "3.5.2"
-  },
   "license": "Apache-2.0",
   "private": true
-}
\ No newline at end of file
+}
diff --git a/polygerrit-ui/app/pnpm-lock.yaml b/polygerrit-ui/app/pnpm-lock.yaml
new file mode 100644
index 0000000..132bf37
--- /dev/null
+++ b/polygerrit-ui/app/pnpm-lock.yaml
@@ -0,0 +1,543 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@material/web':
+        specifier: ^2.5.0
+        version: 2.5.0
+      '@polymer/font-roboto-local':
+        specifier: ^3.0.2
+        version: 3.0.2
+      '@types/resemblejs':
+        specifier: ^4.1.3
+        version: 4.1.3
+      '@types/resize-observer-browser':
+        specifier: ^0.1.11
+        version: 0.1.11
+      '@webcomponents/shadycss':
+        specifier: ^1.11.2
+        version: 1.11.2
+      highlight.js:
+        specifier: ^11.12.0
+        version: 11.12.0
+      highlightjs-closure-templates:
+        specifier: https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba
+        version: https://codeload.github.com/highlightjs/highlightjs-closure-templates/tar.gz/02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba
+      highlightjs-epp:
+        specifier: https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9
+        version: https://codeload.github.com/highlightjs/highlightjs-epp/tar.gz/9f9e1a92f37c217c68899c7d3bdccb4d134681b9
+      highlightjs-structured-text:
+        specifier: https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e
+        version: https://codeload.github.com/highlightjs/highlightjs-structured-text/tar.gz/e68dd7aa829529fb6c40d6287585f43273605a9e
+      highlightjs-ttcn3:
+        specifier: https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d
+        version: git+https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d
+      highlightjs-vue:
+        specifier: https://github.com/paladox/highlightjs-vue#44eed074ea0110d1ad03d2cbd77d27027cf7bb04
+        version: https://codeload.github.com/paladox/highlightjs-vue/tar.gz/44eed074ea0110d1ad03d2cbd77d27027cf7bb04
+      immer:
+        specifier: ^9.0.21
+        version: 9.0.21
+      lit:
+        specifier: ^3.3.1
+        version: 3.3.3
+      marked:
+        specifier: ^18.0.3
+        version: 18.0.3
+      resemblejs:
+        specifier: rsmbl/Resemble.js#66a55c5bfc3bda2303ad632ee8ce3c727b415917
+        version: https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917
+      rxjs:
+        specifier: ^6.6.7
+        version: 6.6.7
+      safevalues:
+        specifier: ^1.2.0
+        version: 1.2.0
+      web-vitals:
+        specifier: ^5.1.0
+        version: 5.1.0
+
+packages:
+
+  '@lit-labs/ssr-dom-shim@1.6.0':
+    resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
+
+  '@lit/context@1.1.6':
+    resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==}
+
+  '@lit/reactive-element@2.1.2':
+    resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
+
+  '@material/web@2.5.0':
+    resolution: {integrity: sha512-x2Uovyq8E/Zc1xHXd9s1eBMXF5pMHVAt70pISKd0fL3Ajj4L6zQaQUY/awcfR2RDAMKXC7i4+vr0arv1YaOR/g==}
+
+  '@polymer/font-roboto-local@3.0.2':
+    resolution: {integrity: sha512-mCd9TcjwnCxU+7uVHCkbREGU+OmzStvYh3ru5DSaftOQDnMrLAzernEv/QCcfSPRgTMHij+pIUN4tcaGeDGcYg==}
+
+  '@types/resemblejs@4.1.3':
+    resolution: {integrity: sha512-p0NA5aACdWCK+I4NJbwUvFoixwYxvfLu+UqaiZt/J3+3PJavMYOxRrdbeXbbiKiMGdKdDFjoxlFWkkaMU7SDxA==}
+
+  '@types/resize-observer-browser@0.1.11':
+    resolution: {integrity: sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==}
+
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+  '@webcomponents/shadycss@1.11.2':
+    resolution: {integrity: sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==}
+
+  base64-js@1.5.1:
+    resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+  bl@4.1.0:
+    resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+
+  buffer@5.7.1:
+    resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+
+  canvas@3.2.0:
+    resolution: {integrity: sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==}
+    engines: {node: ^18.12.0 || >= 20.9.0}
+
+  chownr@1.1.4:
+    resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+
+  decompress-response@6.0.0:
+    resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+    engines: {node: '>=10'}
+
+  deep-extend@0.6.0:
+    resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
+    engines: {node: '>=4.0.0'}
+
+  detect-libc@2.1.2:
+    resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+    engines: {node: '>=8'}
+
+  end-of-stream@1.4.5:
+    resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+  expand-template@2.0.3:
+    resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
+    engines: {node: '>=6'}
+
+  fs-constants@1.0.0:
+    resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+
+  github-from-package@0.0.0:
+    resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
+
+  highlight.js@10.7.3:
+    resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
+
+  highlight.js@11.12.0:
+    resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==}
+    engines: {node: '>=12.0.0'}
+
+  highlightjs-closure-templates@https://codeload.github.com/highlightjs/highlightjs-closure-templates/tar.gz/02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba:
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/highlightjs/highlightjs-closure-templates/tar.gz/02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba}
+    version: 0.0.1
+
+  highlightjs-epp@https://codeload.github.com/highlightjs/highlightjs-epp/tar.gz/9f9e1a92f37c217c68899c7d3bdccb4d134681b9:
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/highlightjs/highlightjs-epp/tar.gz/9f9e1a92f37c217c68899c7d3bdccb4d134681b9}
+    version: 0.0.1
+
+  highlightjs-structured-text@https://codeload.github.com/highlightjs/highlightjs-structured-text/tar.gz/e68dd7aa829529fb6c40d6287585f43273605a9e:
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/highlightjs/highlightjs-structured-text/tar.gz/e68dd7aa829529fb6c40d6287585f43273605a9e}
+    version: 1.4.9
+
+  highlightjs-ttcn3@git+https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d:
+    resolution: {commit: 6daccff309fca1e7561a43984d42fa4f829ce06d, repo: https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git, type: git}
+    version: 0.0.1
+
+  highlightjs-vue@https://codeload.github.com/paladox/highlightjs-vue/tar.gz/44eed074ea0110d1ad03d2cbd77d27027cf7bb04:
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/paladox/highlightjs-vue/tar.gz/44eed074ea0110d1ad03d2cbd77d27027cf7bb04}
+    version: 1.1.0
+
+  ieee754@1.2.1:
+    resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+
+  immer@9.0.21:
+    resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+  ini@1.3.8:
+    resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+
+  lit-element@4.2.2:
+    resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
+
+  lit-html@3.3.3:
+    resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==}
+
+  lit@3.3.3:
+    resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==}
+
+  marked@18.0.3:
+    resolution: {integrity: sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==}
+    engines: {node: '>= 20'}
+    hasBin: true
+
+  mimic-response@3.1.0:
+    resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+    engines: {node: '>=10'}
+
+  minimist@1.2.8:
+    resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+  mkdirp-classic@0.5.3:
+    resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+
+  napi-build-utils@2.0.0:
+    resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
+
+  node-abi@3.85.0:
+    resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==}
+    engines: {node: '>=10'}
+
+  node-addon-api@7.1.1:
+    resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
+  once@1.4.0:
+    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+  prebuild-install@7.1.3:
+    resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
+    engines: {node: '>=10'}
+    deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
+    hasBin: true
+
+  pump@3.0.3:
+    resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
+  rc@1.2.8:
+    resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
+    hasBin: true
+
+  readable-stream@3.6.2:
+    resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+    engines: {node: '>= 6'}
+
+  resemblejs@https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917:
+    resolution: {gitHosted: true, tarball: https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917}
+    version: 5.0.0
+
+  rxjs@6.6.7:
+    resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
+    engines: {npm: '>=2.0.0'}
+
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+  safevalues@1.2.0:
+    resolution: {integrity: sha512-zIsuhjYvJCjfsfjoim2ab6gLKFYAnTiDSJGh0cC3T44L/4kNLL90hBG2BzrXPrHA3f8Ms8FSJ1mljKH5dVR1cw==}
+
+  semver@7.7.3:
+    resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  simple-concat@1.0.1:
+    resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+  simple-get@4.0.1:
+    resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+
+  string_decoder@1.3.0:
+    resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+  strip-json-comments@2.0.1:
+    resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
+    engines: {node: '>=0.10.0'}
+
+  tar-fs@2.1.4:
+    resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
+
+  tar-stream@2.2.0:
+    resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
+    engines: {node: '>=6'}
+
+  tslib@1.14.1:
+    resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+  tunnel-agent@0.6.0:
+    resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+
+  util-deprecate@1.0.2:
+    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+  web-vitals@5.1.0:
+    resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
+
+  wrappy@1.0.2:
+    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+snapshots:
+
+  '@lit-labs/ssr-dom-shim@1.6.0': {}
+
+  '@lit/context@1.1.6':
+    dependencies:
+      '@lit/reactive-element': 2.1.2
+
+  '@lit/reactive-element@2.1.2':
+    dependencies:
+      '@lit-labs/ssr-dom-shim': 1.6.0
+
+  '@material/web@2.5.0':
+    dependencies:
+      '@lit/context': 1.1.6
+      lit: 3.3.3
+      tslib: 2.8.1
+
+  '@polymer/font-roboto-local@3.0.2': {}
+
+  '@types/resemblejs@4.1.3': {}
+
+  '@types/resize-observer-browser@0.1.11': {}
+
+  '@types/trusted-types@2.0.7': {}
+
+  '@webcomponents/shadycss@1.11.2': {}
+
+  base64-js@1.5.1:
+    optional: true
+
+  bl@4.1.0:
+    dependencies:
+      buffer: 5.7.1
+      inherits: 2.0.4
+      readable-stream: 3.6.2
+    optional: true
+
+  buffer@5.7.1:
+    dependencies:
+      base64-js: 1.5.1
+      ieee754: 1.2.1
+    optional: true
+
+  canvas@3.2.0:
+    dependencies:
+      node-addon-api: 7.1.1
+      prebuild-install: 7.1.3
+    optional: true
+
+  chownr@1.1.4:
+    optional: true
+
+  decompress-response@6.0.0:
+    dependencies:
+      mimic-response: 3.1.0
+    optional: true
+
+  deep-extend@0.6.0:
+    optional: true
+
+  detect-libc@2.1.2:
+    optional: true
+
+  end-of-stream@1.4.5:
+    dependencies:
+      once: 1.4.0
+    optional: true
+
+  expand-template@2.0.3:
+    optional: true
+
+  fs-constants@1.0.0:
+    optional: true
+
+  github-from-package@0.0.0:
+    optional: true
+
+  highlight.js@10.7.3: {}
+
+  highlight.js@11.12.0: {}
+
+  highlightjs-closure-templates@https://codeload.github.com/highlightjs/highlightjs-closure-templates/tar.gz/02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba:
+    dependencies:
+      highlight.js: 11.12.0
+
+  highlightjs-epp@https://codeload.github.com/highlightjs/highlightjs-epp/tar.gz/9f9e1a92f37c217c68899c7d3bdccb4d134681b9:
+    dependencies:
+      highlight.js: 11.12.0
+
+  highlightjs-structured-text@https://codeload.github.com/highlightjs/highlightjs-structured-text/tar.gz/e68dd7aa829529fb6c40d6287585f43273605a9e:
+    dependencies:
+      highlight.js: 10.7.3
+
+  highlightjs-ttcn3@git+https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d:
+    dependencies:
+      highlight.js: 11.12.0
+
+  highlightjs-vue@https://codeload.github.com/paladox/highlightjs-vue/tar.gz/44eed074ea0110d1ad03d2cbd77d27027cf7bb04: {}
+
+  ieee754@1.2.1:
+    optional: true
+
+  immer@9.0.21: {}
+
+  inherits@2.0.4:
+    optional: true
+
+  ini@1.3.8:
+    optional: true
+
+  lit-element@4.2.2:
+    dependencies:
+      '@lit-labs/ssr-dom-shim': 1.6.0
+      '@lit/reactive-element': 2.1.2
+      lit-html: 3.3.3
+
+  lit-html@3.3.3:
+    dependencies:
+      '@types/trusted-types': 2.0.7
+
+  lit@3.3.3:
+    dependencies:
+      '@lit/reactive-element': 2.1.2
+      lit-element: 4.2.2
+      lit-html: 3.3.3
+
+  marked@18.0.3: {}
+
+  mimic-response@3.1.0:
+    optional: true
+
+  minimist@1.2.8:
+    optional: true
+
+  mkdirp-classic@0.5.3:
+    optional: true
+
+  napi-build-utils@2.0.0:
+    optional: true
+
+  node-abi@3.85.0:
+    dependencies:
+      semver: 7.7.3
+    optional: true
+
+  node-addon-api@7.1.1:
+    optional: true
+
+  once@1.4.0:
+    dependencies:
+      wrappy: 1.0.2
+    optional: true
+
+  prebuild-install@7.1.3:
+    dependencies:
+      detect-libc: 2.1.2
+      expand-template: 2.0.3
+      github-from-package: 0.0.0
+      minimist: 1.2.8
+      mkdirp-classic: 0.5.3
+      napi-build-utils: 2.0.0
+      node-abi: 3.85.0
+      pump: 3.0.3
+      rc: 1.2.8
+      simple-get: 4.0.1
+      tar-fs: 2.1.4
+      tunnel-agent: 0.6.0
+    optional: true
+
+  pump@3.0.3:
+    dependencies:
+      end-of-stream: 1.4.5
+      once: 1.4.0
+    optional: true
+
+  rc@1.2.8:
+    dependencies:
+      deep-extend: 0.6.0
+      ini: 1.3.8
+      minimist: 1.2.8
+      strip-json-comments: 2.0.1
+    optional: true
+
+  readable-stream@3.6.2:
+    dependencies:
+      inherits: 2.0.4
+      string_decoder: 1.3.0
+      util-deprecate: 1.0.2
+    optional: true
+
+  resemblejs@https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917:
+    optionalDependencies:
+      canvas: 3.2.0
+
+  rxjs@6.6.7:
+    dependencies:
+      tslib: 1.14.1
+
+  safe-buffer@5.2.1:
+    optional: true
+
+  safevalues@1.2.0: {}
+
+  semver@7.7.3:
+    optional: true
+
+  simple-concat@1.0.1:
+    optional: true
+
+  simple-get@4.0.1:
+    dependencies:
+      decompress-response: 6.0.0
+      once: 1.4.0
+      simple-concat: 1.0.1
+    optional: true
+
+  string_decoder@1.3.0:
+    dependencies:
+      safe-buffer: 5.2.1
+    optional: true
+
+  strip-json-comments@2.0.1:
+    optional: true
+
+  tar-fs@2.1.4:
+    dependencies:
+      chownr: 1.1.4
+      mkdirp-classic: 0.5.3
+      pump: 3.0.3
+      tar-stream: 2.2.0
+    optional: true
+
+  tar-stream@2.2.0:
+    dependencies:
+      bl: 4.1.0
+      end-of-stream: 1.4.5
+      fs-constants: 1.0.0
+      inherits: 2.0.4
+      readable-stream: 3.6.2
+    optional: true
+
+  tslib@1.14.1: {}
+
+  tslib@2.8.1: {}
+
+  tunnel-agent@0.6.0:
+    dependencies:
+      safe-buffer: 5.2.1
+    optional: true
+
+  util-deprecate@1.0.2:
+    optional: true
+
+  web-vitals@5.1.0: {}
+
+  wrappy@1.0.2:
+    optional: true
diff --git a/polygerrit-ui/app/pnpm-workspace.yaml b/polygerrit-ui/app/pnpm-workspace.yaml
new file mode 100644
index 0000000..962cec9
--- /dev/null
+++ b/polygerrit-ui/app/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+packages:
+  - 'polygerrit-ui/app'
+
+onlyBuiltDependencies: []
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index ab43ef0..237df57 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -17,14 +17,14 @@
 // so require(plugin_name) can't find a plugin.
 // To fix it, requirePlugin tries:
 // 1. resolve module id using default behavior, i.e. it starts from __dirname
-// 2. if module not found - it tries to resolve module starting from rollupBin
-//    location.
+// 2. if module not found - it tries to resolve module starting from
+//    tools/node_tools/node_modules
 // This workaround also gives us additional power - we can place .config.js
 // file anywhere in a source tree and add all plugins in the same package.json
 // file as rollup node module.
 function requirePlugin(id) {
-  const rollupBinDir = path.dirname(process.argv[1]);
-  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir]});
+  const nodeToolsModulesDir = path.join(__dirname, '../../tools/node_tools/node_modules');
+  const pluginPath = require.resolve(id, {paths: [__dirname, nodeToolsModulesDir] });
   return require(pluginPath);
 }
 
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 593c898..5f815d7 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,5 +1,7 @@
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
-load("//tools/bzl:genrule2.bzl", "genrule2")
+"""Build rules for polygerrit."""
+
+load("@aspect_rules_rollup//rollup:defs.bzl", "rollup")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
 
 def polygerrit_bundle(name, srcs, outs, entry_point, app_name):
     """Build .zip bundle from source code
@@ -16,58 +18,61 @@
     native.filegroup(
         name = app_name + "-full-src",
         srcs = srcs + [
-            "@ui_npm//:node_modules",
+            "//polygerrit-ui/app:node_modules",
         ],
     )
 
-    rollup_bundle(
+    rollup(
         name = app_name + "-bundle-js",
         srcs = [app_name + "-full-src"],
+        node_modules = "//tools/node_tools:node_modules",
         args = [
             "--bundleConfigAsCjs=true",
         ],
         config_file = ":rollup.config.js",
         entry_point = entry_point,
-        rollup_bin = "//tools/node_tools:rollup-bin",
         silent = True,
         sourcemap = "hidden",
         deps = [
-            "@tools_npm//@rollup/plugin-replace",
-            "@tools_npm//@rollup/plugin-node-resolve",
+            "//tools/node_tools:node_modules/@rollup/plugin-replace",
+            "//tools/node_tools:node_modules/@rollup/plugin-node-resolve",
+            "//tools/node_tools:node_modules/@rollup/plugin-terser",
         ],
     )
 
-    rollup_bundle(
+    rollup(
         name = "syntax-worker",
         srcs = [app_name + "-full-src"],
+        node_modules = "//tools/node_tools:node_modules",
         args = [
             "--bundleConfigAsCjs=true",
         ],
         config_file = ":rollup.config.js",
         entry_point = "_pg_ts_out/workers/syntax-worker.js",
-        rollup_bin = "//tools/node_tools:rollup-bin",
         silent = True,
         sourcemap = "hidden",
         deps = [
-            "@tools_npm//@rollup/plugin-replace",
-            "@tools_npm//@rollup/plugin-node-resolve",
+            "//tools/node_tools:node_modules/@rollup/plugin-replace",
+            "//tools/node_tools:node_modules/@rollup/plugin-node-resolve",
+            "//tools/node_tools:node_modules/@rollup/plugin-terser",
         ],
     )
 
-    rollup_bundle(
+    rollup(
         name = "service-worker",
         srcs = [app_name + "-full-src"],
+        node_modules = "//tools/node_tools:node_modules",
         args = [
             "--bundleConfigAsCjs=true",
         ],
         config_file = ":rollup.config.js",
         entry_point = "_pg_ts_out/workers/service-worker.js",
-        rollup_bin = "//tools/node_tools:rollup-bin",
         silent = True,
         sourcemap = "hidden",
         deps = [
-            "@tools_npm//@rollup/plugin-replace",
-            "@tools_npm//@rollup/plugin-node-resolve",
+            "//tools/node_tools:node_modules/@rollup/plugin-replace",
+            "//tools/node_tools:node_modules/@rollup/plugin-node-resolve",
+            "//tools/node_tools:node_modules/@rollup/plugin-terser",
         ],
     )
 
@@ -111,17 +116,14 @@
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
             "//lib/js:emojis__files",
-            "@ui_npm//@webcomponents/webcomponentsjs",
-            "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/package.json",
-            "@ui_npm//:node_modules/resemblejs/resemble.js",
-            "@ui_npm//@polymer/font-roboto-local",
-            "@ui_npm//:node_modules/@polymer/font-roboto-local/package.json",
+            "//polygerrit-ui/app:node_modules/resemblejs/dir",
+            "//polygerrit-ui/app:node_modules/@polymer/font-roboto-local/dir",
         ],
         outs = outs,
         cmd = " && ".join([
-            "FONT_DIR=$$(dirname $(location @ui_npm//:node_modules/@polymer/font-roboto-local/package.json))/fonts",
-            "WEBCOMPONENTJS_DIR=$$(dirname $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/package.json))",
-            "mkdir -p $$TMP/polygerrit_ui/{workers,styles/themes,fonts/{roboto,robotomono},bower_components/{emojis,highlightjs,webcomponentsjs,webcomponentsjs/bundles,resemblejs},elements}",
+            "FONT_DIR=$(location //polygerrit-ui/app:node_modules/@polymer/font-roboto-local/dir)/fonts",
+            "RESEMBLEJS_DIR=$(location //polygerrit-ui/app:node_modules/resemblejs/dir)",
+            "mkdir -p $$TMP/polygerrit_ui/{workers,styles/themes,fonts/{roboto,robotomono},bower_components/{emojis,highlightjs,resemblejs},elements}",
             "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + app_name + ".$$ext; done",
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "cp $(locations //lib/fonts:material-icons) $$TMP/polygerrit_ui/fonts/",
@@ -130,9 +132,7 @@
             "for f in $(locations " + name + "_worker_sources); do cp $$f $$TMP/polygerrit_ui/workers; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "for f in $(locations //lib/js:emojis__files); do cp $$f $$TMP/polygerrit_ui/bower_components/emojis/ ; done",
-            "cp $$WEBCOMPONENTJS_DIR/webcomponents-loader.js $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-loader.js",
-            "cp $$WEBCOMPONENTJS_DIR/bundles/* $$TMP/polygerrit_ui/bower_components/webcomponentsjs/bundles/",
-            "cp $(location @ui_npm//:node_modules/resemblejs/resemble.js) $$TMP/polygerrit_ui/bower_components/resemblejs/resemble.js",
+            "cp $$RESEMBLEJS_DIR/resemble.js $$TMP/polygerrit_ui/bower_components/resemblejs/resemble.js",
             "cp $$FONT_DIR/roboto/*.ttf $$TMP/polygerrit_ui/fonts/roboto/",
             "cp $$FONT_DIR/robotomono/*.ttf $$TMP/polygerrit_ui/fonts/robotomono/",
             "cd $$TMP",
diff --git a/polygerrit-ui/app/run_template_test.sh b/polygerrit-ui/app/run_template_test.sh
deleted file mode 100755
index d2b6989..0000000
--- a/polygerrit-ui/app/run_template_test.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/env bash
-
-if [[ -z "${TEMPLATE_NO_DEFAULT}" ]]; then
-bazel test \
-      --test_env="HOME=$HOME" \
-      //polygerrit-ui/app:all \
-      --test_tag_filters=template \
-      "$@" \
-      --test_output errors \
-      --nocache_test_results
-else
-bazel test \
-      --test_env="HOME=$HOME" \
-      "$@" \
-      --test_output errors \
-      --nocache_test_results
-fi
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.ts b/polygerrit-ui/app/scripts/bundled-polymer.ts
deleted file mode 100644
index ad183c1..0000000
--- a/polygerrit-ui/app/scripts/bundled-polymer.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-// This file is a replacement for the
-// polymer-bridges/polymer/polymer.html file. The polymer.html file loads
-// other scripts to setup different global variables. Because plugins
-// expects that Polymer is available we must setup all Polymer global
-// variables
-//
-// The bundled-polymer.js imports all scripts in the same order as the
-// polymer.html does and must be imported in all es6-modules instead
-// of the polymer.html file.
-
-import './js/bundled-polymer-bridges';
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
deleted file mode 100644
index 01ecf50..0000000
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-// We can't convert bundled-polymer.js to ts. To allow import
-// bundled-polymer.js from .ts files we should add this .d.ts file
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
deleted file mode 100644
index 494acd9..0000000
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-// This file can't be converted to TS - it imports some .js file which
-// can't be imported into typescript
-
-// This file is a replacement for the
-// polymer-bridges/polymer/polymer.html file. The polymer.html file loads
-// other scripts to setup different global variables. Because plugins
-// expects that Polymer is available we must setup all Polymer global
-// variables
-//
-// The bundled-polymer.js imports all scripts in the same order as the
-// polymer.html does and must be imported in all es6-modules instead
-// of the polymer.html file.
-
-import 'polymer-bridges/polymer/lib/utils/boot_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/resolve-url_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/settings_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-module_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/style-gather_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/path_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/case-map_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/async_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/wrap_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/properties-changed_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/property-accessors_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/template-stamp_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/property-effects_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/telemetry_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/properties-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/debounce_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/gestures_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/gesture-event-listeners_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/dir-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/render-status_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/unresolved_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/array-splice_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/flattened-nodes-observer_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/flush_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/polymer.dom_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/legacy-element-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/class_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/polymer-fn_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/mutable-data_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/templatize_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/templatizer-behavior_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-bind_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/html-tag_bridge.js';
-import 'polymer-bridges/polymer/polymer-element_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-repeat_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-if_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/array-selector_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
-import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
deleted file mode 100644
index 584d83a..0000000
--- a/polygerrit-ui/app/scripts/polymer-resin-install.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import 'polymer-resin/standalone/polymer-resin';
-
-export type SafeTypeBridge = (
-  value: unknown,
-  type: string,
-  fallback: unknown
-) => unknown;
-
-export type ReportHandler = (
-  isDisallowedValue: boolean,
-  printfFormatString: string,
-  ...printfArgs: unknown[]
-) => void;
-
-declare global {
-  interface Window {
-    security: {
-      polymer_resin: {
-        SafeType: {
-          CONSTANT: string;
-          HTML: string;
-          JAVASCRIPT: string;
-          RESOURCE_URL: string;
-          /** Unprivileged but possibly wrapped string. */
-          STRING: string;
-          STYLE: string;
-          URL: string;
-        };
-        CONSOLE_LOGGING_REPORT_HANDLER: ReportHandler;
-        install(options: {
-          UNSAFE_passThruDisallowedValues?: boolean;
-          allowedIdentifierPrefixes?: string[];
-          reportHandler?: ReportHandler;
-          safeTypesBridge?: SafeTypeBridge;
-        }): void;
-      };
-    };
-  }
-}
-
-const security = window.security;
-
-export const _testOnly_defaultResinReportHandler =
-  security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
-
-let resinInstalled = false;
-export function installPolymerResin(
-  safeTypesBridge: SafeTypeBridge,
-  reportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER
-) {
-  if (resinInstalled) {
-    return;
-  }
-  window.security.polymer_resin.install({
-    allowedIdentifierPrefixes: [''],
-    reportHandler,
-    safeTypesBridge,
-  });
-  resinInstalled = true;
-}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index e36b94f..4003b38 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -22,4 +22,6 @@
   ML_SUGGESTED_EDIT_FEEDBACK = 'UiFeature__ml_suggested_edit_feedback',
   ML_SUGGESTED_EDIT_EDITABLE_SUGGESTION = 'UiFeature__ml_suggested_edit_editable_suggestion',
   ML_SUGGESTED_EDIT_GET_FIX = 'UiFeature__ml_suggested_edit_get_fix',
+  DASHBOARD_LAZY_LOADING = 'UiFeature__dashboard_lazy_loading',
+  STACK_DIFF = 'UiFeature__stack_diff',
 }
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 2188e70..1cb848b 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1335,7 +1335,8 @@
     if (!changeNum) return;
     const optionsHex = listChangesOptionsToHex(
       ListChangesOption.ALL_REVISIONS,
-      ListChangesOption.ALL_FILES
+      ListChangesOption.ALL_FILES,
+      ListChangesOption.SKIP_DIFFSTAT
     );
 
     const change = await this.getChange(changeNum, undefined, optionsHex);
@@ -1775,6 +1776,38 @@
     }) as Promise<BranchInfo[] | undefined>;
   }
 
+  getProjectCommitDiff(
+    repo: RepoName,
+    commitId: CommitId,
+    baseCommitId: CommitId
+  ): Promise<FileNameToFileInfoMap | undefined> {
+    const encodeName = encodeURIComponent(repo);
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeName}/commits/${commitId}/diff`,
+      params: {
+        base: baseCommitId,
+        'name-only': true,
+      },
+      anonymizedUrl: '/projects/*/commits/*/diff',
+    }) as Promise<FileNameToFileInfoMap | undefined>;
+  }
+
+  getProjectCommitFileDiff(
+    repo: RepoName,
+    commitId: CommitId,
+    baseCommitId: CommitId,
+    fileId: string
+  ): Promise<DiffInfo | undefined> {
+    const encodeName = encodeURIComponent(repo);
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeName}/commits/${commitId}/files/${encodeURIComponent(
+        fileId
+      )}/diff`,
+      params: {base: baseCommitId},
+      anonymizedUrl: '/projects/*/commits/*/files/*/diff',
+    }) as Promise<DiffInfo | undefined>;
+  }
+
   getRepoTags(
     filter: string,
     repo: RepoName,
@@ -1823,6 +1856,19 @@
     }) as Promise<SubmitRequirementInfo[] | undefined>;
   }
 
+  getRepoSubmitRequirementTemplates(
+    repoName: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<SubmitRequirementInfo[] | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeURIComponent(
+        repoName
+      )}/submit_requirements_templates`,
+      errFn,
+      anonymizedUrl: '/projects/*/submit_requirements_templates',
+    }) as Promise<SubmitRequirementInfo[] | undefined>;
+  }
+
   createSubmitRequirement(
     repoName: RepoName,
     input: SubmitRequirementInput,
@@ -2211,7 +2257,8 @@
     const options = listChangesOptionsToHex(
       ListChangesOption.CURRENT_REVISION,
       ListChangesOption.CURRENT_COMMIT,
-      ListChangesOption.SUBMITTABLE
+      ListChangesOption.SUBMITTABLE,
+      ListChangesOption.SKIP_DIFFSTAT
     );
     const params = {
       O: options,
@@ -2231,7 +2278,8 @@
   ): Promise<ChangeInfo[] | undefined> {
     const options = listChangesOptionsToHex(
       ListChangesOption.CURRENT_REVISION,
-      ListChangesOption.CURRENT_COMMIT
+      ListChangesOption.CURRENT_COMMIT,
+      ListChangesOption.SKIP_DIFFSTAT
     );
     const query = [
       `project:${repo}`,
@@ -2262,7 +2310,8 @@
       ListChangesOption.CURRENT_REVISION,
       ListChangesOption.CURRENT_COMMIT,
       ListChangesOption.DETAILED_LABELS,
-      ListChangesOption.SUBMITTABLE
+      ListChangesOption.SUBMITTABLE,
+      ListChangesOption.SKIP_DIFFSTAT
     );
     const queryTerms = [`topic:${escapeAndWrapSearchOperatorValue(topic)}`];
     if (options?.openChangesOnly) {
@@ -2368,6 +2417,7 @@
         }),
         url,
         errFn,
+        isHighPriority: true,
       })
     ) as unknown as Promise<ReviewResult | undefined>;
   }
@@ -2514,7 +2564,8 @@
 
   async restoreFileInChangeEdit(
     changeNum: NumericChangeId,
-    restore_path: string
+    restore_path: string,
+    errFn?: ErrorCallback
   ): Promise<Response> {
     const url = await this._changeBaseURL(changeNum);
     return this._restApiHelper.fetch({
@@ -2523,6 +2574,7 @@
         body: {restore_path},
       }),
       url: `${url}/edit`,
+      errFn,
       anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit`,
       reportServerError: true,
     });
@@ -2547,7 +2599,8 @@
 
   async deleteFileInChangeEdit(
     changeNum: NumericChangeId,
-    path: string
+    path: string,
+    errFn?: ErrorCallback
   ): Promise<Response> {
     const url = await this._changeBaseURL(changeNum);
     return this._restApiHelper.fetch({
@@ -2555,13 +2608,15 @@
       url: `${url}/edit/${encodeURIComponent(path)}`,
       anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`,
       reportServerError: true,
+      errFn,
     });
   }
 
   async saveChangeEdit(
     changeNum: NumericChangeId,
     path: string,
-    contents: string
+    contents: string,
+    errFn?: ErrorCallback
   ): Promise<Response> {
     const url = await this._changeBaseURL(changeNum);
     return this._restApiHelper.fetch({
@@ -2571,6 +2626,7 @@
         contentType: 'text/plain',
       }),
       url: `${url}/edit/${encodeURIComponent(path)}`,
+      errFn,
       anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`,
       reportServerError: true,
     });
@@ -2615,6 +2671,7 @@
           }),
           url: `${url}/fix:preview`,
           anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:preview`,
+          useReadScheduler: true,
         })) as FilePathToDiffInfoMap | undefined;
 
         if (response === undefined) {
@@ -2644,11 +2701,15 @@
     );
     const body: {
       fix_replacement_infos: FixReplacementInfo[];
-      original_patchset_for_fix?: PatchSetNum;
+      original_patchset_for_fix?: number;
     } = {
       fix_replacement_infos: fixReplacementInfos,
     };
-    if (targetPatchNum !== undefined && targetPatchNum !== fixPatchNum) {
+    if (
+      targetPatchNum !== undefined &&
+      targetPatchNum !== fixPatchNum &&
+      typeof fixPatchNum === 'number'
+    ) {
       body.original_patchset_for_fix = fixPatchNum;
     }
     return this._restApiHelper.fetch({
@@ -2803,32 +2864,40 @@
   }
 
   getDiffComments(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    enableContext?: boolean
   ): Promise<{[path: string]: CommentInfo[]} | undefined>;
 
   getDiffComments(
     changeNum: NumericChangeId,
     basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
-    path: string
+    path: string,
+    enableContext?: boolean
   ): Promise<GetDiffCommentsOutput>;
 
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
+    basePatchNum?: BasePatchSetNum | boolean,
     patchNum?: PatchSetNum,
-    path?: string
+    path?: string,
+    enableContext?: boolean
   ) {
+    if (typeof basePatchNum === 'boolean') {
+      enableContext = basePatchNum;
+      basePatchNum = undefined;
+    }
+    const params =
+      enableContext !== false
+        ? {'enable-context': true, 'context-padding': 3}
+        : undefined;
     if (!basePatchNum && !patchNum && !path) {
-      return this._getDiffComments(changeNum, '/comments', {
-        'enable-context': true,
-        'context-padding': 3,
-      });
+      return this._getDiffComments(changeNum, '/comments', params);
     }
     return this._getDiffComments(
       changeNum,
       '/comments',
-      {'enable-context': true, 'context-padding': 3},
+      params,
       basePatchNum,
       patchNum,
       path
@@ -2836,14 +2905,15 @@
   }
 
   async getDiffDrafts(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    enableContext = true
   ): Promise<{[path: string]: DraftInfo[]} | undefined> {
     const loggedIn = await this.getLoggedIn();
     if (!loggedIn) return {};
-    const comments = await this._getDiffComments(changeNum, '/drafts', {
-      'enable-context': true,
-      'context-padding': 3,
-    });
+    const params = enableContext
+      ? {'enable-context': true, 'context-padding': 3}
+      : undefined;
+    const comments = await this._getDiffComments(changeNum, '/drafts', params);
     return addDraftProp(comments);
   }
 
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index 870723d..5f98513 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -306,6 +306,56 @@
     } as CommentInfo);
   });
 
+  test('getDiffComments with enableContext=false omits context params', async () => {
+    sinon.stub(element, 'getRepoName').resolves('test' as RepoName);
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves({} as unknown as ParsedJSON);
+    await element.getDiffComments(42 as NumericChangeId, false);
+    assert.isTrue(fetchStub.calledOnce);
+    const params = fetchStub.lastCall.args[0].params;
+    assert.isUndefined(params?.['enable-context']);
+    assert.isUndefined(params?.['context-padding']);
+  });
+
+  test('getDiffComments default includes context params', async () => {
+    sinon.stub(element, 'getRepoName').resolves('test' as RepoName);
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves({} as unknown as ParsedJSON);
+    await element.getDiffComments(42 as NumericChangeId);
+    assert.isTrue(fetchStub.calledOnce);
+    const params = fetchStub.lastCall.args[0].params;
+    assert.isTrue(params?.['enable-context']);
+    assert.equal(params?.['context-padding'], 3);
+  });
+
+  test('getDiffDrafts with enableContext=false omits context params', async () => {
+    sinon.stub(element, 'getLoggedIn').resolves(true);
+    sinon.stub(element, 'getRepoName').resolves('test' as RepoName);
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves({} as unknown as ParsedJSON);
+    await element.getDiffDrafts(42 as NumericChangeId, false);
+    assert.isTrue(fetchStub.calledOnce);
+    const params = fetchStub.lastCall.args[0].params;
+    assert.isUndefined(params?.['enable-context']);
+    assert.isUndefined(params?.['context-padding']);
+  });
+
+  test('getDiffDrafts default includes context params', async () => {
+    sinon.stub(element, 'getLoggedIn').resolves(true);
+    sinon.stub(element, 'getRepoName').resolves('test' as RepoName);
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves({} as unknown as ParsedJSON);
+    await element.getDiffDrafts(42 as NumericChangeId);
+    assert.isTrue(fetchStub.calledOnce);
+    const params = fetchStub.lastCall.args[0].params;
+    assert.isTrue(params?.['enable-context']);
+    assert.equal(params?.['context-padding'], 3);
+  });
+
   test('legacy n,z key in change url is replaced', async () => {
     const stub = sinon
       .stub(element._restApiHelper, 'fetchJSON')
@@ -2028,6 +2078,25 @@
       assert.deepEqual(body.fix_replacement_infos[0], fixReplacementInfo);
       assert.deepEqual(body.original_patchset_for_fix, 1);
     });
+
+    test('applyFixSuggestion with non-numeric fixPatchNum (EDIT) does not set original_patchset_for_fix', async () => {
+      const fixReplacementInfo = createFixReplacementInfo();
+      await element.applyFixSuggestion(
+        123 as NumericChangeId,
+        'edit' as PatchSetNum,
+        [fixReplacementInfo],
+        2 as PatchSetNum
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/2/fix:apply'
+      );
+      const body = JSON.parse(fetchStub.lastCall.args[0].fetchOptions.body);
+      assert.isTrue(Object.keys(body).length === 1);
+      assert.deepEqual(body.fix_replacement_infos[0], fixReplacementInfo);
+      assert.isUndefined(body.original_patchset_for_fix);
+    });
   });
 
   suite('getFixPreview', () => {
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index ccb1320..5bda207 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -25,6 +25,7 @@
   ChangeMessageId,
   CommentInfo,
   CommentInput,
+  CommitId,
   CommitInfo,
   ConfigInfo,
   ConfigInput,
@@ -209,6 +210,19 @@
     errFn?: ErrorCallback
   ): Promise<BranchInfo[] | undefined>;
 
+  getProjectCommitDiff(
+    repo: RepoName,
+    commitId: CommitId,
+    baseCommitId: CommitId
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
+  getProjectCommitFileDiff(
+    repo: RepoName,
+    commitId: CommitId,
+    baseCommitId: CommitId,
+    fileId: string
+  ): Promise<DiffInfo | undefined>;
+
   getChangeDetail(
     changeNum?: number | string,
     errFn?: ErrorCallback
@@ -294,12 +308,14 @@
 
   deleteFileInChangeEdit(
     changeNum: NumericChangeId,
-    path: string
+    path: string,
+    errFn?: ErrorCallback
   ): Promise<Response | undefined>;
 
   restoreFileInChangeEdit(
     changeNum: NumericChangeId,
-    restore_path: string
+    restore_path: string,
+    errFn?: ErrorCallback
   ): Promise<Response | undefined>;
 
   renameFileInChangeEdit(
@@ -320,6 +336,11 @@
     errFn?: ErrorCallback
   ): Promise<SubmitRequirementInfo[] | undefined>;
 
+  getRepoSubmitRequirementTemplates(
+    repoName: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<SubmitRequirementInfo[] | undefined>;
+
   createSubmitRequirement(
     repoName: RepoName,
     input: SubmitRequirementInput,
@@ -526,19 +547,22 @@
   ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   getDiffComments(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    enableContext?: boolean
   ): Promise<{[path: string]: CommentInfo[]} | undefined>;
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
+    basePatchNum: BasePatchSetNum,
     patchNum: PatchSetNum,
-    path: string
+    path: string,
+    enableContext?: boolean
   ): Promise<GetDiffCommentsOutput>;
   getDiffComments(
     changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
+    basePatchNum?: BasePatchSetNum | boolean,
     patchNum?: PatchSetNum,
-    path?: string
+    path?: string,
+    enableContext?: boolean
   ):
     | Promise<{[path: string]: CommentInfo[]} | undefined>
     | Promise<GetDiffCommentsOutput>;
@@ -549,7 +573,8 @@
    * empty object.
    */
   getDiffDrafts(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    enableContext?: boolean
   ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   createGroup(config: GroupInput): Promise<Response>;
@@ -718,7 +743,8 @@
   saveChangeEdit(
     changeNum: NumericChangeId,
     path: string,
-    contents: string
+    contents: string,
+    errFn?: ErrorCallback
   ): Promise<Response>;
   getRepoTags(
     filter: string,
diff --git a/polygerrit-ui/app/services/scheduler/fake-scheduler.ts b/polygerrit-ui/app/services/scheduler/fake-scheduler.ts
index d4df3ce..c9038c9 100644
--- a/polygerrit-ui/app/services/scheduler/fake-scheduler.ts
+++ b/polygerrit-ui/app/services/scheduler/fake-scheduler.ts
@@ -9,7 +9,15 @@
 export class FakeScheduler<T> implements Scheduler<T> {
   readonly scheduled: Array<FakeTask> = [];
 
-  schedule(task: Task<T>) {
+  get activeCount() {
+    return this.scheduled.length;
+  }
+
+  get activeRequests() {
+    return [];
+  }
+
+  schedule(task: Task<T>, _name?: string) {
     return new Promise<T>((resolve, reject) => {
       this.scheduled.push(async (error?: unknown) => {
         if (error) {
diff --git a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts
index 1febcb6..65e9e13 100644
--- a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts
+++ b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts
@@ -8,22 +8,36 @@
 export class MaxInFlightScheduler<T> implements Scheduler<T> {
   private inflight = 0;
 
-  private waiting: Array<Task<void>> = [];
+  private waiting: Array<{task: Task<void>; name?: string}> = [];
+
+  private readonly running: string[] = [];
+
+  get activeCount(): number {
+    return this.running.length + this.waiting.length;
+  }
+
+  get activeRequests(): string[] {
+    const waitingNames = this.waiting.map(w => w.name || 'unknown');
+    return [...this.running, ...waitingNames];
+  }
 
   constructor(
     private readonly base: Scheduler<T>,
     private maxInflight: number = 10
   ) {}
 
-  async schedule(task: Task<T>): Promise<T> {
+  async schedule(task: Task<T>, name?: string): Promise<T> {
     return new Promise<T>((resolve, reject) => {
-      this.waiting.push(async () => {
-        try {
-          const result = await this.base.schedule(task);
-          resolve(result);
-        } catch (e: unknown) {
-          reject(e);
-        }
+      this.waiting.push({
+        task: async () => {
+          try {
+            const result = await this.base.schedule(task, name);
+            resolve(result);
+          } catch (e: unknown) {
+            reject(e);
+          }
+        },
+        name,
       });
       this.next();
     });
@@ -32,10 +46,14 @@
   private next() {
     if (this.inflight >= this.maxInflight) return;
     if (this.waiting.length === 0) return;
-    const task = this.waiting.shift() as Task<void>;
+    const {task, name} = this.waiting.shift()!;
     ++this.inflight;
+    const taskName = name || 'unknown';
+    this.running.push(taskName);
     task().finally(() => {
       --this.inflight;
+      const index = this.running.indexOf(taskName);
+      if (index > -1) this.running.splice(index, 1);
       this.next();
     });
   }
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
index d96fc0b..67842a0 100644
--- a/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
@@ -33,15 +33,26 @@
     private backoffFactor: number = 1.618
   ) {}
 
-  async schedule(task: Task<T>): Promise<T> {
+  get activeCount(): number {
+    return this.base.activeCount;
+  }
+
+  get activeRequests(): string[] {
+    return this.base.activeRequests;
+  }
+
+  async schedule(task: Task<T>, name?: string): Promise<T> {
     let tries = 0;
     let timeout = this.backoffIntervalMs;
 
     const worker: Task<T> = async () => {
       try {
-        return await this.base.schedule(task);
+        return await this.base.schedule(task, name);
       } catch (e: unknown) {
         if (e instanceof RetryError && tries++ < this.maxRetry) {
+          console.info(
+            `[RetryScheduler] Retrying task, try ${tries} after ${timeout}ms`
+          );
           await untilTimeout(timeout);
           timeout = timeout * this.backoffFactor;
           return await worker();
diff --git a/polygerrit-ui/app/services/scheduler/scheduler.ts b/polygerrit-ui/app/services/scheduler/scheduler.ts
index b834ab3..da0c3e4 100644
--- a/polygerrit-ui/app/services/scheduler/scheduler.ts
+++ b/polygerrit-ui/app/services/scheduler/scheduler.ts
@@ -5,10 +5,20 @@
  */
 export type Task<T> = () => Promise<T>;
 export interface Scheduler<T> {
-  schedule(task: Task<T>): Promise<T>;
+  schedule(task: Task<T>, name?: string): Promise<T>;
+  get activeCount(): number;
+  get activeRequests(): string[];
 }
 export class BaseScheduler<T> implements Scheduler<T> {
-  schedule(task: Task<T>) {
+  schedule(task: Task<T>, _name?: string) {
     return task();
   }
+
+  get activeCount() {
+    return 0;
+  }
+
+  get activeRequests() {
+    return [];
+  }
 }
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index 446d2d9..331fe59 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -411,9 +411,13 @@
     'Hide/show left diff',
     {key: 'A'}
   );
-  describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
-    key: 'c',
-  });
+  describe(
+    Shortcut.NEW_COMMENT,
+    ShortcutSection.DIFFS,
+    'Draft new comment',
+    {key: 'c'},
+    {key: 'C'}
+  );
   describe(
     Shortcut.SAVE_COMMENT,
     ShortcutSection.DIFFS,
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index a73afc4..cfc00ce 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -126,8 +126,12 @@
   .truncatedRepo {
     display: none;
   }
+  .hashtags {
+    white-space: nowrap;
+  }
   @media only screen and (max-width: 150em) {
-    .branch {
+    .branch,
+    .hashtags {
       overflow: hidden;
       max-width: 18rem;
       text-overflow: ellipsis;
@@ -140,7 +144,8 @@
     }
   }
   @media only screen and (max-width: 100em) {
-    .branch {
+    .branch,
+    .hashtags {
       max-width: 10rem;
     }
   }
@@ -182,6 +187,7 @@
     .status,
     .repo,
     .branch,
+    .hashtags,
     .updated,
     .submitted,
     .waiting,
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
index 67ee146..72351d7 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -4,58 +4,40 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+import {css} from 'lit';
 
-const $_documentContainer = document.createElement('template');
-
-/*
-  These are shared styles for change-view-integration endpoints.
-  All plugins that registered that endpoint should include this in
-  the component to have a consistent UX:
-
-  <style include="gr-change-view-integration-shared-styles"></style>
-
-  And use those defined class to apply these styles.
-*/
-$_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
-  <template>
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
-    <style>
-      :host {
-        border-top: 1px solid var(--border-color);
-        display: block;
-      }
-      .header {
-        color: var(--primary-text-color);
-        background-color: var(--table-header-background-color);
-        justify-content: space-between;
-        padding: var(--spacing-m) var(--spacing-l);
-        border-bottom: 1px solid var(--border-color);
-      }
-      .header .label {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-        margin: 0 var(--spacing-l) 0 0;
-      }
-      .header .note {
-        color: var(--deemphasized-text-color);
-      }
-      .content {
-        background-color: var(--view-background-color);
-      }
-      .header a,
-      .content a {
-        color: var(--link-color);
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
+/**
+ * Shared styles for change-view integration.
+ * This provides the core styling and overrides used by external
+ * plugins or components that integrate closely with the Change View.
+ */
+export const changeViewIntegrationStyles = css`
+  :host {
+    border-top: 1px solid var(--border-color);
+    display: block;
+  }
+  .header {
+    color: var(--primary-text-color);
+    background-color: var(--table-header-background-color);
+    justify-content: space-between;
+    padding: var(--spacing-m) var(--spacing-l);
+    border-bottom: 1px solid var(--border-color);
+  }
+  .header .label {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+    margin: 0 var(--spacing-l) 0 0;
+  }
+  .header .note {
+    color: var(--deemphasized-text-color);
+  }
+  .content {
+    background-color: var(--view-background-color);
+  }
+  .header a,
+  .content a {
+    color: var(--link-color);
+  }
+`;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index a614e4d..d4a703d 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -436,6 +436,8 @@
     /* This is just an initial value. Will be updated by a ResizeObserver. */
     --main-header-height: 48px;
     --main-footer-height: 36px;
+    --change-header-height: 0px;
+    --diff-header-height: 0px;
 
     /* diff colors */
     --dark-add-highlight-color: #aaf2aa;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index dc77649..9846589 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -3,9 +3,6 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
-// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
-import '../scripts/bundled-polymer';
 import {getAppContext} from '../services/app-context';
 import {
   createTestAppContext,
@@ -17,16 +14,11 @@
   getCleanupsCount,
   removeThemeStyles,
 } from './test-utils';
-import {safeTypesBridge} from '../utils/safe-types-util';
 import {
   initGerrit,
   initGlobalVariables,
 } from '../elements/gr-app-global-var-init';
 import {assert, fixtureCleanup} from '@open-wc/testing';
-import {
-  _testOnly_defaultResinReportHandler,
-  installPolymerResin,
-} from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 import {
@@ -54,19 +46,6 @@
 window.litIssuedWarnings = window.litIssuedWarnings || new Set();
 window.litIssuedWarnings.add('dev-mode');
 
-installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => {
-  // Suppress 'initResin' log message from polymer-resin.
-  if (fmt === 'initResin') {
-    return;
-  }
-  const log = _testOnly_defaultResinReportHandler;
-  log(isViolation, fmt, ...args);
-  if (isViolation) {
-    // This will cause the test to fail if there is a data binding violation.
-    throw new Error('polymer-resin violation: ' + fmt + JSON.stringify(args));
-  }
-});
-
 let testSetupTimestampMs = 0;
 let currentTestName = '';
 
@@ -103,7 +82,8 @@
   evt.callback(() => testResolver(evt.dependency));
 }
 
-setup(function () {
+setup(async function () {
+  await document.fonts?.ready;
   testSetupTimestampMs = new Date().getTime();
   currentTestName = this.currentTest?.title || 'unknown test';
 
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 083f75e..fe87db1 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -410,6 +410,11 @@
   getRepoSubmitRequirements(): Promise<SubmitRequirementInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getRepoSubmitRequirementTemplates(): Promise<
+    SubmitRequirementInfo[] | undefined
+  > {
+    return Promise.resolve([]);
+  },
   createSubmitRequirement(): Promise<SubmitRequirementInfo | undefined> {
     return Promise.resolve(undefined);
   },
@@ -446,6 +451,12 @@
   getRepoBranches(): Promise<BranchInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getProjectCommitDiff(): Promise<FileNameToFileInfoMap | undefined> {
+    return Promise.resolve(undefined);
+  },
+  getProjectCommitFileDiff(): Promise<DiffInfo | undefined> {
+    return Promise.resolve(undefined);
+  },
   getRepoDashboards(): Promise<DashboardInfo[] | undefined> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index a2d8694..a22d0a9 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -1840,6 +1840,29 @@
   attemptDetails: [],
 };
 
+export const checkRun6: CheckRun = {
+  pluginName: 'f6',
+  internalRunId: 'f6',
+  checkName: 'FAKE Run with no link URL',
+  status: RunStatus.SCHEDULED,
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f0r0',
+      category: Category.ERROR,
+      summary: 'I would like to point out this error: 1 is not equal to 2!',
+      links: [
+        {primary: true, url: '', icon: LinkIcon.EXTERNAL},
+        {primary: false, url: '  ', icon: LinkIcon.EXTERNAL},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+      ],
+      tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
+    },
+  ],
+};
+
 export function setAllcheckRuns(model: ChecksModel) {
   model.updateStateSetProvider('f0', ChecksPatchset.LATEST);
   model.updateStateSetProvider('f1', ChecksPatchset.LATEST);
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index a70d02b..4ada9b8 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -329,7 +329,10 @@
 ) {
   applyDarkTheme();
   document.documentElement.classList.add('darkTheme');
-  await visualDiff(element, `${name}-dark`);
-  removeDarkTheme();
-  document.documentElement.classList.remove('darkTheme');
+  try {
+    await visualDiff(element, `${name}-dark`);
+  } finally {
+    removeDarkTheme();
+    document.documentElement.classList.remove('darkTheme');
+  }
 }
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index eab8759..d4290aa 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -34,7 +34,7 @@
     /* Module Resolution Options */
     "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
     "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
-    "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+    "preserveSymlinks": false, /* Do not resolve the real path of symlinks. */
 
     /* Advanced Options */
     "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index 730fc4d..8cd5dfb 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -1,6 +1,10 @@
 {
   "extends": "./tsconfig.json",
   "compilerOptions": {
+    // Override the yarn/IDE outDir from tsconfig.json: rules_ts requires the
+    // output directory to stay within the Bazel package (kept in sync with
+    // out_dir of the compile_pg ts_project in the BUILD file).
+    "outDir": "_pg_ts_out",
     "typeRoots": [
       "../../external/ui_npm/node_modules/@types",
       "../../external/ui_dev_npm/node_modules/@types"
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index c6a940b..e6fc2cc 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -1,15 +1,17 @@
 {
   "extends": "./tsconfig_bazel.json",
   "compilerOptions": {
+    // Override the yarn/IDE outDir from tsconfig.json: rules_ts requires the
+    // output directory to stay within the Bazel package (kept in sync with
+    // out_dir of the compile_pg_with_tests ts_project).
+    "outDir": "_pg_with_tests_out",
     "typeRoots": [
-      "../../external/ui_npm/node_modules/@types",
-      "../../external/ui_dev_npm/node_modules/@types"
-    ]
+      "node_modules/@types",
+      "../node_modules/@types"
+    ],
+    "types": ["mocha"]
   },
   "include": [
-    // Items below must be in sync with the src_dirs list in the BUILD file
-    // Also items must be in sync with tsconfig.json, tsconfig_test.json
-    // (include and exclude arrays are overridden when extends)
     "api/**/*",
     "constants/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 435be97..8aa53b7 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -30,6 +30,7 @@
   ActionNameToActionInfoMap,
   ApprovalInfo,
   AuthInfo,
+  AUTO_MERGE,
   AvatarInfo,
   Base64FileContent,
   BasePatchSetNum,
@@ -65,6 +66,7 @@
   EmailInfo,
   FetchInfo,
   FileInfo,
+  FIRST_PARENT,
   FixId,
   FixReplacementInfo,
   FixSuggestionInfo,
@@ -125,7 +127,7 @@
   VotingRangeInfo,
   WebLinkInfo,
 } from '../api/rest-api';
-import {DiffInfo, IgnoreWhitespaceType} from './diff';
+import {DiffInfo, DiffResponsiveMode, IgnoreWhitespaceType} from './diff';
 import {LineNumber, PatchRange} from '../api/diff';
 
 export type {
@@ -163,6 +165,7 @@
   ContextLine,
   ContributorAgreementInfo,
   DetailedLabelInfo,
+  DiffResponsiveMode,
   DownloadInfo,
   DownloadSchemeInfo,
   EditPatchSet,
@@ -226,7 +229,14 @@
   VotingRangeInfo,
   WebLinkInfo,
 };
-export {EDIT, PARENT, isDetailedLabelInfo, isQuickLabelInfo};
+export {
+  AUTO_MERGE,
+  EDIT,
+  PARENT,
+  FIRST_PARENT,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+};
 
 /*
  * In T, make a set of properties whose keys are in the union K required
@@ -947,6 +957,7 @@
   message?: string;
   tag?: string;
   unresolved?: boolean;
+  is_ai?: boolean;
   fix_suggestions?: FixSuggestionInfo[];
 }
 
@@ -1004,6 +1015,7 @@
   tab_size?: number;
   font_size?: number;
   line_wrapping?: boolean;
+  responsive_mode?: DiffResponsiveMode;
   indent_with_tabs?: boolean;
 }
 
@@ -1077,6 +1089,7 @@
   allow_autocompleting_comments?: boolean;
   diff_page_sidebar?: DiffPageSidebar;
   ai_chat_selected_model?: string;
+  label_filter?: string;
 }
 
 /**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 8dde4f4..2a2ec16 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -43,6 +43,7 @@
     'confirm': CustomEvent<{}>;
     // prettier-ignore
     'drop': DropEvent;
+    'explain-code-requested': ExplainCodeRequestedEvent;
     'hide-alert': CustomEvent<{}>;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
@@ -109,7 +110,6 @@
 
 export type ChangeEvent = InputEvent;
 
-// TODO: This event seems to be unused (no listener). Remove?
 export type ChangedEvent = CustomEvent<string | undefined>;
 
 export interface ChangeMessageDeletedEventDetail {
@@ -262,3 +262,9 @@
 export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
 
 export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
+
+export interface ExplainCodeRequestedEventDetail {
+  prompt?: string;
+}
+export type ExplainCodeRequestedEvent =
+  CustomEvent<ExplainCodeRequestedEventDetail>;
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 37f31b1..8c6ed4e 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -5,6 +5,7 @@
  */
 import {ParsedJSON} from './common';
 import {HighlightJS} from './types';
+import {css, html, LitElement} from 'lit';
 
 export {};
 
@@ -12,6 +13,9 @@
   interface Window {
     CANONICAL_PATH?: string;
     INITIAL_DATA?: {[key: string]: ParsedJSON};
+    LitElement?: typeof LitElement;
+    html?: typeof html;
+    css?: typeof css;
     HTMLImports?: {whenReady: (cb: () => void) => void};
     linkify(
       text: string,
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 4b0c1ab..ef08cf0 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -96,15 +96,6 @@
   const path1 = c1.path ?? '';
   const path2 = c2.path ?? '';
   if (path1 !== path2) {
-    // TODO: Why is this logic not part of specialFilePathCompare()?
-    // '/PATCHSET' will not come before '/COMMIT' when sorting
-    // alphabetically so move it to the front explicitly
-    if (path1 === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-      return -1;
-    }
-    if (path2 === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-      return 1;
-    }
     return specialFilePathCompare(path1, path2);
   }
 
@@ -211,7 +202,7 @@
     path: replyingTo.path,
     patch_set: replyingTo.patch_set,
     side: replyingTo.side,
-    line: replyingTo.line,
+    line: replyingTo.range ? replyingTo.range.end_line : replyingTo.line,
     range: replyingTo.range,
     parent: replyingTo.parent,
     in_reply_to: replyingTo.id,
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index e8fe606..abda42c 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -8,6 +8,7 @@
   computeDisplayLine,
   createCommentThreads,
   createNew,
+  createNewReply,
   createUserFixSuggestion,
   getContentInCommentRange,
   getMentionedThreads,
@@ -748,4 +749,36 @@
       assert.equal(computeDisplayLine({}), '');
     });
   });
+
+  suite('createNewReply', () => {
+    test('standard comment reply (no range)', () => {
+      const replyingTo = {
+        ...createComment(),
+        id: 'parent_id' as UrlEncodedCommentId,
+        line: 5,
+      };
+      const reply = createNewReply(replyingTo, 'reply message', false);
+      assert.equal(reply.line, 5);
+      assert.isUndefined(reply.range);
+      assert.equal(reply.in_reply_to, 'parent_id');
+    });
+
+    test('reply to comment with range (mismatched line)', () => {
+      const replyingTo = {
+        ...createComment(),
+        id: 'parent_id' as UrlEncodedCommentId,
+        line: 1,
+        range: {
+          start_line: 1,
+          start_character: 0,
+          end_line: 2,
+          end_character: 5,
+        },
+      };
+      const reply = createNewReply(replyingTo, 'reply message', false);
+      assert.equal(reply.line, 2); // Should match range.end_line, not replyingTo.line
+      assert.deepEqual(reply.range, replyingTo.range);
+      assert.equal(reply.in_reply_to, 'parent_id');
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util.ts b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
index 6d80a39..f97611a 100644
--- a/polygerrit-ui/app/utils/commit-message-formatter-util.ts
+++ b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
@@ -32,7 +32,7 @@
 const MAX_LINE_LENGTH = 72;
 const INDENTATION_THRESHOLD = 4;
 const BULLET_POINT_REGEX = /^\s*[-+*#]\s/;
-const FOOTER_REGEX = /^([\w-]+):[ \t]+(.+)$/;
+const FOOTER_REGEX = /^([\w-]+)(?::[ \t]+|=)(.*)$/;
 
 /*
  * Check if last line of "Body" follows the "footer" format and if yes, then transfer it to the "footer section"
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts b/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts
index b4ba84b..bb8ab84 100644
--- a/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts
+++ b/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts
@@ -100,6 +100,42 @@
       );
     });
 
+    test('footers with equals sign (TAG=agy, CONV=...) are not split or merged', () => {
+      const message =
+        'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\nChange-Id: abcdefg\n';
+      assert.equal(
+        formatCommitMessageString(message),
+        'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\nChange-Id: abcdefg\n'
+      );
+    });
+
+    test('footers with equals sign separated by a blank line', () => {
+      const message =
+        'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\n\nChange-Id: abcdefg\n';
+      assert.equal(
+        formatCommitMessageString(message),
+        'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\nChange-Id: abcdefg\n'
+      );
+    });
+
+    test('footers with equals sign only are recognized as footers', () => {
+      const message =
+        'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\n';
+      assert.equal(
+        formatCommitMessageString(message),
+        'Fix the thing\n\nThis is the body.\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048\n'
+      );
+    });
+
+    test('chromium-style footers with equals sign are preserved', () => {
+      const message =
+        'Add new feature\n\nThis is a long description of the feature that should be wrapped across multiple lines if needed.\n\nBUG=chromium:12345\nTEST=browser_tests\nR=reviewer@chromium.org\n';
+      assert.equal(
+        formatCommitMessageString(message),
+        'Add new feature\n\nThis is a long description of the feature that should be wrapped across\nmultiple lines if needed.\n\nBUG=chromium:12345\nTEST=browser_tests\nR=reviewer@chromium.org\n'
+      );
+    });
+
     test('indented lines are untouched', () => {
       const message =
         'Fix the thing\n\n    This is an indented line.\n        This is another indented line.\n\nChange-Id: abcdefg\n';
@@ -505,5 +541,18 @@
         'footer with at least one proper format line should be kept as footer'
       );
     });
+
+    test('footer with equals sign format line is kept as footer', () => {
+      assertParseResult(
+        'Subject\n\nBody line\n\nTAG=agy\nCONV=fe186e29-8ffb-4bb8-a778-d48e3c804048',
+        {
+          subject: 'Subject',
+          body: ['Body line'],
+          footer: ['TAG=agy', 'CONV=fe186e29-8ffb-4bb8-a778-d48e3c804048'],
+          hasTrailingBlankLine: false,
+        },
+        'footer with equals sign format line should be kept as footer'
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts
index b6b51a0..56313b8 100644
--- a/polygerrit-ui/app/utils/diff-util.ts
+++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -117,3 +117,19 @@
     !!diff?.meta_b?.content_type.startsWith('image/')
   );
 }
+
+const MARKDOWN_FILE_EXTENSIONS = /\.(md|markdown|mdown|mkdn|mkd)$/i;
+
+export function isMarkdownDiff(path?: string, diff?: DiffInfo): boolean {
+  if (path && MARKDOWN_FILE_EXTENSIONS.test(path)) {
+    return true;
+  }
+  const contentTypeA = diff?.meta_a?.content_type;
+  const contentTypeB = diff?.meta_b?.content_type;
+  return (
+    !!contentTypeA?.startsWith('text/x-markdown') ||
+    !!contentTypeB?.startsWith('text/x-markdown') ||
+    !!contentTypeA?.startsWith('text/markdown') ||
+    !!contentTypeB?.startsWith('text/markdown')
+  );
+}
diff --git a/polygerrit-ui/app/utils/diff-util_test.ts b/polygerrit-ui/app/utils/diff-util_test.ts
index 838fab3..becae05 100644
--- a/polygerrit-ui/app/utils/diff-util_test.ts
+++ b/polygerrit-ui/app/utils/diff-util_test.ts
@@ -11,6 +11,7 @@
   getContentFromDiff,
   isFileUnchanged,
   isLineUnchanged,
+  isMarkdownDiff,
 } from './diff-util';
 
 suite('diff-util tests', () => {
@@ -196,4 +197,69 @@
       assert.equal(getContentFromDiff(diff, 18, 1, 18, 3, Side.RIGHT), 'xc');
     });
   });
+
+  suite('isMarkdownDiff()', () => {
+    test('detects markdown extensions', () => {
+      assert.isTrue(isMarkdownDiff('foo/bar/README.md'));
+      assert.isTrue(isMarkdownDiff('SKILL.md'));
+      assert.isTrue(isMarkdownDiff('doc.markdown'));
+      assert.isTrue(isMarkdownDiff('notes.mdown'));
+      assert.isTrue(isMarkdownDiff('file.mkd'));
+      assert.isFalse(isMarkdownDiff('code.ts'));
+      assert.isFalse(isMarkdownDiff('image.png'));
+      assert.isFalse(isMarkdownDiff(undefined));
+    });
+
+    test('detects markdown content types', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        meta_a: {
+          name: 'doc',
+          content_type: 'text/x-markdown',
+          lines: 10,
+        },
+      };
+      assert.isTrue(isMarkdownDiff('doc', diff));
+
+      const diffB: DiffInfo = {
+        ...createDiff(),
+        meta_b: {
+          name: 'doc',
+          content_type: 'text/markdown',
+          lines: 10,
+        },
+      };
+      assert.isTrue(isMarkdownDiff('doc', diffB));
+
+      const diffCharset: DiffInfo = {
+        ...createDiff(),
+        meta_b: {
+          name: 'doc',
+          content_type: 'text/markdown; charset=utf-8',
+          lines: 10,
+        },
+      };
+      assert.isTrue(isMarkdownDiff('doc', diffCharset));
+
+      const diffXCharset: DiffInfo = {
+        ...createDiff(),
+        meta_a: {
+          name: 'doc',
+          content_type: 'text/x-markdown; charset=utf-8',
+          lines: 10,
+        },
+      };
+      assert.isTrue(isMarkdownDiff('doc', diffXCharset));
+
+      const diffOther: DiffInfo = {
+        ...createDiff(),
+        meta_a: {
+          name: 'code',
+          content_type: 'text/plain',
+          lines: 10,
+        },
+      };
+      assert.isFalse(isMarkdownDiff('code', diffOther));
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/flows-util.ts b/polygerrit-ui/app/utils/flows-util.ts
index cb069ee..cc62ade 100644
--- a/polygerrit-ui/app/utils/flows-util.ts
+++ b/polygerrit-ui/app/utils/flows-util.ts
@@ -8,6 +8,7 @@
 import {capitalizeFirstLetter} from './string-util';
 
 export const STAGE_SEPARATOR = ';';
+export const EMAIL_PATTERN = /\S+@\S+\.\S+/;
 
 export interface Stage {
   condition: string;
@@ -45,7 +46,9 @@
  * Converts snake_case (e.g., 'add_reviewer') to Title Case (e.g., 'Add Reviewer').
  */
 export function formatActionName(name?: string): string {
-  if (!name) return '';
+  if (!name) {
+    return '';
+  }
   return name
     .split('_')
     .map(word => capitalizeFirstLetter(word))
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index ee7aa3a..7347402 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -342,6 +342,8 @@
       return {icon: 'error', filled: true};
     case SubmitRequirementStatus.FORCED:
       return {icon: 'check_circle', filled: true};
+    case SubmitRequirementStatus.TIMEOUT:
+      return {icon: 'block'};
     default:
       assertNever(status, `Unsupported status: ${status}`);
   }
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index 2f12bd7..5e5a023 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {CommentLinkInfo, CommentLinks} from '../types/common';
-import {getBaseUrl} from './url-util';
+import {getBaseUrl, sameOrigin} from './url-util';
 
 /**
  * Finds links within the base string and convert them to HTML. Config-based
@@ -39,7 +39,7 @@
     let match: RegExpExecArray | null;
 
     while ((match = regexp.exec(base)) !== null) {
-      const fullReplacementText = getReplacementText(match[0], rewrite);
+      const fullReplacementText = getReplacementText(match, rewrite);
       // The replacement may not be changing the entire matched substring so we
       // "trim" the replacement position and text to the part that is actually
       // different. This makes sure that unchanged portions are still eligible
@@ -104,26 +104,41 @@
   );
 }
 
+function resolveTemplate(template: string, match: RegExpExecArray): string {
+  return template.replace(/\$(\$|&|\d+)/g, (placeholder, p1) => {
+    if (p1 === '$') return '$';
+    if (p1 === '&') return match[0];
+    const index = parseInt(p1, 10);
+    return index < match.length ? match[index] ?? '' : placeholder;
+  });
+}
+
 /**
  * For a given regexp match, apply the rewrite based on the rewrite's type and
  * return the resulting string.
  */
 function getReplacementText(
-  matchedText: string,
+  match: RegExpExecArray,
   rewrite: CommentLinkInfo
 ): string {
-  const replacementHref = rewrite.link.startsWith('/')
-    ? `${getBaseUrl()}${rewrite.link}`
-    : rewrite.link;
-  const regexp = new RegExp(rewrite.match, 'g');
-  return matchedText.replace(
-    regexp,
-    createLinkTemplate(
-      replacementHref,
-      rewrite.text ?? '$&',
-      rewrite.prefix,
-      rewrite.suffix
-    )
+  const resolvedHref = resolveTemplate(rewrite.link, match);
+  const resolvedText = resolveTemplate(rewrite.text ?? '$&', match);
+  const resolvedPrefix = rewrite.prefix
+    ? resolveTemplate(rewrite.prefix, match)
+    : undefined;
+  const resolvedSuffix = rewrite.suffix
+    ? resolveTemplate(rewrite.suffix, match)
+    : undefined;
+
+  const replacementHref = resolvedHref.startsWith('/')
+    ? `${getBaseUrl()}${resolvedHref}`
+    : resolvedHref;
+
+  return createLinkTemplate(
+    replacementHref,
+    resolvedText,
+    resolvedPrefix,
+    resolvedSuffix
   );
 }
 
@@ -133,9 +148,10 @@
   prefix?: string,
   suffix?: string
 ) {
-  return `${
-    prefix ?? ''
-  }<a href="${href}" rel="noopener noreferrer" target="_blank">${displayText}</a>${
+  const attributes = sameOrigin(href)
+    ? ''
+    : ' rel="noopener noreferrer" target="_blank"';
+  return `${prefix ?? ''}<a href="${href}"${attributes}>${displayText}</a>${
     suffix ?? ''
   }`;
 }
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index 7b09320..c73a8a22 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -12,16 +12,20 @@
     return `<a href="${href}" rel="noopener noreferrer" target="_blank">${text}</a>`;
   }
 
+  function internalLink(text: string, href: string) {
+    return `<a href="${href}">${text}</a>`;
+  }
+
   suite('link rewrites', () => {
     test('without text', () => {
       assert.equal(
         linkifyUrlsAndApplyRewrite('foo', {
           fooLinkWithoutText: {
             match: 'foo',
-            link: 'foo.gov',
+            link: 'http://foo.gov',
           },
         }),
-        link('foo', 'foo.gov')
+        link('foo', 'http://foo.gov')
       );
     });
 
@@ -30,11 +34,11 @@
         linkifyUrlsAndApplyRewrite('foo', {
           fooLinkWithText: {
             match: 'foo',
-            link: 'foo.gov',
+            link: 'http://foo.gov',
             text: 'foo site',
           },
         }),
-        link('foo site', 'foo.gov')
+        link('foo site', 'http://foo.gov')
       );
     });
 
@@ -43,13 +47,13 @@
         linkifyUrlsAndApplyRewrite('there are 12 foos here', {
           fooLinkWithText: {
             match: '(.*)(bug|foo)s(.*)',
-            link: '$2.gov',
+            link: 'http://$2.gov',
             text: '$2 list',
             prefix: '$1on the ',
             suffix: '$3',
           },
         }),
-        `there are 12 on the ${link('foo list', 'foo.gov')} here`
+        `there are 12 on the ${link('foo list', 'http://foo.gov')} here`
       );
     });
 
@@ -58,10 +62,37 @@
         linkifyUrlsAndApplyRewrite('foo foo', {
           foo: {
             match: 'foo',
-            link: 'foo.gov',
+            link: 'http://foo.gov',
           },
         }),
-        `${link('foo', 'foo.gov')} ${link('foo', 'foo.gov')}`
+        `${link('foo', 'http://foo.gov')} ${link('foo', 'http://foo.gov')}`
+      );
+    });
+
+    test('boundary match with trailing non-word character in match group', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('Flag: build.RELEASE', {
+          flag: {
+            match: '(^|\\s)([fF][lL][aA][gG][:=]\\s*)([a-z0-9_\\.]+)\\b',
+            link: 'http://flag/$3',
+            prefix: '$1$2',
+            text: '$3',
+          },
+        }),
+        `Flag: ${link('build.', 'http://flag/build.')}RELEASE`
+      );
+    });
+
+    test('unmatched placeholder preserved when index exceeds groups', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('item 123', {
+          item: {
+            match: 'item (\\d+)',
+            link: 'http://item/$1?price=$500',
+            text: '$1 cost $500',
+          },
+        }),
+        link('123 cost $500', 'http://item/123?price=$500')
       );
     });
   });
@@ -71,18 +102,18 @@
       linkifyUrlsAndApplyRewrite('foobarbaz', {
         foo: {
           match: 'foo',
-          link: 'foo.gov',
+          link: 'http://foo.gov',
         },
         foobarbaz: {
           match: 'foobarbaz',
-          link: 'foobarbaz.gov',
+          link: 'http://foobarbaz.gov',
         },
         foobar: {
           match: 'foobar',
-          link: 'foobar.gov',
+          link: 'http://foobar.gov',
         },
       }),
-      link('foobarbaz', 'foobarbaz.gov')
+      link('foobarbaz', 'http://foobarbaz.gov')
     );
   });
 
@@ -91,18 +122,18 @@
       linkifyUrlsAndApplyRewrite('foobarbaz', {
         foo: {
           match: 'baz',
-          link: 'Baz.gov',
+          link: 'http://Baz.gov',
         },
         foobarbaz: {
           match: 'foobarbaz',
-          link: 'FooBarBaz.gov',
+          link: 'http://FooBarBaz.gov',
         },
         foobar: {
           match: 'barbaz',
-          link: 'BarBaz.gov',
+          link: 'http://BarBaz.gov',
         },
       }),
-      link('foobarbaz', 'FooBarBaz.gov')
+      link('foobarbaz', 'http://FooBarBaz.gov')
     );
   });
 
@@ -111,18 +142,18 @@
       linkifyUrlsAndApplyRewrite('foobarbaz', {
         foo: {
           match: 'foo',
-          link: 'FOO',
+          link: 'http://FOO',
         },
         oobarba: {
           match: 'oobarba',
-          link: 'OOBARBA',
+          link: 'http://OOBARBA',
         },
         baz: {
           match: 'baz',
-          link: 'BAZ',
+          link: 'http://BAZ',
         },
       }),
-      `${link('foo', 'FOO')}bar${link('baz', 'BAZ')}`
+      `${link('foo', 'http://FOO')}bar${link('baz', 'http://BAZ')}`
     );
   });
 
@@ -148,10 +179,10 @@
           text: 'bug/$4',
         },
       }),
-      `bugs: ${link('bug/123', 'bug/123')} ${link('bug/234', 'bug/234')} ${link(
-        'bug/345',
-        'bug/345'
-      )}`
+      `bugs: ${internalLink('bug/123', 'bug/123')} ${internalLink(
+        'bug/234',
+        'bug/234'
+      )} ${internalLink('bug/345', 'bug/345')}`
     );
   });
 });
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index f31ff9f..34b7232 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -1,4 +1,5 @@
 import {
+  AUTO_MERGE,
   BasePatchSetNum,
   BranchName,
   ChangeInfo,
@@ -70,6 +71,17 @@
   return patchset as PatchSetNum;
 }
 
+/**
+ * Same as `convertToPatchSetNum()`, but for the base of a patch range, where
+ * `0` is used for encoding an explicitly chosen `AUTO_MERGE` base.
+ */
+export function convertToBasePatchSetNum(
+  patchset: string | undefined
+): BasePatchSetNum | undefined {
+  if (patchset === '0') return AUTO_MERGE;
+  return convertToPatchSetNum(patchset) as BasePatchSetNum | undefined;
+}
+
 export function isNumber(psn?: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 5c523b3..31e17c9 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -8,6 +8,14 @@
 import {hasOwnProperty} from './common-util';
 
 export function specialFilePathCompare(a: string, b: string) {
+  // Patchset-level comments always go first.
+  if (a === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+    return -1;
+  }
+  if (b === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+    return 1;
+  }
+
   // The commit message always goes first.
   if (a === SpecialFilePath.COMMIT_MESSAGE) {
     return -1;
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index cdd8182..a7c1e53 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -21,12 +21,14 @@
     const testFiles = [
       '/a.h',
       '/MERGE_LIST',
+      SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
       '/a.cpp',
       '/COMMIT_MSG',
       '/asdasd',
       '/mrPeanutbutter.py',
     ];
     assert.deepEqual(testFiles.sort(specialFilePathCompare), [
+      SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
       '/COMMIT_MSG',
       '/MERGE_LIST',
       '/a.h',
diff --git a/polygerrit-ui/app/utils/safe-types-util.ts b/polygerrit-ui/app/utils/safe-types-util.ts
deleted file mode 100644
index e5c33bb..0000000
--- a/polygerrit-ui/app/utils/safe-types-util.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * @license
- * Copyright 2018 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
-
-/**
- * Wraps a string to be used as a URL. An error is thrown if the string cannot
- * be considered safe.
- */
-class SafeUrl {
-  private readonly _url: string;
-
-  constructor(url: string) {
-    if (!SAFE_URL_PATTERN.test(url)) {
-      throw new Error(`URL not marked as safe: ${url}`);
-    }
-    this._url = url;
-  }
-
-  toString() {
-    return this._url;
-  }
-}
-
-export const _testOnly_SafeUrl = SafeUrl;
-
-/**
- * Get the string representation of the safe URL.
- */
-export function safeTypesBridge(value: unknown, type: string): unknown {
-  // If the value is being bound to a URL, ensure the value is wrapped in the
-  // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
-  // to surface the error.
-  if (type === 'URL') {
-    let safeValue = null;
-    if (value instanceof SafeUrl) {
-      safeValue = value;
-    } else if (typeof value === 'string') {
-      safeValue = new SafeUrl(value);
-    }
-    if (safeValue) {
-      return safeValue.toString();
-    }
-  }
-
-  // If the value is being bound to a string or a constant, then the string
-  // can be used as is.
-  if (type === 'STRING' || type === 'CONSTANT') {
-    return value;
-  }
-
-  // Otherwise fail.
-  throw new Error(`Refused to bind value as ${type}: ${value}`);
-}
diff --git a/polygerrit-ui/app/utils/safe-types-util_test.ts b/polygerrit-ui/app/utils/safe-types-util_test.ts
deleted file mode 100644
index 2c8bb70..0000000
--- a/polygerrit-ui/app/utils/safe-types-util_test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright 2018 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import '../test/common-test-setup';
-import {_testOnly_SafeUrl, safeTypesBridge} from './safe-types-util';
-
-suite('safe-types-util tests', () => {
-  test('SafeUrl accepts valid urls', () => {
-    function accepts(url: string) {
-      const safeUrl = new _testOnly_SafeUrl(url);
-      assert.isOk(safeUrl);
-      assert.equal(url, safeUrl.toString());
-    }
-    accepts('http://www.google.com/');
-    accepts('https://www.google.com/');
-    accepts('HtTpS://www.google.com/');
-    accepts('//www.google.com/');
-    accepts('/c/1234/file/path.html@45');
-    accepts('#hash-url');
-    accepts('mailto:name@example.com');
-  });
-
-  test('SafeUrl rejects invalid urls', () => {
-    function rejects(url: string) {
-      assert.throws(() => {
-        new _testOnly_SafeUrl(url);
-      });
-    }
-    rejects('javascript://alert("evil");');
-    rejects('ftp:example.com');
-    rejects('data:text/html,scary business');
-  });
-
-  suite('safeTypesBridge', () => {
-    function acceptsString(value: string, type: string) {
-      assert.equal(safeTypesBridge(value, type), value);
-    }
-
-    function rejects(value: unknown, type: string) {
-      assert.throws(() => {
-        safeTypesBridge(value, type);
-      });
-    }
-
-    test('accepts valid URL strings', () => {
-      acceptsString('/foo/bar', 'URL');
-      acceptsString('#baz', 'URL');
-    });
-
-    test('rejects invalid URL strings', () => {
-      rejects('javascript://void();', 'URL');
-    });
-
-    test('accepts SafeUrl values', () => {
-      const url = '/abc/123';
-      const safeUrl = new _testOnly_SafeUrl(url);
-      assert.equal(safeTypesBridge(safeUrl, 'URL'), url);
-    });
-
-    test('rejects non-string or non-SafeUrl types', () => {
-      rejects(3.1415926, 'URL');
-    });
-
-    test('accepts any binding to STRING or CONSTANT', () => {
-      acceptsString('foo/bar/baz', 'STRING');
-      acceptsString('lorem ipsum dolor', 'CONSTANT');
-    });
-
-    test('rejects all other types', () => {
-      rejects('foo', 'JAVASCRIPT');
-      rejects('foo', 'HTML');
-      rejects('foo', 'RESOURCE_URL');
-      rejects('foo', 'STYLE');
-    });
-  });
-});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index ca4fdb4..905aa4c 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -5,6 +5,7 @@
  */
 import {
   AuthInfo,
+  AUTO_MERGE,
   BasePatchSetNum,
   PARENT,
   RevisionPatchSetNum,
@@ -78,6 +79,12 @@
   if (params.patchNum) {
     range = `${params.patchNum}`;
   }
+  // An explicitly chosen auto-merge base is encoded as `0`. Note that `0` alone
+  // means "auto merge against the latest patchset", see
+  // `normalizePatchRangeParams()`.
+  if (params.basePatchNum === AUTO_MERGE) {
+    return range ? `0..${range}` : '0';
+  }
   if (params.basePatchNum && params.basePatchNum !== PARENT) {
     range = `${params.basePatchNum}..${range}`;
   }
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index 366fdfc..4004b2a 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -3,7 +3,13 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AuthType, BasePatchSetNum, RevisionPatchSetNum} from '../api/rest-api';
+import {
+  AuthType,
+  AUTO_MERGE,
+  BasePatchSetNum,
+  PARENT,
+  RevisionPatchSetNum,
+} from '../api/rest-api';
 import '../test/common-test-setup';
 import {
   encodeURL,
@@ -174,4 +180,25 @@
     actual = getPatchRangeExpression(params);
     assert.equal(actual, '2..');
   });
+
+  test('getPatchRangeExpression encodes AUTO_MERGE as 0', () => {
+    assert.equal(
+      getPatchRangeExpression({
+        basePatchNum: AUTO_MERGE,
+        patchNum: 4 as RevisionPatchSetNum,
+      }),
+      '0..4'
+    );
+    // A lone `0` means "auto merge against the latest patchset".
+    assert.equal(getPatchRangeExpression({basePatchNum: AUTO_MERGE}), '0');
+    // `PARENT` stays implicit, so that it can be overridden by the
+    // `default_base_for_merges` preference.
+    assert.equal(
+      getPatchRangeExpression({
+        basePatchNum: PARENT,
+        patchNum: 4 as RevisionPatchSetNum,
+      }),
+      '4'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index c67ebb4..23a8b97 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,45 +2,39 @@
 # yarn lockfile v1
 
 
-"@lit-labs/ssr-dom-shim@^1.4.0":
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz#55eb80ab5ef6e188f7e541c1e2bea1ef582413b8"
-  integrity sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==
+"@lit-labs/ssr-dom-shim@^1.5.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz#693e129b809741fd23e98fcb57e41fd3d082db1a"
+  integrity sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==
 
-"@lit/reactive-element@^2.1.0":
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.1.1.tgz#0662ac4a43d4898974aef9a6c5cd47b9e331919a"
-  integrity sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==
+"@lit/context@^1.1.6":
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/@lit/context/-/context-1.1.6.tgz#ae67126bab4cabda65374a3286e4168f07bc31e6"
+  integrity sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.4.0"
+    "@lit/reactive-element" "^1.6.2 || ^2.1.0"
 
-"@material/web@^2.4.1":
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/@material/web/-/web-2.4.1.tgz#afd629ba350cf9485c3e19a7bfeb0476f02a0ec1"
-  integrity sha512-0sk9t25acJ72Qv3r0n9r0lgDbPaAKnpm0p+QmEAAwYyZomHxuVbgrrAdtNXaRm7jFyGh+WsTr8bhtvCnpPRFjw==
+"@lit/reactive-element@^1.6.2 || ^2.1.0", "@lit/reactive-element@^2.1.0":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.1.2.tgz#4c6af9042603c98e61ba90b294607904d51b61cb"
+  integrity sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==
   dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.5.0"
+
+"@material/web@^2.5.0":
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/@material/web/-/web-2.5.0.tgz#14ad6bc1c872e1db3538fd01ee080e6b9952a40b"
+  integrity sha512-x2Uovyq8E/Zc1xHXd9s1eBMXF5pMHVAt70pISKd0fL3Ajj4L6zQaQUY/awcfR2RDAMKXC7i4+vr0arv1YaOR/g==
+  dependencies:
+    "@lit/context" "^1.1.6"
     lit "^2.8.0 || ^3.0.0"
     tslib "^2.4.0"
 
-"@polymer/decorators@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
-  integrity sha512-qh+VID9nDV9q3ABvIfWgm7/+udl7v2HKsMLPXFm8tj1fI7qr7yWJMFwS3xWBkMmuNPtmkS8MDP0vqLAQIEOWzg==
-  dependencies:
-    "@polymer/polymer" "^3.0.5"
-
 "@polymer/font-roboto-local@^3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/font-roboto-local/-/font-roboto-local-3.0.2.tgz#563cd6cabbcaef54999d654c0f3d476bcc49ce58"
   integrity sha512-mCd9TcjwnCxU+7uVHCkbREGU+OmzStvYh3ru5DSaftOQDnMrLAzernEv/QCcfSPRgTMHij+pIUN4tcaGeDGcYg==
 
-"@polymer/polymer@3.5.2", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5":
-  version "3.5.2"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.2.tgz#af0e7e13976df53ace6728e841121c36aca351de"
-  integrity sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==
-  dependencies:
-    "@webcomponents/shadycss" "^1.9.1"
-
 "@types/resemblejs@^4.1.3":
   version "4.1.3"
   resolved "https://registry.yarnpkg.com/@types/resemblejs/-/resemblejs-4.1.3.tgz#46d16888952e377b9143484c206b63f6da56e91e"
@@ -56,16 +50,11 @@
   resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
   integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
 
-"@webcomponents/shadycss@^1.11.2", "@webcomponents/shadycss@^1.9.1":
+"@webcomponents/shadycss@^1.11.2":
   version "1.11.2"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.2.tgz#7539b0ad29598aa2eafee8b341059e20ac9e1006"
   integrity sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==
 
-"@webcomponents/webcomponentsjs@^2.0.3", "@webcomponents/webcomponentsjs@^2.8.0":
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz#ab21f027594fa827c1889e8b646da7be27c7908a"
-  integrity sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w==
-
 base64-js@^1.3.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -145,10 +134,10 @@
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
   integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
 
-highlight.js@^11.11.1, highlight.js@^11.9.0, "highlight.js@^11.9.0 || ^10.4.1":
-  version "11.11.1"
-  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585"
-  integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
+highlight.js@^11.12.0, highlight.js@^11.9.0, "highlight.js@^11.9.0 || ^10.4.1":
+  version "11.12.0"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.12.0.tgz#470d918fd556a76debc40fe5e4b540b6b9cb9890"
+  integrity sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==
 
 "highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba":
   version "0.0.1"
@@ -199,34 +188,34 @@
   integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
 
 lit-element@^4.2.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.2.1.tgz#0a3782f36eaa545862fe07f84abcb14b2903a042"
-  integrity sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.2.2.tgz#f74fcbfbea945eae5614ece22a674fa52ca3365b"
+  integrity sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.4.0"
+    "@lit-labs/ssr-dom-shim" "^1.5.0"
     "@lit/reactive-element" "^2.1.0"
     lit-html "^3.3.0"
 
 lit-html@^3.3.0:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.3.1.tgz#f0a7e4b9ea0a1d034eb28a4bf2d1b0a0096253e3"
-  integrity sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.3.3.tgz#a63fd02fb8c1c7b7057ee805ab6c612fdebef0b1"
+  integrity sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
 "lit@^2.8.0 || ^3.0.0", lit@^3.3.1:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-3.3.1.tgz#9dc79be626bc9a3b824de98b107dd662cabdeda6"
-  integrity sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.3.3.tgz#93579885e51a20a772c68482a34706fe1636e8f0"
+  integrity sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==
   dependencies:
     "@lit/reactive-element" "^2.1.0"
     lit-element "^4.2.0"
     lit-html "^3.3.0"
 
-marked@^17.0.1:
-  version "17.0.1"
-  resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.1.tgz#9db34197ac145e5929572ee49ef701e37ee9b2e6"
-  integrity sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==
+marked@^18.0.3:
+  version "18.0.3"
+  resolved "https://registry.yarnpkg.com/marked/-/marked-18.0.3.tgz#278b5ba89f1c7ccbaf0422f3ee8955928489220b"
+  integrity sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==
 
 mimic-response@^3.1.0:
   version "3.1.0"
@@ -267,17 +256,6 @@
   dependencies:
     wrappy "1"
 
-"polymer-bridges@file:../../polymer-bridges":
-  version "1.0.0"
-
-polymer-resin@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/polymer-resin/-/polymer-resin-2.0.1.tgz#08abcf0ea47bac746450cefa96caab1ebea199a8"
-  integrity sha512-Vak/4JiuToOKzvvjzcpISoFAlI7AwbOk79bZhqWNAZcqdzqEeFwos5hytjLV90d00TzVbff1LREaFpWR35zOjg==
-  dependencies:
-    "@polymer/polymer" "^3.0.2"
-    "@webcomponents/webcomponentsjs" "^2.0.3"
-
 prebuild-install@^7.1.3:
   version "7.1.3"
   resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec"
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 1707aa3..208cc89 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -6,15 +6,22 @@
     "@types/sinon": "^17.0.4"
   },
   "devDependencies": {
-    "@types/mocha": "^10.0.10",
     "@open-wc/semantic-dom-diff": "^0.20.1",
     "@open-wc/testing": "^4.0.0",
+    "@open-wc/testing-helpers": "3.0.1",
+    "@types/mocha": "^10.0.10",
     "@web/dev-server-esbuild": "^1.0.4",
     "@web/test-runner": "^0.20.2",
+    "@web/test-runner-commands": "0.9.0",
     "@web/test-runner-playwright": "^0.11.1",
     "@web/test-runner-visual-regression": "^0.10.0",
     "accessibility-developer-tools": "^2.12.0",
+    "lit": "3.3.2",
+    "lit-element": "4.2.2",
+    "lit-html": "3.3.2",
     "mocha": "^10.2.0",
+    "pixelmatch": "^7.1.0",
+    "pngjs": "^7.0.0",
     "sinon": "^20.0.0",
     "source-map-support": "^0.5.21"
   },
@@ -32,4 +39,4 @@
   },
   "license": "Apache-2.0",
   "private": true
-}
\ No newline at end of file
+}
diff --git a/polygerrit-ui/pnpm-lock.yaml b/polygerrit-ui/pnpm-lock.yaml
new file mode 100644
index 0000000..ac1685a
--- /dev/null
+++ b/polygerrit-ui/pnpm-lock.yaml
@@ -0,0 +1,3886 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@types/sinon':
+        specifier: ^17.0.4
+        version: 17.0.4
+    devDependencies:
+      '@open-wc/semantic-dom-diff':
+        specifier: ^0.20.1
+        version: 0.20.1
+      '@open-wc/testing':
+        specifier: ^4.0.0
+        version: 4.0.0
+      '@open-wc/testing-helpers':
+        specifier: 3.0.1
+        version: 3.0.1
+      '@types/mocha':
+        specifier: ^10.0.10
+        version: 10.0.10
+      '@web/dev-server-esbuild':
+        specifier: ^1.0.4
+        version: 1.0.4
+      '@web/test-runner':
+        specifier: ^0.20.2
+        version: 0.20.2
+      '@web/test-runner-commands':
+        specifier: 0.9.0
+        version: 0.9.0
+      '@web/test-runner-playwright':
+        specifier: ^0.11.1
+        version: 0.11.1
+      '@web/test-runner-visual-regression':
+        specifier: ^0.10.0
+        version: 0.10.0
+      accessibility-developer-tools:
+        specifier: ^2.12.0
+        version: 2.12.0
+      lit:
+        specifier: 3.3.2
+        version: 3.3.2
+      lit-element:
+        specifier: 4.2.2
+        version: 4.2.2
+      lit-html:
+        specifier: 3.3.2
+        version: 3.3.2
+      mocha:
+        specifier: ^10.2.0
+        version: 10.8.2
+      pixelmatch:
+        specifier: ^7.1.0
+        version: 7.2.0
+      pngjs:
+        specifier: ^7.0.0
+        version: 7.0.0
+      sinon:
+        specifier: ^20.0.0
+        version: 20.0.0
+      source-map-support:
+        specifier: ^0.5.21
+        version: 0.5.21
+
+packages:
+
+  '@babel/code-frame@7.27.1':
+    resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+    engines: {node: '>=6.9.0'}
+
+  '@babel/helper-validator-identifier@7.27.1':
+    resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
+    engines: {node: '>=6.9.0'}
+
+  '@esbuild/aix-ppc64@0.25.9':
+    resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.25.9':
+    resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.25.9':
+    resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.25.9':
+    resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.25.9':
+    resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.25.9':
+    resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.25.9':
+    resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.25.9':
+    resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.25.9':
+    resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.25.9':
+    resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.25.9':
+    resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.25.9':
+    resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.25.9':
+    resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.25.9':
+    resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.25.9':
+    resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.25.9':
+    resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.25.9':
+    resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.25.9':
+    resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.25.9':
+    resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.25.9':
+    resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.25.9':
+    resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.25.9':
+    resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.25.9':
+    resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.25.9':
+    resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.25.9':
+    resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.25.9':
+    resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@esm-bundle/chai@4.3.4-fix.0':
+    resolution: {integrity: sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==}
+
+  '@hapi/bourne@3.0.0':
+    resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/sourcemap-codec@1.5.5':
+    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+  '@jridgewell/trace-mapping@0.3.30':
+    resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+
+  '@lit-labs/ssr-dom-shim@1.4.0':
+    resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==}
+
+  '@lit-labs/ssr-dom-shim@1.6.0':
+    resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
+
+  '@lit/reactive-element@2.1.1':
+    resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==}
+
+  '@mdn/browser-compat-data@4.2.1':
+    resolution: {integrity: sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==}
+
+  '@nodelib/fs.scandir@2.1.5':
+    resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.stat@2.0.5':
+    resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+    engines: {node: '>= 8'}
+
+  '@nodelib/fs.walk@1.2.8':
+    resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+    engines: {node: '>= 8'}
+
+  '@open-wc/dedupe-mixin@2.0.1':
+    resolution: {integrity: sha512-+R4VxvceUxHAUJXJQipkkoV9fy10vNo+OnUnGKZnVmcwxMl460KLzytnUM4S35SI073R0yZQp9ra0MbPUwVcEA==}
+
+  '@open-wc/scoped-elements@3.0.6':
+    resolution: {integrity: sha512-w1ayJaUUmBw8tALtqQ6cBueld+op+bufujzbrOdH0uCTXnSQkONYZzOH+9jyQ8auVgKLqcxZ8oU6SzfqQhQkPg==}
+
+  '@open-wc/semantic-dom-diff@0.20.1':
+    resolution: {integrity: sha512-mPF/RPT2TU7Dw41LEDdaeP6eyTOWBD4z0+AHP4/d0SbgcfJZVRymlIB6DQmtz0fd2CImIS9kszaMmwMt92HBPA==}
+
+  '@open-wc/testing-helpers@3.0.1':
+    resolution: {integrity: sha512-hyNysSatbgT2FNxHJsS3rGKcLEo6+HwDFu1UQL6jcSQUabp/tj3PyX7UnXL3H5YGv0lJArdYLSnvjLnjn3O2fw==}
+
+  '@open-wc/testing@4.0.0':
+    resolution: {integrity: sha512-KI70O0CJEpBWs3jrTju4BFCy7V/d4tFfYWkg8pMzncsDhD7TYNHLw5cy+s1FHXIgVFetnMDhPpwlKIPvtTQW7w==}
+
+  '@puppeteer/browsers@2.10.7':
+    resolution: {integrity: sha512-wHWLkQWBjHtajZeqCB74nsa/X70KheyOhySYBRmVQDJiNj0zjZR/naPCvdWjMhcG1LmjaMV/9WtTo5mpe8qWLw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  '@rollup/plugin-node-resolve@15.3.1':
+    resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^2.78.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/pluginutils@5.2.0':
+    resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/rollup-android-arm-eabi@4.49.0':
+    resolution: {integrity: sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.49.0':
+    resolution: {integrity: sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.49.0':
+    resolution: {integrity: sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.49.0':
+    resolution: {integrity: sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.49.0':
+    resolution: {integrity: sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.49.0':
+    resolution: {integrity: sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.49.0':
+    resolution: {integrity: sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.49.0':
+    resolution: {integrity: sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.49.0':
+    resolution: {integrity: sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.49.0':
+    resolution: {integrity: sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loongarch64-gnu@4.49.0':
+    resolution: {integrity: sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-ppc64-gnu@4.49.0':
+    resolution: {integrity: sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.49.0':
+    resolution: {integrity: sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.49.0':
+    resolution: {integrity: sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.49.0':
+    resolution: {integrity: sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.49.0':
+    resolution: {integrity: sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.49.0':
+    resolution: {integrity: sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-win32-arm64-msvc@4.49.0':
+    resolution: {integrity: sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.49.0':
+    resolution: {integrity: sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.49.0':
+    resolution: {integrity: sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==}
+    cpu: [x64]
+    os: [win32]
+
+  '@sinonjs/commons@3.0.1':
+    resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
+
+  '@sinonjs/fake-timers@13.0.5':
+    resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==}
+
+  '@sinonjs/samsam@8.0.3':
+    resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==}
+
+  '@tootallnate/quickjs-emscripten@0.23.0':
+    resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+
+  '@types/accepts@1.3.7':
+    resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
+
+  '@types/babel__code-frame@7.0.6':
+    resolution: {integrity: sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==}
+
+  '@types/body-parser@1.19.6':
+    resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+  '@types/chai-dom@1.11.3':
+    resolution: {integrity: sha512-EUEZI7uID4ewzxnU7DJXtyvykhQuwe+etJ1wwOiJyQRTH/ifMWKX+ghiXkxCUvNJ6IQDodf0JXhuP6zZcy2qXQ==}
+
+  '@types/chai@4.3.20':
+    resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==}
+
+  '@types/chai@5.2.2':
+    resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
+
+  '@types/co-body@6.1.3':
+    resolution: {integrity: sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==}
+
+  '@types/command-line-args@5.2.3':
+    resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==}
+
+  '@types/connect@3.4.38':
+    resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+  '@types/content-disposition@0.5.9':
+    resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==}
+
+  '@types/convert-source-map@2.0.3':
+    resolution: {integrity: sha512-ag0BfJLZf6CQz8VIuRIEYQ5Ggwk/82uvTQf27RcpyDNbY0Vw49LIPqAxk5tqYfrCs9xDaIMvl4aj7ZopnYL8bA==}
+
+  '@types/cookies@0.9.1':
+    resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==}
+
+  '@types/debounce@1.2.4':
+    resolution: {integrity: sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==}
+
+  '@types/deep-eql@4.0.2':
+    resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+  '@types/estree@1.0.8':
+    resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+  '@types/express-serve-static-core@5.0.7':
+    resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==}
+
+  '@types/express@5.0.3':
+    resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==}
+
+  '@types/http-assert@1.5.6':
+    resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
+
+  '@types/http-errors@2.0.5':
+    resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
+  '@types/istanbul-lib-coverage@2.0.6':
+    resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
+
+  '@types/istanbul-lib-report@3.0.3':
+    resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==}
+
+  '@types/istanbul-reports@3.0.4':
+    resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
+
+  '@types/keygrip@1.0.6':
+    resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==}
+
+  '@types/koa-compose@3.2.8':
+    resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==}
+
+  '@types/koa@2.15.0':
+    resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==}
+
+  '@types/koa@3.0.0':
+    resolution: {integrity: sha512-MOcVYdVYmkSutVHZZPh8j3+dAjLyR5Tl59CN0eKgpkE1h/LBSmPAsQQuWs+bKu7WtGNn+hKfJH9Gzml+PulmDg==}
+
+  '@types/mime@1.3.5':
+    resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+
+  '@types/mkdirp@1.0.2':
+    resolution: {integrity: sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==}
+
+  '@types/mocha@10.0.10':
+    resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==}
+
+  '@types/node@24.3.0':
+    resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
+
+  '@types/parse5@6.0.3':
+    resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
+
+  '@types/pixelmatch@5.2.6':
+    resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==}
+
+  '@types/pngjs@6.0.5':
+    resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
+
+  '@types/qs@6.14.0':
+    resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
+
+  '@types/range-parser@1.2.7':
+    resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
+  '@types/resolve@1.20.2':
+    resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+
+  '@types/send@0.17.5':
+    resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
+
+  '@types/serve-static@1.15.8':
+    resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
+
+  '@types/sinon-chai@3.2.12':
+    resolution: {integrity: sha512-9y0Gflk3b0+NhQZ/oxGtaAJDvRywCa5sIyaVnounqLvmf93yBF4EgIRspePtkMs3Tr844nCclYMlcCNmLCvjuQ==}
+
+  '@types/sinon@17.0.4':
+    resolution: {integrity: sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==}
+
+  '@types/sinonjs__fake-timers@8.1.5':
+    resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==}
+
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+  '@types/ws@7.4.7':
+    resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==}
+
+  '@types/yauzl@2.10.3':
+    resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
+  '@web/browser-logs@0.4.1':
+    resolution: {integrity: sha512-ypmMG+72ERm+LvP+loj9A64MTXvWMXHUOu773cPO4L1SV/VWg6xA9Pv7vkvkXQX+ItJtCJt+KQ+U6ui2HhSFUw==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/config-loader@0.3.3':
+    resolution: {integrity: sha512-ilzeQzrPpPLWZhzFCV+4doxKDGm7oKVfdKpW9wiUNVgive34NSzCw+WzXTvjE4Jgr5CkyTDIObEmMrqQEjhT0g==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-core@0.7.5':
+    resolution: {integrity: sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-esbuild@1.0.4':
+    resolution: {integrity: sha512-ia1LxBwwRiQBYhJ7/RtLenHyPjzle3SvTw3jOZaeGv8UGXVPOkQV8fR05caOtW/DPPZaZovNAybzRKVnNiYIZg==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server-rollup@0.6.4':
+    resolution: {integrity: sha512-sJZfTGCCrdku5xYnQQG51odGI092hKY9YFM0X3Z0tRY3iXKXcYRaLZrErw5KfCxr6g0JRuhe4BBhqXTA5Q2I3Q==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/dev-server@0.4.6':
+    resolution: {integrity: sha512-jj/1bcElAy5EZet8m2CcUdzxT+CRvUjIXGh8Lt7vxtthkN9PzY9wlhWx/9WOs5iwlnG1oj0VGo6f/zvbPO0s9w==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  '@web/parse5-utils@2.1.0':
+    resolution: {integrity: sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-chrome@0.18.1':
+    resolution: {integrity: sha512-eO6ctCaqSguGM6G3cFobGHnrEs9wlv9Juj/Akyr4XLjeEMTheNULdvOXw9Bygi+QC/ir/0snMmt+/YKnfy8rYA==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-commands@0.9.0':
+    resolution: {integrity: sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-core@0.13.4':
+    resolution: {integrity: sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-coverage-v8@0.8.0':
+    resolution: {integrity: sha512-PskiucYpjUtgNfR2zF2AWqWwjXL7H3WW/SnCAYmzUrtob7X9o/+BjdyZ4wKbOxWWSbJO4lEdGIDLu+8X2Xw+lA==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-mocha@0.9.0':
+    resolution: {integrity: sha512-ZL9F6FXd0DBQvo/h/+mSfzFTSRVxzV9st/AHhpgABtUtV/AIpVE9to6+xdkpu6827kwjezdpuadPfg+PlrBWqQ==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-playwright@0.11.1':
+    resolution: {integrity: sha512-l9tmX0LtBqMaKAApS4WshpB87A/M8sOHZyfCobSGuYqnREgz5rqQpX314yx+4fwHXLLTa5N64mTrawsYkLjliw==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner-visual-regression@0.10.0':
+    resolution: {integrity: sha512-fz+OPvPXpGlQdkyEKeIRr1t1/hIWb11JmmFUZWKgrifvheuLlLGko0VwOxIOQ7Ht11RcepsMXl4PzTuxkPv6rA==}
+    engines: {node: '>=18.0.0'}
+
+  '@web/test-runner@0.20.2':
+    resolution: {integrity: sha512-zfEGYEDnS0EI8qgoWFjmtkIXhqP15W40NW3dCaKtbxj5eU0a7E53f3GV/tZGD0GlZKF8d4Fyw+AFrwOJU9Z4GA==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+
+  accessibility-developer-tools@2.12.0:
+    resolution: {integrity: sha512-ltexLD/Bzwr1tDskQQFi88L4akbn8zFLIFIc00vFkH3G4hNEHruuJVcJuJTeUXLxms9dSon+cHSCmfFThnowFQ==}
+
+  agent-base@7.1.4:
+    resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+    engines: {node: '>= 14'}
+
+  ansi-colors@4.1.3:
+    resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
+    engines: {node: '>=6'}
+
+  ansi-escapes@4.3.2:
+    resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
+    engines: {node: '>=8'}
+
+  ansi-regex@5.0.1:
+    resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+    engines: {node: '>=8'}
+
+  ansi-styles@4.3.0:
+    resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+    engines: {node: '>=8'}
+
+  anymatch@3.1.3:
+    resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+    engines: {node: '>= 8'}
+
+  argparse@2.0.1:
+    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+  array-back@3.1.0:
+    resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==}
+    engines: {node: '>=6'}
+
+  array-back@6.2.2:
+    resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==}
+    engines: {node: '>=12.17'}
+
+  array-union@2.1.0:
+    resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
+    engines: {node: '>=8'}
+
+  ast-types@0.13.4:
+    resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
+    engines: {node: '>=4'}
+
+  astral-regex@2.0.0:
+    resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
+    engines: {node: '>=8'}
+
+  async@3.2.6:
+    resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+
+  axe-core@4.10.3:
+    resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==}
+    engines: {node: '>=4'}
+
+  b4a@1.6.7:
+    resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
+
+  balanced-match@1.0.2:
+    resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+  bare-events@2.6.1:
+    resolution: {integrity: sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==}
+
+  bare-fs@4.2.1:
+    resolution: {integrity: sha512-mELROzV0IhqilFgsl1gyp48pnZsaV9xhQapHLDsvn4d4ZTfbFhcghQezl7FTEDNBcGqLUnNI3lUlm6ecrLWdFA==}
+    engines: {bare: '>=1.16.0'}
+    peerDependencies:
+      bare-buffer: '*'
+    peerDependenciesMeta:
+      bare-buffer:
+        optional: true
+
+  bare-os@3.6.2:
+    resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==}
+    engines: {bare: '>=1.14.0'}
+
+  bare-path@3.0.0:
+    resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
+
+  bare-stream@2.7.0:
+    resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==}
+    peerDependencies:
+      bare-buffer: '*'
+      bare-events: '*'
+    peerDependenciesMeta:
+      bare-buffer:
+        optional: true
+      bare-events:
+        optional: true
+
+  basic-ftp@5.0.5:
+    resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
+    engines: {node: '>=10.0.0'}
+    deprecated: Security vulnerability fixed in 5.2.1, please upgrade
+
+  binary-extensions@2.3.0:
+    resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+    engines: {node: '>=8'}
+
+  brace-expansion@2.0.2:
+    resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+  braces@3.0.3:
+    resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+    engines: {node: '>=8'}
+
+  browser-stdout@1.3.1:
+    resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==}
+
+  buffer-crc32@0.2.13:
+    resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
+  buffer-from@1.1.2:
+    resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
+  bytes@3.1.2:
+    resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+    engines: {node: '>= 0.8'}
+
+  cache-content-type@1.0.1:
+    resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==}
+    engines: {node: '>= 6.0.0'}
+
+  call-bind-apply-helpers@1.0.2:
+    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+    engines: {node: '>= 0.4'}
+
+  call-bound@1.0.4:
+    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+    engines: {node: '>= 0.4'}
+
+  camelcase@6.3.0:
+    resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
+    engines: {node: '>=10'}
+
+  chai-a11y-axe@1.5.0:
+    resolution: {integrity: sha512-V/Vg/zJDr9aIkaHJ2KQu7lGTQQm5ZOH4u1k5iTMvIXuSVlSuUo0jcSpSqf9wUn9zl6oQXa4e4E0cqH18KOgKlQ==}
+
+  chalk-template@0.4.0:
+    resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==}
+    engines: {node: '>=12'}
+
+  chalk@4.1.2:
+    resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+    engines: {node: '>=10'}
+
+  chokidar@3.6.0:
+    resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+    engines: {node: '>= 8.10.0'}
+
+  chokidar@4.0.3:
+    resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+    engines: {node: '>= 14.16.0'}
+
+  chrome-launcher@0.15.2:
+    resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==}
+    engines: {node: '>=12.13.0'}
+    hasBin: true
+
+  chromium-bidi@8.0.0:
+    resolution: {integrity: sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==}
+    peerDependencies:
+      devtools-protocol: '*'
+
+  cli-cursor@3.1.0:
+    resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
+    engines: {node: '>=8'}
+
+  cliui@7.0.4:
+    resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
+
+  cliui@8.0.1:
+    resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+    engines: {node: '>=12'}
+
+  clone@2.1.2:
+    resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
+    engines: {node: '>=0.8'}
+
+  co-body@6.2.0:
+    resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==}
+    engines: {node: '>=8.0.0'}
+
+  co@4.6.0:
+    resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
+    engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
+
+  color-convert@2.0.1:
+    resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+    engines: {node: '>=7.0.0'}
+
+  color-name@1.1.4:
+    resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+  command-line-args@5.2.1:
+    resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
+    engines: {node: '>=4.0.0'}
+
+  command-line-usage@7.0.3:
+    resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==}
+    engines: {node: '>=12.20.0'}
+
+  content-disposition@0.5.4:
+    resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
+    engines: {node: '>= 0.6'}
+
+  content-type@1.0.5:
+    resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+    engines: {node: '>= 0.6'}
+
+  convert-source-map@2.0.0:
+    resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+  cookies@0.9.1:
+    resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
+    engines: {node: '>= 0.8'}
+
+  cross-spawn@7.0.6:
+    resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+    engines: {node: '>= 8'}
+
+  data-uri-to-buffer@6.0.2:
+    resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
+    engines: {node: '>= 14'}
+
+  debounce@1.2.1:
+    resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
+
+  debug@2.6.9:
+    resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  debug@3.2.7:
+    resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  debug@4.4.1:
+    resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  decamelize@4.0.0:
+    resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==}
+    engines: {node: '>=10'}
+
+  deep-equal@1.0.1:
+    resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
+
+  deepmerge@4.3.1:
+    resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+    engines: {node: '>=0.10.0'}
+
+  default-gateway@6.0.3:
+    resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==}
+    engines: {node: '>= 10'}
+
+  define-lazy-prop@2.0.0:
+    resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+    engines: {node: '>=8'}
+
+  degenerator@5.0.1:
+    resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
+    engines: {node: '>= 14'}
+
+  delegates@1.0.0:
+    resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
+
+  depd@1.1.2:
+    resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
+    engines: {node: '>= 0.6'}
+
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
+  dependency-graph@0.11.0:
+    resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
+    engines: {node: '>= 0.6.0'}
+
+  destroy@1.2.0:
+    resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
+  devtools-protocol@0.0.1475386:
+    resolution: {integrity: sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==}
+
+  diff@5.2.0:
+    resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
+    engines: {node: '>=0.3.1'}
+
+  diff@7.0.0:
+    resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
+    engines: {node: '>=0.3.1'}
+
+  dir-glob@3.0.1:
+    resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
+    engines: {node: '>=8'}
+
+  dunder-proto@1.0.1:
+    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+    engines: {node: '>= 0.4'}
+
+  ee-first@1.1.1:
+    resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+  emoji-regex@8.0.0:
+    resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+  encodeurl@1.0.2:
+    resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
+    engines: {node: '>= 0.8'}
+
+  end-of-stream@1.4.5:
+    resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+  errorstacks@2.4.1:
+    resolution: {integrity: sha512-jE4i0SMYevwu/xxAuzhly/KTwtj0xDhbzB6m1xPImxTkw8wcCbgarOQPfCVMi5JKVyW7in29pNJCCJrry3Ynnw==}
+
+  es-define-property@1.0.1:
+    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+    engines: {node: '>= 0.4'}
+
+  es-errors@1.3.0:
+    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+    engines: {node: '>= 0.4'}
+
+  es-module-lexer@1.7.0:
+    resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+  es-object-atoms@1.1.1:
+    resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+    engines: {node: '>= 0.4'}
+
+  esbuild@0.25.9:
+    resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escalade@3.2.0:
+    resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+    engines: {node: '>=6'}
+
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+  escape-string-regexp@4.0.0:
+    resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+    engines: {node: '>=10'}
+
+  escodegen@2.1.0:
+    resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+    engines: {node: '>=6.0'}
+    hasBin: true
+
+  esprima@4.0.1:
+    resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+    engines: {node: '>=4'}
+    hasBin: true
+
+  estraverse@5.3.0:
+    resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+    engines: {node: '>=4.0'}
+
+  estree-walker@2.0.2:
+    resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+  esutils@2.0.3:
+    resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+    engines: {node: '>=0.10.0'}
+
+  etag@1.8.1:
+    resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+    engines: {node: '>= 0.6'}
+
+  execa@5.1.1:
+    resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+    engines: {node: '>=10'}
+
+  extract-zip@2.0.1:
+    resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+    engines: {node: '>= 10.17.0'}
+    hasBin: true
+
+  fast-fifo@1.3.2:
+    resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
+  fast-glob@3.3.3:
+    resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+    engines: {node: '>=8.6.0'}
+
+  fastq@1.19.1:
+    resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
+
+  fd-slicer@1.1.0:
+    resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
+  fill-range@7.1.1:
+    resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+    engines: {node: '>=8'}
+
+  find-replace@3.0.0:
+    resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==}
+    engines: {node: '>=4.0.0'}
+
+  find-up@5.0.0:
+    resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+    engines: {node: '>=10'}
+
+  flat@5.0.2:
+    resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
+    hasBin: true
+
+  fresh@0.5.2:
+    resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
+    engines: {node: '>= 0.6'}
+
+  fs.realpath@1.0.0:
+    resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
+  fsevents@2.3.2:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  get-caller-file@2.0.5:
+    resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+    engines: {node: 6.* || 8.* || >= 10.*}
+
+  get-intrinsic@1.3.0:
+    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+    engines: {node: '>= 0.4'}
+
+  get-proto@1.0.1:
+    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+    engines: {node: '>= 0.4'}
+
+  get-stream@5.2.0:
+    resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+    engines: {node: '>=8'}
+
+  get-stream@6.0.1:
+    resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+    engines: {node: '>=10'}
+
+  get-uri@6.0.5:
+    resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
+    engines: {node: '>= 14'}
+
+  glob-parent@5.1.2:
+    resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+    engines: {node: '>= 6'}
+
+  glob@8.1.0:
+    resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
+    engines: {node: '>=12'}
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+
+  globby@11.1.0:
+    resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
+    engines: {node: '>=10'}
+
+  gopd@1.2.0:
+    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+    engines: {node: '>= 0.4'}
+
+  has-flag@4.0.0:
+    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+    engines: {node: '>=8'}
+
+  has-symbols@1.1.0:
+    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+    engines: {node: '>= 0.4'}
+
+  has-tostringtag@1.0.2:
+    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+    engines: {node: '>= 0.4'}
+
+  hasown@2.0.2:
+    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+    engines: {node: '>= 0.4'}
+
+  he@1.2.0:
+    resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+    hasBin: true
+
+  html-escaper@2.0.2:
+    resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
+  http-assert@1.5.0:
+    resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==}
+    engines: {node: '>= 0.8'}
+
+  http-errors@1.6.3:
+    resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==}
+    engines: {node: '>= 0.6'}
+
+  http-errors@1.8.1:
+    resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==}
+    engines: {node: '>= 0.6'}
+
+  http-errors@2.0.0:
+    resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+    engines: {node: '>= 0.8'}
+
+  http-proxy-agent@7.0.2:
+    resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+    engines: {node: '>= 14'}
+
+  https-proxy-agent@7.0.6:
+    resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+    engines: {node: '>= 14'}
+
+  human-signals@2.1.0:
+    resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+    engines: {node: '>=10.17.0'}
+
+  iconv-lite@0.4.24:
+    resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
+    engines: {node: '>=0.10.0'}
+
+  ignore@5.3.2:
+    resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+    engines: {node: '>= 4'}
+
+  inflation@2.1.0:
+    resolution: {integrity: sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==}
+    engines: {node: '>= 0.8.0'}
+
+  inflight@1.0.6:
+    resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+    deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
+
+  inherits@2.0.3:
+    resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==}
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+  internal-ip@6.2.0:
+    resolution: {integrity: sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==}
+    engines: {node: '>=10'}
+
+  ip-address@10.0.1:
+    resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
+    engines: {node: '>= 12'}
+
+  ip-regex@4.3.0:
+    resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
+    engines: {node: '>=8'}
+
+  ipaddr.js@1.9.1:
+    resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+    engines: {node: '>= 0.10'}
+
+  is-binary-path@2.1.0:
+    resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+    engines: {node: '>=8'}
+
+  is-core-module@2.16.1:
+    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
+    engines: {node: '>= 0.4'}
+
+  is-docker@2.2.1:
+    resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+    engines: {node: '>=8'}
+    hasBin: true
+
+  is-extglob@2.1.1:
+    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+    engines: {node: '>=0.10.0'}
+
+  is-fullwidth-code-point@3.0.0:
+    resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+    engines: {node: '>=8'}
+
+  is-generator-function@1.1.0:
+    resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==}
+    engines: {node: '>= 0.4'}
+
+  is-glob@4.0.3:
+    resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+    engines: {node: '>=0.10.0'}
+
+  is-ip@3.1.0:
+    resolution: {integrity: sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==}
+    engines: {node: '>=8'}
+
+  is-module@1.0.0:
+    resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+
+  is-number@7.0.0:
+    resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+    engines: {node: '>=0.12.0'}
+
+  is-plain-obj@2.1.0:
+    resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
+    engines: {node: '>=8'}
+
+  is-regex@1.2.1:
+    resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+    engines: {node: '>= 0.4'}
+
+  is-stream@2.0.1:
+    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+    engines: {node: '>=8'}
+
+  is-unicode-supported@0.1.0:
+    resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
+    engines: {node: '>=10'}
+
+  is-wsl@2.2.0:
+    resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+    engines: {node: '>=8'}
+
+  isbinaryfile@5.0.5:
+    resolution: {integrity: sha512-vh9MWXjhhblwrHlt/yutrubDuBD01kKFscyVndE2/VEeEU5aAizrzuWAsEaCjo997k+IluhN6C4jgxfS69SCIw==}
+    engines: {node: '>= 18.0.0'}
+
+  isexe@2.0.0:
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+  istanbul-lib-coverage@3.2.2:
+    resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+    engines: {node: '>=8'}
+
+  istanbul-lib-report@3.0.1:
+    resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+    engines: {node: '>=10'}
+
+  istanbul-reports@3.2.0:
+    resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+    engines: {node: '>=8'}
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  js-yaml@4.1.0:
+    resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+    hasBin: true
+
+  keygrip@1.1.0:
+    resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
+    engines: {node: '>= 0.6'}
+
+  koa-compose@4.1.0:
+    resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==}
+
+  koa-convert@2.0.0:
+    resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==}
+    engines: {node: '>= 10'}
+
+  koa-etag@4.0.0:
+    resolution: {integrity: sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==}
+
+  koa-send@5.0.1:
+    resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==}
+    engines: {node: '>= 8'}
+
+  koa-static@5.0.0:
+    resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==}
+    engines: {node: '>= 7.6.0'}
+
+  koa@2.16.2:
+    resolution: {integrity: sha512-+CCssgnrWKx9aI3OeZwroa/ckG4JICxvIFnSiOUyl2Uv+UTI+xIw0FfFrWS7cQFpoePpr9o8csss7KzsTzNL8Q==}
+    engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4}
+
+  lighthouse-logger@1.4.2:
+    resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
+
+  lit-element@4.2.2:
+    resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
+
+  lit-html@3.3.2:
+    resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==}
+
+  lit@3.3.2:
+    resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
+
+  locate-path@6.0.0:
+    resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+    engines: {node: '>=10'}
+
+  lodash.camelcase@4.3.0:
+    resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
+
+  log-symbols@4.1.0:
+    resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
+    engines: {node: '>=10'}
+
+  log-update@4.0.0:
+    resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==}
+    engines: {node: '>=10'}
+
+  lru-cache@7.18.3:
+    resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
+    engines: {node: '>=12'}
+
+  lru-cache@8.0.5:
+    resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
+    engines: {node: '>=16.14'}
+
+  make-dir@4.0.0:
+    resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+    engines: {node: '>=10'}
+
+  marky@1.3.0:
+    resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  media-typer@0.3.0:
+    resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+    engines: {node: '>= 0.6'}
+
+  merge-stream@2.0.0:
+    resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+
+  merge2@1.4.1:
+    resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+    engines: {node: '>= 8'}
+
+  micromatch@4.0.8:
+    resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+    engines: {node: '>=8.6'}
+
+  mime-db@1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+
+  mimic-fn@2.1.0:
+    resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+    engines: {node: '>=6'}
+
+  minimatch@5.1.6:
+    resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
+    engines: {node: '>=10'}
+
+  mitt@3.0.1:
+    resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
+  mkdirp@1.0.4:
+    resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  mocha@10.8.2:
+    resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==}
+    engines: {node: '>= 14.0.0'}
+    hasBin: true
+
+  ms@2.0.0:
+    resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  nanocolors@0.2.13:
+    resolution: {integrity: sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==}
+
+  nanoid@3.3.11:
+    resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+    engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+    hasBin: true
+
+  negotiator@0.6.3:
+    resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+    engines: {node: '>= 0.6'}
+
+  netmask@2.0.2:
+    resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
+    engines: {node: '>= 0.4.0'}
+
+  normalize-path@3.0.0:
+    resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+    engines: {node: '>=0.10.0'}
+
+  npm-run-path@4.0.1:
+    resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+    engines: {node: '>=8'}
+
+  object-inspect@1.13.4:
+    resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+    engines: {node: '>= 0.4'}
+
+  on-finished@2.4.1:
+    resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+    engines: {node: '>= 0.8'}
+
+  once@1.4.0:
+    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+  onetime@5.1.2:
+    resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+    engines: {node: '>=6'}
+
+  only@0.0.2:
+    resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==}
+
+  open@8.4.2:
+    resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
+    engines: {node: '>=12'}
+
+  p-event@4.2.0:
+    resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
+    engines: {node: '>=8'}
+
+  p-finally@1.0.0:
+    resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
+    engines: {node: '>=4'}
+
+  p-limit@3.1.0:
+    resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+    engines: {node: '>=10'}
+
+  p-locate@5.0.0:
+    resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+    engines: {node: '>=10'}
+
+  p-timeout@3.2.0:
+    resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
+    engines: {node: '>=8'}
+
+  pac-proxy-agent@7.2.0:
+    resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
+    engines: {node: '>= 14'}
+
+  pac-resolver@7.0.1:
+    resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
+    engines: {node: '>= 14'}
+
+  parse5@6.0.1:
+    resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
+
+  parseurl@1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+
+  path-exists@4.0.0:
+    resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+    engines: {node: '>=8'}
+
+  path-is-absolute@1.0.1:
+    resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+    engines: {node: '>=0.10.0'}
+
+  path-key@3.1.1:
+    resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+    engines: {node: '>=8'}
+
+  path-parse@1.0.7:
+    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+  path-type@4.0.0:
+    resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
+    engines: {node: '>=8'}
+
+  pend@1.2.0:
+    resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
+  picocolors@1.1.1:
+    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+  picomatch@2.3.1:
+    resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+    engines: {node: '>=8.6'}
+
+  picomatch@4.0.3:
+    resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+    engines: {node: '>=12'}
+
+  pixelmatch@5.3.0:
+    resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==}
+    hasBin: true
+
+  pixelmatch@7.2.0:
+    resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==}
+    hasBin: true
+
+  playwright-core@1.55.0:
+    resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  playwright@1.55.0:
+    resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  pngjs@6.0.0:
+    resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
+    engines: {node: '>=12.13.0'}
+
+  pngjs@7.0.0:
+    resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
+    engines: {node: '>=14.19.0'}
+
+  portfinder@1.0.37:
+    resolution: {integrity: sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==}
+    engines: {node: '>= 10.12'}
+
+  progress@2.0.3:
+    resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+    engines: {node: '>=0.4.0'}
+
+  proxy-agent@6.5.0:
+    resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
+    engines: {node: '>= 14'}
+
+  proxy-from-env@1.1.0:
+    resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
+  pump@3.0.3:
+    resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
+
+  punycode@2.3.1:
+    resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+    engines: {node: '>=6'}
+
+  puppeteer-core@24.17.0:
+    resolution: {integrity: sha512-RYOBKFiF+3RdwIZTEacqNpD567gaFcBAOKTT7742FdB1icXudrPI7BlZbYTYWK2wgGQUXt9Zi1Yn+D5PmCs4CA==}
+    engines: {node: '>=18'}
+
+  qs@6.14.0:
+    resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
+    engines: {node: '>=0.6'}
+
+  queue-microtask@1.2.3:
+    resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+  randombytes@2.1.0:
+    resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
+
+  raw-body@2.5.2:
+    resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
+    engines: {node: '>= 0.8'}
+
+  readdirp@3.6.0:
+    resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+    engines: {node: '>=8.10.0'}
+
+  readdirp@4.1.2:
+    resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+    engines: {node: '>= 14.18.0'}
+
+  require-directory@2.1.1:
+    resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+    engines: {node: '>=0.10.0'}
+
+  resolve-path@1.4.0:
+    resolution: {integrity: sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==}
+    engines: {node: '>= 0.8'}
+
+  resolve@1.22.10:
+    resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
+    engines: {node: '>= 0.4'}
+    hasBin: true
+
+  restore-cursor@3.1.0:
+    resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
+    engines: {node: '>=8'}
+
+  reusify@1.1.0:
+    resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+    engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+  rollup@4.49.0:
+    resolution: {integrity: sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  run-parallel@1.2.0:
+    resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+  safe-regex-test@1.1.0:
+    resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+    engines: {node: '>= 0.4'}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  semver@7.7.2:
+    resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  serialize-javascript@6.0.2:
+    resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
+
+  setprototypeof@1.1.0:
+    resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
+
+  setprototypeof@1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+  shebang-command@2.0.0:
+    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+    engines: {node: '>=8'}
+
+  shebang-regex@3.0.0:
+    resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+    engines: {node: '>=8'}
+
+  side-channel-list@1.0.0:
+    resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-map@1.0.1:
+    resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-weakmap@1.0.2:
+    resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+    engines: {node: '>= 0.4'}
+
+  side-channel@1.1.0:
+    resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+    engines: {node: '>= 0.4'}
+
+  signal-exit@3.0.7:
+    resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
+  sinon@20.0.0:
+    resolution: {integrity: sha512-+FXOAbdnj94AQIxH0w1v8gzNxkawVvNqE3jUzRLptR71Oykeu2RrQXXl/VQjKay+Qnh73fDt/oDfMo6xMeDQbQ==}
+
+  slash@3.0.0:
+    resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
+    engines: {node: '>=8'}
+
+  slice-ansi@4.0.0:
+    resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
+    engines: {node: '>=10'}
+
+  smart-buffer@4.2.0:
+    resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+    engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+
+  socks-proxy-agent@8.0.5:
+    resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
+    engines: {node: '>= 14'}
+
+  socks@2.8.7:
+    resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
+    engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+
+  source-map-support@0.5.21:
+    resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
+  source-map@0.6.1:
+    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+    engines: {node: '>=0.10.0'}
+
+  source-map@0.7.6:
+    resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
+    engines: {node: '>= 12'}
+
+  statuses@1.5.0:
+    resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+    engines: {node: '>= 0.6'}
+
+  statuses@2.0.1:
+    resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
+    engines: {node: '>= 0.8'}
+
+  streamx@2.22.1:
+    resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==}
+
+  string-width@4.2.3:
+    resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+    engines: {node: '>=8'}
+
+  strip-ansi@6.0.1:
+    resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+    engines: {node: '>=8'}
+
+  strip-final-newline@2.0.0:
+    resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+    engines: {node: '>=6'}
+
+  strip-json-comments@3.1.1:
+    resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+    engines: {node: '>=8'}
+
+  supports-color@7.2.0:
+    resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+    engines: {node: '>=8'}
+
+  supports-color@8.1.1:
+    resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+    engines: {node: '>=10'}
+
+  supports-preserve-symlinks-flag@1.0.0:
+    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+    engines: {node: '>= 0.4'}
+
+  table-layout@4.1.1:
+    resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
+    engines: {node: '>=12.17'}
+
+  tar-fs@3.1.0:
+    resolution: {integrity: sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==}
+
+  tar-stream@3.1.7:
+    resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
+
+  text-decoder@1.2.3:
+    resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
+
+  to-regex-range@5.0.1:
+    resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+    engines: {node: '>=8.0'}
+
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
+  tr46@5.1.1:
+    resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+    engines: {node: '>=18'}
+
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+  tsscmp@1.0.6:
+    resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
+    engines: {node: '>=0.6.x'}
+
+  type-detect@4.0.8:
+    resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
+    engines: {node: '>=4'}
+
+  type-detect@4.1.0:
+    resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
+    engines: {node: '>=4'}
+
+  type-fest@0.21.3:
+    resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
+    engines: {node: '>=10'}
+
+  type-is@1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+
+  typed-query-selector@2.12.0:
+    resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==}
+
+  typical@4.0.0:
+    resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==}
+    engines: {node: '>=8'}
+
+  typical@7.3.0:
+    resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==}
+    engines: {node: '>=12.17'}
+
+  ua-parser-js@1.0.41:
+    resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
+    hasBin: true
+
+  undici-types@7.10.0:
+    resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
+
+  unpipe@1.0.0:
+    resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+    engines: {node: '>= 0.8'}
+
+  v8-to-istanbul@9.3.0:
+    resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
+    engines: {node: '>=10.12.0'}
+
+  vary@1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+
+  webidl-conversions@7.0.0:
+    resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+    engines: {node: '>=12'}
+
+  whatwg-url@14.2.0:
+    resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+    engines: {node: '>=18'}
+
+  which@2.0.2:
+    resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+    engines: {node: '>= 8'}
+    hasBin: true
+
+  wordwrapjs@5.1.0:
+    resolution: {integrity: sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==}
+    engines: {node: '>=12.17'}
+
+  workerpool@6.5.1:
+    resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==}
+
+  wrap-ansi@6.2.0:
+    resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+    engines: {node: '>=8'}
+
+  wrap-ansi@7.0.0:
+    resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+    engines: {node: '>=10'}
+
+  wrappy@1.0.2:
+    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+  ws@7.5.10:
+    resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
+    engines: {node: '>=8.3.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: ^5.0.2
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  ws@8.18.3:
+    resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  y18n@5.0.8:
+    resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+    engines: {node: '>=10'}
+
+  yargs-parser@20.2.9:
+    resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
+    engines: {node: '>=10'}
+
+  yargs-parser@21.1.1:
+    resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+    engines: {node: '>=12'}
+
+  yargs-unparser@2.0.0:
+    resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==}
+    engines: {node: '>=10'}
+
+  yargs@16.2.0:
+    resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
+    engines: {node: '>=10'}
+
+  yargs@17.7.2:
+    resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+    engines: {node: '>=12'}
+
+  yauzl@2.10.0:
+    resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
+  ylru@1.4.0:
+    resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==}
+    engines: {node: '>= 4.0.0'}
+
+  yocto-queue@0.1.0:
+    resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+    engines: {node: '>=10'}
+
+  zod@3.25.76:
+    resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+snapshots:
+
+  '@babel/code-frame@7.27.1':
+    dependencies:
+      '@babel/helper-validator-identifier': 7.27.1
+      js-tokens: 4.0.0
+      picocolors: 1.1.1
+
+  '@babel/helper-validator-identifier@7.27.1': {}
+
+  '@esbuild/aix-ppc64@0.25.9':
+    optional: true
+
+  '@esbuild/android-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/android-arm@0.25.9':
+    optional: true
+
+  '@esbuild/android-x64@0.25.9':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/darwin-x64@0.25.9':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-arm@0.25.9':
+    optional: true
+
+  '@esbuild/linux-ia32@0.25.9':
+    optional: true
+
+  '@esbuild/linux-loong64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.25.9':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.25.9':
+    optional: true
+
+  '@esbuild/linux-s390x@0.25.9':
+    optional: true
+
+  '@esbuild/linux-x64@0.25.9':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.25.9':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/sunos-x64@0.25.9':
+    optional: true
+
+  '@esbuild/win32-arm64@0.25.9':
+    optional: true
+
+  '@esbuild/win32-ia32@0.25.9':
+    optional: true
+
+  '@esbuild/win32-x64@0.25.9':
+    optional: true
+
+  '@esm-bundle/chai@4.3.4-fix.0':
+    dependencies:
+      '@types/chai': 4.3.20
+
+  '@hapi/bourne@3.0.0': {}
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/sourcemap-codec@1.5.5': {}
+
+  '@jridgewell/trace-mapping@0.3.30':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.5.5
+
+  '@lit-labs/ssr-dom-shim@1.4.0': {}
+
+  '@lit-labs/ssr-dom-shim@1.6.0': {}
+
+  '@lit/reactive-element@2.1.1':
+    dependencies:
+      '@lit-labs/ssr-dom-shim': 1.4.0
+
+  '@mdn/browser-compat-data@4.2.1': {}
+
+  '@nodelib/fs.scandir@2.1.5':
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      run-parallel: 1.2.0
+
+  '@nodelib/fs.stat@2.0.5': {}
+
+  '@nodelib/fs.walk@1.2.8':
+    dependencies:
+      '@nodelib/fs.scandir': 2.1.5
+      fastq: 1.19.1
+
+  '@open-wc/dedupe-mixin@2.0.1': {}
+
+  '@open-wc/scoped-elements@3.0.6':
+    dependencies:
+      '@open-wc/dedupe-mixin': 2.0.1
+      lit: 3.3.2
+
+  '@open-wc/semantic-dom-diff@0.20.1':
+    dependencies:
+      '@types/chai': 4.3.20
+      '@web/test-runner-commands': 0.9.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@open-wc/testing-helpers@3.0.1':
+    dependencies:
+      '@open-wc/scoped-elements': 3.0.6
+      lit: 3.3.2
+      lit-html: 3.3.2
+
+  '@open-wc/testing@4.0.0':
+    dependencies:
+      '@esm-bundle/chai': 4.3.4-fix.0
+      '@open-wc/semantic-dom-diff': 0.20.1
+      '@open-wc/testing-helpers': 3.0.1
+      '@types/chai-dom': 1.11.3
+      '@types/sinon-chai': 3.2.12
+      chai-a11y-axe: 1.5.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@puppeteer/browsers@2.10.7':
+    dependencies:
+      debug: 4.4.1(supports-color@8.1.1)
+      extract-zip: 2.0.1
+      progress: 2.0.3
+      proxy-agent: 6.5.0
+      semver: 7.7.2
+      tar-fs: 3.1.0
+      yargs: 17.7.2
+    transitivePeerDependencies:
+      - bare-buffer
+      - supports-color
+
+  '@rollup/plugin-node-resolve@15.3.1(rollup@4.49.0)':
+    dependencies:
+      '@rollup/pluginutils': 5.2.0(rollup@4.49.0)
+      '@types/resolve': 1.20.2
+      deepmerge: 4.3.1
+      is-module: 1.0.0
+      resolve: 1.22.10
+    optionalDependencies:
+      rollup: 4.49.0
+
+  '@rollup/pluginutils@5.2.0(rollup@4.49.0)':
+    dependencies:
+      '@types/estree': 1.0.8
+      estree-walker: 2.0.2
+      picomatch: 4.0.3
+    optionalDependencies:
+      rollup: 4.49.0
+
+  '@rollup/rollup-android-arm-eabi@4.49.0':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-loongarch64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-ppc64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.49.0':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.49.0':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.49.0':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.49.0':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.49.0':
+    optional: true
+
+  '@sinonjs/commons@3.0.1':
+    dependencies:
+      type-detect: 4.0.8
+
+  '@sinonjs/fake-timers@13.0.5':
+    dependencies:
+      '@sinonjs/commons': 3.0.1
+
+  '@sinonjs/samsam@8.0.3':
+    dependencies:
+      '@sinonjs/commons': 3.0.1
+      type-detect: 4.1.0
+
+  '@tootallnate/quickjs-emscripten@0.23.0': {}
+
+  '@types/accepts@1.3.7':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/babel__code-frame@7.0.6': {}
+
+  '@types/body-parser@1.19.6':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/node': 24.3.0
+
+  '@types/chai-dom@1.11.3':
+    dependencies:
+      '@types/chai': 5.2.2
+
+  '@types/chai@4.3.20': {}
+
+  '@types/chai@5.2.2':
+    dependencies:
+      '@types/deep-eql': 4.0.2
+
+  '@types/co-body@6.1.3':
+    dependencies:
+      '@types/node': 24.3.0
+      '@types/qs': 6.14.0
+
+  '@types/command-line-args@5.2.3': {}
+
+  '@types/connect@3.4.38':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/content-disposition@0.5.9': {}
+
+  '@types/convert-source-map@2.0.3': {}
+
+  '@types/cookies@0.9.1':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/express': 5.0.3
+      '@types/keygrip': 1.0.6
+      '@types/node': 24.3.0
+
+  '@types/debounce@1.2.4': {}
+
+  '@types/deep-eql@4.0.2': {}
+
+  '@types/estree@1.0.8': {}
+
+  '@types/express-serve-static-core@5.0.7':
+    dependencies:
+      '@types/node': 24.3.0
+      '@types/qs': 6.14.0
+      '@types/range-parser': 1.2.7
+      '@types/send': 0.17.5
+
+  '@types/express@5.0.3':
+    dependencies:
+      '@types/body-parser': 1.19.6
+      '@types/express-serve-static-core': 5.0.7
+      '@types/serve-static': 1.15.8
+
+  '@types/http-assert@1.5.6': {}
+
+  '@types/http-errors@2.0.5': {}
+
+  '@types/istanbul-lib-coverage@2.0.6': {}
+
+  '@types/istanbul-lib-report@3.0.3':
+    dependencies:
+      '@types/istanbul-lib-coverage': 2.0.6
+
+  '@types/istanbul-reports@3.0.4':
+    dependencies:
+      '@types/istanbul-lib-report': 3.0.3
+
+  '@types/keygrip@1.0.6': {}
+
+  '@types/koa-compose@3.2.8':
+    dependencies:
+      '@types/koa': 3.0.0
+
+  '@types/koa@2.15.0':
+    dependencies:
+      '@types/accepts': 1.3.7
+      '@types/content-disposition': 0.5.9
+      '@types/cookies': 0.9.1
+      '@types/http-assert': 1.5.6
+      '@types/http-errors': 2.0.5
+      '@types/keygrip': 1.0.6
+      '@types/koa-compose': 3.2.8
+      '@types/node': 24.3.0
+
+  '@types/koa@3.0.0':
+    dependencies:
+      '@types/accepts': 1.3.7
+      '@types/content-disposition': 0.5.9
+      '@types/cookies': 0.9.1
+      '@types/http-assert': 1.5.6
+      '@types/http-errors': 2.0.5
+      '@types/keygrip': 1.0.6
+      '@types/koa-compose': 3.2.8
+      '@types/node': 24.3.0
+
+  '@types/mime@1.3.5': {}
+
+  '@types/mkdirp@1.0.2':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/mocha@10.0.10': {}
+
+  '@types/node@24.3.0':
+    dependencies:
+      undici-types: 7.10.0
+
+  '@types/parse5@6.0.3': {}
+
+  '@types/pixelmatch@5.2.6':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/pngjs@6.0.5':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/qs@6.14.0': {}
+
+  '@types/range-parser@1.2.7': {}
+
+  '@types/resolve@1.20.2': {}
+
+  '@types/send@0.17.5':
+    dependencies:
+      '@types/mime': 1.3.5
+      '@types/node': 24.3.0
+
+  '@types/serve-static@1.15.8':
+    dependencies:
+      '@types/http-errors': 2.0.5
+      '@types/node': 24.3.0
+      '@types/send': 0.17.5
+
+  '@types/sinon-chai@3.2.12':
+    dependencies:
+      '@types/chai': 5.2.2
+      '@types/sinon': 17.0.4
+
+  '@types/sinon@17.0.4':
+    dependencies:
+      '@types/sinonjs__fake-timers': 8.1.5
+
+  '@types/sinonjs__fake-timers@8.1.5': {}
+
+  '@types/trusted-types@2.0.7': {}
+
+  '@types/ws@7.4.7':
+    dependencies:
+      '@types/node': 24.3.0
+
+  '@types/yauzl@2.10.3':
+    dependencies:
+      '@types/node': 24.3.0
+    optional: true
+
+  '@web/browser-logs@0.4.1':
+    dependencies:
+      errorstacks: 2.4.1
+
+  '@web/config-loader@0.3.3': {}
+
+  '@web/dev-server-core@0.7.5':
+    dependencies:
+      '@types/koa': 2.15.0
+      '@types/ws': 7.4.7
+      '@web/parse5-utils': 2.1.0
+      chokidar: 4.0.3
+      clone: 2.1.2
+      es-module-lexer: 1.7.0
+      get-stream: 6.0.1
+      is-stream: 2.0.1
+      isbinaryfile: 5.0.5
+      koa: 2.16.2
+      koa-etag: 4.0.0
+      koa-send: 5.0.1
+      koa-static: 5.0.0
+      lru-cache: 8.0.5
+      mime-types: 2.1.35
+      parse5: 6.0.1
+      picomatch: 2.3.1
+      ws: 7.5.10
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server-esbuild@1.0.4':
+    dependencies:
+      '@mdn/browser-compat-data': 4.2.1
+      '@web/dev-server-core': 0.7.5
+      esbuild: 0.25.9
+      parse5: 6.0.1
+      ua-parser-js: 1.0.41
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server-rollup@0.6.4':
+    dependencies:
+      '@rollup/plugin-node-resolve': 15.3.1(rollup@4.49.0)
+      '@web/dev-server-core': 0.7.5
+      nanocolors: 0.2.13
+      parse5: 6.0.1
+      rollup: 4.49.0
+      whatwg-url: 14.2.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/dev-server@0.4.6':
+    dependencies:
+      '@babel/code-frame': 7.27.1
+      '@types/command-line-args': 5.2.3
+      '@web/config-loader': 0.3.3
+      '@web/dev-server-core': 0.7.5
+      '@web/dev-server-rollup': 0.6.4
+      camelcase: 6.3.0
+      command-line-args: 5.2.1
+      command-line-usage: 7.0.3
+      debounce: 1.2.1
+      deepmerge: 4.3.1
+      internal-ip: 6.2.0
+      nanocolors: 0.2.13
+      open: 8.4.2
+      portfinder: 1.0.37
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/parse5-utils@2.1.0':
+    dependencies:
+      '@types/parse5': 6.0.3
+      parse5: 6.0.1
+
+  '@web/test-runner-chrome@0.18.1':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+      '@web/test-runner-coverage-v8': 0.8.0
+      chrome-launcher: 0.15.2
+      puppeteer-core: 24.17.0
+    transitivePeerDependencies:
+      - bare-buffer
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-commands@0.9.0':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+      mkdirp: 1.0.4
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-core@0.13.4':
+    dependencies:
+      '@babel/code-frame': 7.27.1
+      '@types/babel__code-frame': 7.0.6
+      '@types/co-body': 6.1.3
+      '@types/convert-source-map': 2.0.3
+      '@types/debounce': 1.2.4
+      '@types/istanbul-lib-coverage': 2.0.6
+      '@types/istanbul-reports': 3.0.4
+      '@web/browser-logs': 0.4.1
+      '@web/dev-server-core': 0.7.5
+      chokidar: 4.0.3
+      cli-cursor: 3.1.0
+      co-body: 6.2.0
+      convert-source-map: 2.0.0
+      debounce: 1.2.1
+      dependency-graph: 0.11.0
+      globby: 11.1.0
+      internal-ip: 6.2.0
+      istanbul-lib-coverage: 3.2.2
+      istanbul-lib-report: 3.0.1
+      istanbul-reports: 3.2.0
+      log-update: 4.0.0
+      nanocolors: 0.2.13
+      nanoid: 3.3.11
+      open: 8.4.2
+      picomatch: 2.3.1
+      source-map: 0.7.6
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-coverage-v8@0.8.0':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+      istanbul-lib-coverage: 3.2.2
+      lru-cache: 8.0.5
+      picomatch: 2.3.1
+      v8-to-istanbul: 9.3.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-mocha@0.9.0':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-playwright@0.11.1':
+    dependencies:
+      '@web/test-runner-core': 0.13.4
+      '@web/test-runner-coverage-v8': 0.8.0
+      playwright: 1.55.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner-visual-regression@0.10.0':
+    dependencies:
+      '@types/mkdirp': 1.0.2
+      '@types/pixelmatch': 5.2.6
+      '@types/pngjs': 6.0.5
+      '@web/test-runner-commands': 0.9.0
+      '@web/test-runner-core': 0.13.4
+      mkdirp: 1.0.4
+      pixelmatch: 5.3.0
+      pngjs: 7.0.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  '@web/test-runner@0.20.2':
+    dependencies:
+      '@web/browser-logs': 0.4.1
+      '@web/config-loader': 0.3.3
+      '@web/dev-server': 0.4.6
+      '@web/test-runner-chrome': 0.18.1
+      '@web/test-runner-commands': 0.9.0
+      '@web/test-runner-core': 0.13.4
+      '@web/test-runner-mocha': 0.9.0
+      camelcase: 6.3.0
+      command-line-args: 5.2.1
+      command-line-usage: 7.0.3
+      convert-source-map: 2.0.0
+      diff: 5.2.0
+      globby: 11.1.0
+      nanocolors: 0.2.13
+      portfinder: 1.0.37
+      source-map: 0.7.6
+    transitivePeerDependencies:
+      - bare-buffer
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  accepts@1.3.8:
+    dependencies:
+      mime-types: 2.1.35
+      negotiator: 0.6.3
+
+  accessibility-developer-tools@2.12.0: {}
+
+  agent-base@7.1.4: {}
+
+  ansi-colors@4.1.3: {}
+
+  ansi-escapes@4.3.2:
+    dependencies:
+      type-fest: 0.21.3
+
+  ansi-regex@5.0.1: {}
+
+  ansi-styles@4.3.0:
+    dependencies:
+      color-convert: 2.0.1
+
+  anymatch@3.1.3:
+    dependencies:
+      normalize-path: 3.0.0
+      picomatch: 2.3.1
+
+  argparse@2.0.1: {}
+
+  array-back@3.1.0: {}
+
+  array-back@6.2.2: {}
+
+  array-union@2.1.0: {}
+
+  ast-types@0.13.4:
+    dependencies:
+      tslib: 2.8.1
+
+  astral-regex@2.0.0: {}
+
+  async@3.2.6: {}
+
+  axe-core@4.10.3: {}
+
+  b4a@1.6.7: {}
+
+  balanced-match@1.0.2: {}
+
+  bare-events@2.6.1:
+    optional: true
+
+  bare-fs@4.2.1:
+    dependencies:
+      bare-events: 2.6.1
+      bare-path: 3.0.0
+      bare-stream: 2.7.0(bare-events@2.6.1)
+    optional: true
+
+  bare-os@3.6.2:
+    optional: true
+
+  bare-path@3.0.0:
+    dependencies:
+      bare-os: 3.6.2
+    optional: true
+
+  bare-stream@2.7.0(bare-events@2.6.1):
+    dependencies:
+      streamx: 2.22.1
+    optionalDependencies:
+      bare-events: 2.6.1
+    optional: true
+
+  basic-ftp@5.0.5: {}
+
+  binary-extensions@2.3.0: {}
+
+  brace-expansion@2.0.2:
+    dependencies:
+      balanced-match: 1.0.2
+
+  braces@3.0.3:
+    dependencies:
+      fill-range: 7.1.1
+
+  browser-stdout@1.3.1: {}
+
+  buffer-crc32@0.2.13: {}
+
+  buffer-from@1.1.2: {}
+
+  bytes@3.1.2: {}
+
+  cache-content-type@1.0.1:
+    dependencies:
+      mime-types: 2.1.35
+      ylru: 1.4.0
+
+  call-bind-apply-helpers@1.0.2:
+    dependencies:
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+
+  call-bound@1.0.4:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      get-intrinsic: 1.3.0
+
+  camelcase@6.3.0: {}
+
+  chai-a11y-axe@1.5.0:
+    dependencies:
+      axe-core: 4.10.3
+
+  chalk-template@0.4.0:
+    dependencies:
+      chalk: 4.1.2
+
+  chalk@4.1.2:
+    dependencies:
+      ansi-styles: 4.3.0
+      supports-color: 7.2.0
+
+  chokidar@3.6.0:
+    dependencies:
+      anymatch: 3.1.3
+      braces: 3.0.3
+      glob-parent: 5.1.2
+      is-binary-path: 2.1.0
+      is-glob: 4.0.3
+      normalize-path: 3.0.0
+      readdirp: 3.6.0
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  chokidar@4.0.3:
+    dependencies:
+      readdirp: 4.1.2
+
+  chrome-launcher@0.15.2:
+    dependencies:
+      '@types/node': 24.3.0
+      escape-string-regexp: 4.0.0
+      is-wsl: 2.2.0
+      lighthouse-logger: 1.4.2
+    transitivePeerDependencies:
+      - supports-color
+
+  chromium-bidi@8.0.0(devtools-protocol@0.0.1475386):
+    dependencies:
+      devtools-protocol: 0.0.1475386
+      mitt: 3.0.1
+      zod: 3.25.76
+
+  cli-cursor@3.1.0:
+    dependencies:
+      restore-cursor: 3.1.0
+
+  cliui@7.0.4:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 7.0.0
+
+  cliui@8.0.1:
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 7.0.0
+
+  clone@2.1.2: {}
+
+  co-body@6.2.0:
+    dependencies:
+      '@hapi/bourne': 3.0.0
+      inflation: 2.1.0
+      qs: 6.14.0
+      raw-body: 2.5.2
+      type-is: 1.6.18
+
+  co@4.6.0: {}
+
+  color-convert@2.0.1:
+    dependencies:
+      color-name: 1.1.4
+
+  color-name@1.1.4: {}
+
+  command-line-args@5.2.1:
+    dependencies:
+      array-back: 3.1.0
+      find-replace: 3.0.0
+      lodash.camelcase: 4.3.0
+      typical: 4.0.0
+
+  command-line-usage@7.0.3:
+    dependencies:
+      array-back: 6.2.2
+      chalk-template: 0.4.0
+      table-layout: 4.1.1
+      typical: 7.3.0
+
+  content-disposition@0.5.4:
+    dependencies:
+      safe-buffer: 5.2.1
+
+  content-type@1.0.5: {}
+
+  convert-source-map@2.0.0: {}
+
+  cookies@0.9.1:
+    dependencies:
+      depd: 2.0.0
+      keygrip: 1.1.0
+
+  cross-spawn@7.0.6:
+    dependencies:
+      path-key: 3.1.1
+      shebang-command: 2.0.0
+      which: 2.0.2
+
+  data-uri-to-buffer@6.0.2: {}
+
+  debounce@1.2.1: {}
+
+  debug@2.6.9:
+    dependencies:
+      ms: 2.0.0
+
+  debug@3.2.7:
+    dependencies:
+      ms: 2.1.3
+
+  debug@4.4.1(supports-color@8.1.1):
+    dependencies:
+      ms: 2.1.3
+    optionalDependencies:
+      supports-color: 8.1.1
+
+  decamelize@4.0.0: {}
+
+  deep-equal@1.0.1: {}
+
+  deepmerge@4.3.1: {}
+
+  default-gateway@6.0.3:
+    dependencies:
+      execa: 5.1.1
+
+  define-lazy-prop@2.0.0: {}
+
+  degenerator@5.0.1:
+    dependencies:
+      ast-types: 0.13.4
+      escodegen: 2.1.0
+      esprima: 4.0.1
+
+  delegates@1.0.0: {}
+
+  depd@1.1.2: {}
+
+  depd@2.0.0: {}
+
+  dependency-graph@0.11.0: {}
+
+  destroy@1.2.0: {}
+
+  devtools-protocol@0.0.1475386: {}
+
+  diff@5.2.0: {}
+
+  diff@7.0.0: {}
+
+  dir-glob@3.0.1:
+    dependencies:
+      path-type: 4.0.0
+
+  dunder-proto@1.0.1:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  ee-first@1.1.1: {}
+
+  emoji-regex@8.0.0: {}
+
+  encodeurl@1.0.2: {}
+
+  end-of-stream@1.4.5:
+    dependencies:
+      once: 1.4.0
+
+  errorstacks@2.4.1: {}
+
+  es-define-property@1.0.1: {}
+
+  es-errors@1.3.0: {}
+
+  es-module-lexer@1.7.0: {}
+
+  es-object-atoms@1.1.1:
+    dependencies:
+      es-errors: 1.3.0
+
+  esbuild@0.25.9:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.25.9
+      '@esbuild/android-arm': 0.25.9
+      '@esbuild/android-arm64': 0.25.9
+      '@esbuild/android-x64': 0.25.9
+      '@esbuild/darwin-arm64': 0.25.9
+      '@esbuild/darwin-x64': 0.25.9
+      '@esbuild/freebsd-arm64': 0.25.9
+      '@esbuild/freebsd-x64': 0.25.9
+      '@esbuild/linux-arm': 0.25.9
+      '@esbuild/linux-arm64': 0.25.9
+      '@esbuild/linux-ia32': 0.25.9
+      '@esbuild/linux-loong64': 0.25.9
+      '@esbuild/linux-mips64el': 0.25.9
+      '@esbuild/linux-ppc64': 0.25.9
+      '@esbuild/linux-riscv64': 0.25.9
+      '@esbuild/linux-s390x': 0.25.9
+      '@esbuild/linux-x64': 0.25.9
+      '@esbuild/netbsd-arm64': 0.25.9
+      '@esbuild/netbsd-x64': 0.25.9
+      '@esbuild/openbsd-arm64': 0.25.9
+      '@esbuild/openbsd-x64': 0.25.9
+      '@esbuild/openharmony-arm64': 0.25.9
+      '@esbuild/sunos-x64': 0.25.9
+      '@esbuild/win32-arm64': 0.25.9
+      '@esbuild/win32-ia32': 0.25.9
+      '@esbuild/win32-x64': 0.25.9
+
+  escalade@3.2.0: {}
+
+  escape-html@1.0.3: {}
+
+  escape-string-regexp@4.0.0: {}
+
+  escodegen@2.1.0:
+    dependencies:
+      esprima: 4.0.1
+      estraverse: 5.3.0
+      esutils: 2.0.3
+    optionalDependencies:
+      source-map: 0.6.1
+
+  esprima@4.0.1: {}
+
+  estraverse@5.3.0: {}
+
+  estree-walker@2.0.2: {}
+
+  esutils@2.0.3: {}
+
+  etag@1.8.1: {}
+
+  execa@5.1.1:
+    dependencies:
+      cross-spawn: 7.0.6
+      get-stream: 6.0.1
+      human-signals: 2.1.0
+      is-stream: 2.0.1
+      merge-stream: 2.0.0
+      npm-run-path: 4.0.1
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+      strip-final-newline: 2.0.0
+
+  extract-zip@2.0.1:
+    dependencies:
+      debug: 4.4.1(supports-color@8.1.1)
+      get-stream: 5.2.0
+      yauzl: 2.10.0
+    optionalDependencies:
+      '@types/yauzl': 2.10.3
+    transitivePeerDependencies:
+      - supports-color
+
+  fast-fifo@1.3.2: {}
+
+  fast-glob@3.3.3:
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      '@nodelib/fs.walk': 1.2.8
+      glob-parent: 5.1.2
+      merge2: 1.4.1
+      micromatch: 4.0.8
+
+  fastq@1.19.1:
+    dependencies:
+      reusify: 1.1.0
+
+  fd-slicer@1.1.0:
+    dependencies:
+      pend: 1.2.0
+
+  fill-range@7.1.1:
+    dependencies:
+      to-regex-range: 5.0.1
+
+  find-replace@3.0.0:
+    dependencies:
+      array-back: 3.1.0
+
+  find-up@5.0.0:
+    dependencies:
+      locate-path: 6.0.0
+      path-exists: 4.0.0
+
+  flat@5.0.2: {}
+
+  fresh@0.5.2: {}
+
+  fs.realpath@1.0.0: {}
+
+  fsevents@2.3.2:
+    optional: true
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  get-caller-file@2.0.5: {}
+
+  get-intrinsic@1.3.0:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.1
+      function-bind: 1.1.2
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.2
+      math-intrinsics: 1.1.0
+
+  get-proto@1.0.1:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-object-atoms: 1.1.1
+
+  get-stream@5.2.0:
+    dependencies:
+      pump: 3.0.3
+
+  get-stream@6.0.1: {}
+
+  get-uri@6.0.5:
+    dependencies:
+      basic-ftp: 5.0.5
+      data-uri-to-buffer: 6.0.2
+      debug: 4.4.1(supports-color@8.1.1)
+    transitivePeerDependencies:
+      - supports-color
+
+  glob-parent@5.1.2:
+    dependencies:
+      is-glob: 4.0.3
+
+  glob@8.1.0:
+    dependencies:
+      fs.realpath: 1.0.0
+      inflight: 1.0.6
+      inherits: 2.0.4
+      minimatch: 5.1.6
+      once: 1.4.0
+
+  globby@11.1.0:
+    dependencies:
+      array-union: 2.1.0
+      dir-glob: 3.0.1
+      fast-glob: 3.3.3
+      ignore: 5.3.2
+      merge2: 1.4.1
+      slash: 3.0.0
+
+  gopd@1.2.0: {}
+
+  has-flag@4.0.0: {}
+
+  has-symbols@1.1.0: {}
+
+  has-tostringtag@1.0.2:
+    dependencies:
+      has-symbols: 1.1.0
+
+  hasown@2.0.2:
+    dependencies:
+      function-bind: 1.1.2
+
+  he@1.2.0: {}
+
+  html-escaper@2.0.2: {}
+
+  http-assert@1.5.0:
+    dependencies:
+      deep-equal: 1.0.1
+      http-errors: 1.8.1
+
+  http-errors@1.6.3:
+    dependencies:
+      depd: 1.1.2
+      inherits: 2.0.3
+      setprototypeof: 1.1.0
+      statuses: 1.5.0
+
+  http-errors@1.8.1:
+    dependencies:
+      depd: 1.1.2
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 1.5.0
+      toidentifier: 1.0.1
+
+  http-errors@2.0.0:
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.1
+      toidentifier: 1.0.1
+
+  http-proxy-agent@7.0.2:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1(supports-color@8.1.1)
+    transitivePeerDependencies:
+      - supports-color
+
+  https-proxy-agent@7.0.6:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1(supports-color@8.1.1)
+    transitivePeerDependencies:
+      - supports-color
+
+  human-signals@2.1.0: {}
+
+  iconv-lite@0.4.24:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  ignore@5.3.2: {}
+
+  inflation@2.1.0: {}
+
+  inflight@1.0.6:
+    dependencies:
+      once: 1.4.0
+      wrappy: 1.0.2
+
+  inherits@2.0.3: {}
+
+  inherits@2.0.4: {}
+
+  internal-ip@6.2.0:
+    dependencies:
+      default-gateway: 6.0.3
+      ipaddr.js: 1.9.1
+      is-ip: 3.1.0
+      p-event: 4.2.0
+
+  ip-address@10.0.1: {}
+
+  ip-regex@4.3.0: {}
+
+  ipaddr.js@1.9.1: {}
+
+  is-binary-path@2.1.0:
+    dependencies:
+      binary-extensions: 2.3.0
+
+  is-core-module@2.16.1:
+    dependencies:
+      hasown: 2.0.2
+
+  is-docker@2.2.1: {}
+
+  is-extglob@2.1.1: {}
+
+  is-fullwidth-code-point@3.0.0: {}
+
+  is-generator-function@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      get-proto: 1.0.1
+      has-tostringtag: 1.0.2
+      safe-regex-test: 1.1.0
+
+  is-glob@4.0.3:
+    dependencies:
+      is-extglob: 2.1.1
+
+  is-ip@3.1.0:
+    dependencies:
+      ip-regex: 4.3.0
+
+  is-module@1.0.0: {}
+
+  is-number@7.0.0: {}
+
+  is-plain-obj@2.1.0: {}
+
+  is-regex@1.2.1:
+    dependencies:
+      call-bound: 1.0.4
+      gopd: 1.2.0
+      has-tostringtag: 1.0.2
+      hasown: 2.0.2
+
+  is-stream@2.0.1: {}
+
+  is-unicode-supported@0.1.0: {}
+
+  is-wsl@2.2.0:
+    dependencies:
+      is-docker: 2.2.1
+
+  isbinaryfile@5.0.5: {}
+
+  isexe@2.0.0: {}
+
+  istanbul-lib-coverage@3.2.2: {}
+
+  istanbul-lib-report@3.0.1:
+    dependencies:
+      istanbul-lib-coverage: 3.2.2
+      make-dir: 4.0.0
+      supports-color: 7.2.0
+
+  istanbul-reports@3.2.0:
+    dependencies:
+      html-escaper: 2.0.2
+      istanbul-lib-report: 3.0.1
+
+  js-tokens@4.0.0: {}
+
+  js-yaml@4.1.0:
+    dependencies:
+      argparse: 2.0.1
+
+  keygrip@1.1.0:
+    dependencies:
+      tsscmp: 1.0.6
+
+  koa-compose@4.1.0: {}
+
+  koa-convert@2.0.0:
+    dependencies:
+      co: 4.6.0
+      koa-compose: 4.1.0
+
+  koa-etag@4.0.0:
+    dependencies:
+      etag: 1.8.1
+
+  koa-send@5.0.1:
+    dependencies:
+      debug: 4.4.1(supports-color@8.1.1)
+      http-errors: 1.8.1
+      resolve-path: 1.4.0
+    transitivePeerDependencies:
+      - supports-color
+
+  koa-static@5.0.0:
+    dependencies:
+      debug: 3.2.7
+      koa-send: 5.0.1
+    transitivePeerDependencies:
+      - supports-color
+
+  koa@2.16.2:
+    dependencies:
+      accepts: 1.3.8
+      cache-content-type: 1.0.1
+      content-disposition: 0.5.4
+      content-type: 1.0.5
+      cookies: 0.9.1
+      debug: 4.4.1(supports-color@8.1.1)
+      delegates: 1.0.0
+      depd: 2.0.0
+      destroy: 1.2.0
+      encodeurl: 1.0.2
+      escape-html: 1.0.3
+      fresh: 0.5.2
+      http-assert: 1.5.0
+      http-errors: 1.8.1
+      is-generator-function: 1.1.0
+      koa-compose: 4.1.0
+      koa-convert: 2.0.0
+      on-finished: 2.4.1
+      only: 0.0.2
+      parseurl: 1.3.3
+      statuses: 1.5.0
+      type-is: 1.6.18
+      vary: 1.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  lighthouse-logger@1.4.2:
+    dependencies:
+      debug: 2.6.9
+      marky: 1.3.0
+    transitivePeerDependencies:
+      - supports-color
+
+  lit-element@4.2.2:
+    dependencies:
+      '@lit-labs/ssr-dom-shim': 1.6.0
+      '@lit/reactive-element': 2.1.1
+      lit-html: 3.3.2
+
+  lit-html@3.3.2:
+    dependencies:
+      '@types/trusted-types': 2.0.7
+
+  lit@3.3.2:
+    dependencies:
+      '@lit/reactive-element': 2.1.1
+      lit-element: 4.2.2
+      lit-html: 3.3.2
+
+  locate-path@6.0.0:
+    dependencies:
+      p-locate: 5.0.0
+
+  lodash.camelcase@4.3.0: {}
+
+  log-symbols@4.1.0:
+    dependencies:
+      chalk: 4.1.2
+      is-unicode-supported: 0.1.0
+
+  log-update@4.0.0:
+    dependencies:
+      ansi-escapes: 4.3.2
+      cli-cursor: 3.1.0
+      slice-ansi: 4.0.0
+      wrap-ansi: 6.2.0
+
+  lru-cache@7.18.3: {}
+
+  lru-cache@8.0.5: {}
+
+  make-dir@4.0.0:
+    dependencies:
+      semver: 7.7.2
+
+  marky@1.3.0: {}
+
+  math-intrinsics@1.1.0: {}
+
+  media-typer@0.3.0: {}
+
+  merge-stream@2.0.0: {}
+
+  merge2@1.4.1: {}
+
+  micromatch@4.0.8:
+    dependencies:
+      braces: 3.0.3
+      picomatch: 2.3.1
+
+  mime-db@1.52.0: {}
+
+  mime-types@2.1.35:
+    dependencies:
+      mime-db: 1.52.0
+
+  mimic-fn@2.1.0: {}
+
+  minimatch@5.1.6:
+    dependencies:
+      brace-expansion: 2.0.2
+
+  mitt@3.0.1: {}
+
+  mkdirp@1.0.4: {}
+
+  mocha@10.8.2:
+    dependencies:
+      ansi-colors: 4.1.3
+      browser-stdout: 1.3.1
+      chokidar: 3.6.0
+      debug: 4.4.1(supports-color@8.1.1)
+      diff: 5.2.0
+      escape-string-regexp: 4.0.0
+      find-up: 5.0.0
+      glob: 8.1.0
+      he: 1.2.0
+      js-yaml: 4.1.0
+      log-symbols: 4.1.0
+      minimatch: 5.1.6
+      ms: 2.1.3
+      serialize-javascript: 6.0.2
+      strip-json-comments: 3.1.1
+      supports-color: 8.1.1
+      workerpool: 6.5.1
+      yargs: 16.2.0
+      yargs-parser: 20.2.9
+      yargs-unparser: 2.0.0
+
+  ms@2.0.0: {}
+
+  ms@2.1.3: {}
+
+  nanocolors@0.2.13: {}
+
+  nanoid@3.3.11: {}
+
+  negotiator@0.6.3: {}
+
+  netmask@2.0.2: {}
+
+  normalize-path@3.0.0: {}
+
+  npm-run-path@4.0.1:
+    dependencies:
+      path-key: 3.1.1
+
+  object-inspect@1.13.4: {}
+
+  on-finished@2.4.1:
+    dependencies:
+      ee-first: 1.1.1
+
+  once@1.4.0:
+    dependencies:
+      wrappy: 1.0.2
+
+  onetime@5.1.2:
+    dependencies:
+      mimic-fn: 2.1.0
+
+  only@0.0.2: {}
+
+  open@8.4.2:
+    dependencies:
+      define-lazy-prop: 2.0.0
+      is-docker: 2.2.1
+      is-wsl: 2.2.0
+
+  p-event@4.2.0:
+    dependencies:
+      p-timeout: 3.2.0
+
+  p-finally@1.0.0: {}
+
+  p-limit@3.1.0:
+    dependencies:
+      yocto-queue: 0.1.0
+
+  p-locate@5.0.0:
+    dependencies:
+      p-limit: 3.1.0
+
+  p-timeout@3.2.0:
+    dependencies:
+      p-finally: 1.0.0
+
+  pac-proxy-agent@7.2.0:
+    dependencies:
+      '@tootallnate/quickjs-emscripten': 0.23.0
+      agent-base: 7.1.4
+      debug: 4.4.1(supports-color@8.1.1)
+      get-uri: 6.0.5
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.6
+      pac-resolver: 7.0.1
+      socks-proxy-agent: 8.0.5
+    transitivePeerDependencies:
+      - supports-color
+
+  pac-resolver@7.0.1:
+    dependencies:
+      degenerator: 5.0.1
+      netmask: 2.0.2
+
+  parse5@6.0.1: {}
+
+  parseurl@1.3.3: {}
+
+  path-exists@4.0.0: {}
+
+  path-is-absolute@1.0.1: {}
+
+  path-key@3.1.1: {}
+
+  path-parse@1.0.7: {}
+
+  path-type@4.0.0: {}
+
+  pend@1.2.0: {}
+
+  picocolors@1.1.1: {}
+
+  picomatch@2.3.1: {}
+
+  picomatch@4.0.3: {}
+
+  pixelmatch@5.3.0:
+    dependencies:
+      pngjs: 6.0.0
+
+  pixelmatch@7.2.0:
+    dependencies:
+      pngjs: 7.0.0
+
+  playwright-core@1.55.0: {}
+
+  playwright@1.55.0:
+    dependencies:
+      playwright-core: 1.55.0
+    optionalDependencies:
+      fsevents: 2.3.2
+
+  pngjs@6.0.0: {}
+
+  pngjs@7.0.0: {}
+
+  portfinder@1.0.37:
+    dependencies:
+      async: 3.2.6
+      debug: 4.4.1(supports-color@8.1.1)
+    transitivePeerDependencies:
+      - supports-color
+
+  progress@2.0.3: {}
+
+  proxy-agent@6.5.0:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1(supports-color@8.1.1)
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.6
+      lru-cache: 7.18.3
+      pac-proxy-agent: 7.2.0
+      proxy-from-env: 1.1.0
+      socks-proxy-agent: 8.0.5
+    transitivePeerDependencies:
+      - supports-color
+
+  proxy-from-env@1.1.0: {}
+
+  pump@3.0.3:
+    dependencies:
+      end-of-stream: 1.4.5
+      once: 1.4.0
+
+  punycode@2.3.1: {}
+
+  puppeteer-core@24.17.0:
+    dependencies:
+      '@puppeteer/browsers': 2.10.7
+      chromium-bidi: 8.0.0(devtools-protocol@0.0.1475386)
+      debug: 4.4.1(supports-color@8.1.1)
+      devtools-protocol: 0.0.1475386
+      typed-query-selector: 2.12.0
+      ws: 8.18.3
+    transitivePeerDependencies:
+      - bare-buffer
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
+  qs@6.14.0:
+    dependencies:
+      side-channel: 1.1.0
+
+  queue-microtask@1.2.3: {}
+
+  randombytes@2.1.0:
+    dependencies:
+      safe-buffer: 5.2.1
+
+  raw-body@2.5.2:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.0
+      iconv-lite: 0.4.24
+      unpipe: 1.0.0
+
+  readdirp@3.6.0:
+    dependencies:
+      picomatch: 2.3.1
+
+  readdirp@4.1.2: {}
+
+  require-directory@2.1.1: {}
+
+  resolve-path@1.4.0:
+    dependencies:
+      http-errors: 1.6.3
+      path-is-absolute: 1.0.1
+
+  resolve@1.22.10:
+    dependencies:
+      is-core-module: 2.16.1
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+
+  restore-cursor@3.1.0:
+    dependencies:
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+
+  reusify@1.1.0: {}
+
+  rollup@4.49.0:
+    dependencies:
+      '@types/estree': 1.0.8
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.49.0
+      '@rollup/rollup-android-arm64': 4.49.0
+      '@rollup/rollup-darwin-arm64': 4.49.0
+      '@rollup/rollup-darwin-x64': 4.49.0
+      '@rollup/rollup-freebsd-arm64': 4.49.0
+      '@rollup/rollup-freebsd-x64': 4.49.0
+      '@rollup/rollup-linux-arm-gnueabihf': 4.49.0
+      '@rollup/rollup-linux-arm-musleabihf': 4.49.0
+      '@rollup/rollup-linux-arm64-gnu': 4.49.0
+      '@rollup/rollup-linux-arm64-musl': 4.49.0
+      '@rollup/rollup-linux-loongarch64-gnu': 4.49.0
+      '@rollup/rollup-linux-ppc64-gnu': 4.49.0
+      '@rollup/rollup-linux-riscv64-gnu': 4.49.0
+      '@rollup/rollup-linux-riscv64-musl': 4.49.0
+      '@rollup/rollup-linux-s390x-gnu': 4.49.0
+      '@rollup/rollup-linux-x64-gnu': 4.49.0
+      '@rollup/rollup-linux-x64-musl': 4.49.0
+      '@rollup/rollup-win32-arm64-msvc': 4.49.0
+      '@rollup/rollup-win32-ia32-msvc': 4.49.0
+      '@rollup/rollup-win32-x64-msvc': 4.49.0
+      fsevents: 2.3.3
+
+  run-parallel@1.2.0:
+    dependencies:
+      queue-microtask: 1.2.3
+
+  safe-buffer@5.2.1: {}
+
+  safe-regex-test@1.1.0:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      is-regex: 1.2.1
+
+  safer-buffer@2.1.2: {}
+
+  semver@7.7.2: {}
+
+  serialize-javascript@6.0.2:
+    dependencies:
+      randombytes: 2.1.0
+
+  setprototypeof@1.1.0: {}
+
+  setprototypeof@1.2.0: {}
+
+  shebang-command@2.0.0:
+    dependencies:
+      shebang-regex: 3.0.0
+
+  shebang-regex@3.0.0: {}
+
+  side-channel-list@1.0.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-map@1.0.1:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-weakmap@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-map: 1.0.1
+
+  side-channel@1.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-list: 1.0.0
+      side-channel-map: 1.0.1
+      side-channel-weakmap: 1.0.2
+
+  signal-exit@3.0.7: {}
+
+  sinon@20.0.0:
+    dependencies:
+      '@sinonjs/commons': 3.0.1
+      '@sinonjs/fake-timers': 13.0.5
+      '@sinonjs/samsam': 8.0.3
+      diff: 7.0.0
+      supports-color: 7.2.0
+
+  slash@3.0.0: {}
+
+  slice-ansi@4.0.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      astral-regex: 2.0.0
+      is-fullwidth-code-point: 3.0.0
+
+  smart-buffer@4.2.0: {}
+
+  socks-proxy-agent@8.0.5:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1(supports-color@8.1.1)
+      socks: 2.8.7
+    transitivePeerDependencies:
+      - supports-color
+
+  socks@2.8.7:
+    dependencies:
+      ip-address: 10.0.1
+      smart-buffer: 4.2.0
+
+  source-map-support@0.5.21:
+    dependencies:
+      buffer-from: 1.1.2
+      source-map: 0.6.1
+
+  source-map@0.6.1: {}
+
+  source-map@0.7.6: {}
+
+  statuses@1.5.0: {}
+
+  statuses@2.0.1: {}
+
+  streamx@2.22.1:
+    dependencies:
+      fast-fifo: 1.3.2
+      text-decoder: 1.2.3
+    optionalDependencies:
+      bare-events: 2.6.1
+
+  string-width@4.2.3:
+    dependencies:
+      emoji-regex: 8.0.0
+      is-fullwidth-code-point: 3.0.0
+      strip-ansi: 6.0.1
+
+  strip-ansi@6.0.1:
+    dependencies:
+      ansi-regex: 5.0.1
+
+  strip-final-newline@2.0.0: {}
+
+  strip-json-comments@3.1.1: {}
+
+  supports-color@7.2.0:
+    dependencies:
+      has-flag: 4.0.0
+
+  supports-color@8.1.1:
+    dependencies:
+      has-flag: 4.0.0
+
+  supports-preserve-symlinks-flag@1.0.0: {}
+
+  table-layout@4.1.1:
+    dependencies:
+      array-back: 6.2.2
+      wordwrapjs: 5.1.0
+
+  tar-fs@3.1.0:
+    dependencies:
+      pump: 3.0.3
+      tar-stream: 3.1.7
+    optionalDependencies:
+      bare-fs: 4.2.1
+      bare-path: 3.0.0
+    transitivePeerDependencies:
+      - bare-buffer
+
+  tar-stream@3.1.7:
+    dependencies:
+      b4a: 1.6.7
+      fast-fifo: 1.3.2
+      streamx: 2.22.1
+
+  text-decoder@1.2.3:
+    dependencies:
+      b4a: 1.6.7
+
+  to-regex-range@5.0.1:
+    dependencies:
+      is-number: 7.0.0
+
+  toidentifier@1.0.1: {}
+
+  tr46@5.1.1:
+    dependencies:
+      punycode: 2.3.1
+
+  tslib@2.8.1: {}
+
+  tsscmp@1.0.6: {}
+
+  type-detect@4.0.8: {}
+
+  type-detect@4.1.0: {}
+
+  type-fest@0.21.3: {}
+
+  type-is@1.6.18:
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.35
+
+  typed-query-selector@2.12.0: {}
+
+  typical@4.0.0: {}
+
+  typical@7.3.0: {}
+
+  ua-parser-js@1.0.41: {}
+
+  undici-types@7.10.0: {}
+
+  unpipe@1.0.0: {}
+
+  v8-to-istanbul@9.3.0:
+    dependencies:
+      '@jridgewell/trace-mapping': 0.3.30
+      '@types/istanbul-lib-coverage': 2.0.6
+      convert-source-map: 2.0.0
+
+  vary@1.1.2: {}
+
+  webidl-conversions@7.0.0: {}
+
+  whatwg-url@14.2.0:
+    dependencies:
+      tr46: 5.1.1
+      webidl-conversions: 7.0.0
+
+  which@2.0.2:
+    dependencies:
+      isexe: 2.0.0
+
+  wordwrapjs@5.1.0: {}
+
+  workerpool@6.5.1: {}
+
+  wrap-ansi@6.2.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  wrap-ansi@7.0.0:
+    dependencies:
+      ansi-styles: 4.3.0
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+
+  wrappy@1.0.2: {}
+
+  ws@7.5.10: {}
+
+  ws@8.18.3: {}
+
+  y18n@5.0.8: {}
+
+  yargs-parser@20.2.9: {}
+
+  yargs-parser@21.1.1: {}
+
+  yargs-unparser@2.0.0:
+    dependencies:
+      camelcase: 6.3.0
+      decamelize: 4.0.0
+      flat: 5.0.2
+      is-plain-obj: 2.1.0
+
+  yargs@16.2.0:
+    dependencies:
+      cliui: 7.0.4
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 20.2.9
+
+  yargs@17.7.2:
+    dependencies:
+      cliui: 8.0.1
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 21.1.1
+
+  yauzl@2.10.0:
+    dependencies:
+      buffer-crc32: 0.2.13
+      fd-slicer: 1.1.0
+
+  ylru@1.4.0: {}
+
+  yocto-queue@0.1.0: {}
+
+  zod@3.25.76: {}
diff --git a/polygerrit-ui/pnpm-workspace.yaml b/polygerrit-ui/pnpm-workspace.yaml
new file mode 100644
index 0000000..7d40629
--- /dev/null
+++ b/polygerrit-ui/pnpm-workspace.yaml
@@ -0,0 +1 @@
+onlyBuiltDependencies: []
diff --git a/polygerrit-ui/polygerrit.MODULE.bazel b/polygerrit-ui/polygerrit.MODULE.bazel
new file mode 100644
index 0000000..e04ccd6
--- /dev/null
+++ b/polygerrit-ui/polygerrit.MODULE.bazel
@@ -0,0 +1,106 @@
+# npm packages are split into different node_modules directories based on their
+# usage.
+# 1. @npm (node_modules) - contains packages to run tests, check code, etc...
+#    It is expected that @npm is used ONLY to run tools. No packages from @npm
+#    are used by other code in gerrit.
+# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder
+#    contains self-written tools which are run for building and/or testing. The
+#    @tools_npm directory contains all the packages needed to run this tools.
+# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which
+#    are necessary to run polygerrit and to bundle it. Only code from these
+#    packages can be included in the final bundle for polygerrit. @ui_npm folder
+#    must not have devDependencies. All devDependencies must be placed in
+#    @ui_dev_npm.
+# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit.
+#    The packages from these folder can be used for testing, but must not be
+#    included in the final bundle.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit
+#    plugins. The packages here are expected to be used in plugins.
+# Note: separation between @ui_npm and @ui_dev_npm is necessary because with
+#    rules_nodejs we can't generate two external repositories from the same
+#    package.json. At the same time we want to avoid accidental usages of code
+#    from devDependencies in polygerrit bundle.
+
+rules_ts_ext = use_extension("@aspect_rules_ts//ts:extensions.bzl", "ext", dev_dependency = True)
+rules_ts_ext.deps(
+    ts_version_from = "//:package.json",
+)
+use_repo(rules_ts_ext, "npm_typescript")
+
+# The node version is read from //:.nvmrc, the cross-tool convention file
+# also honored by nvm and CI -- Bazel and the yarn dev flow share one pin.
+# Versions unknown to the pinned rules_nodejs release are auto-fetched and
+# recorded in MODULE.bazel.lock (rules_nodejs >= 6.7.5).
+node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
+node.toolchain(node_version_from_nvmrc = "//:.nvmrc")
+
+npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
+npm.npm_translate_lock(
+    name = "npm",
+    data = [
+        "//:package.json",
+        "//:pnpm-workspace.yaml",
+    ],
+    pnpm_lock = "//:pnpm-lock.yaml",
+    update_pnpm_lock = True,
+    yarn_lock = "//:yarn.lock",
+)
+use_repo(npm, "npm")
+
+# Keep no_optional=True.
+# ui_npm is the browser/runtime dependency set for PolyGerrit.
+# If this is removed, aspect_rules_js npm_translate_lock defaults to installing
+# optionalDependencies from the full transitive graph (not just from our root
+# package.json). In this repo that pulls in Resemble.js -> canvas ->
+# prebuild-install/native helper packages, which changes the realized
+# node_modules tree and breaks license generation.
+# This differs from normal pnpm behavior, which may skip platform-unneeded
+# optional deps.
+npm.npm_translate_lock(
+    name = "ui_npm",
+    data = [
+        "//polygerrit-ui/app:package.json",
+        "//polygerrit-ui/app:pnpm-workspace.yaml",
+    ],
+    no_optional = True,
+    pnpm_lock = "//polygerrit-ui/app:pnpm-lock.yaml",
+    update_pnpm_lock = True,
+    yarn_lock = "//polygerrit-ui/app:yarn.lock",
+)
+use_repo(npm, "ui_npm")
+
+npm.npm_translate_lock(
+    name = "ui_dev_npm",
+    data = [
+        "//polygerrit-ui:package.json",
+        "//polygerrit-ui:pnpm-workspace.yaml",
+    ],
+    pnpm_lock = "//polygerrit-ui:pnpm-lock.yaml",
+    update_pnpm_lock = True,
+    yarn_lock = "//polygerrit-ui:yarn.lock",
+)
+use_repo(npm, "ui_dev_npm")
+
+npm.npm_translate_lock(
+    name = "tools_npm",
+    data = [
+        "//tools/node_tools:package.json",
+        "//tools/node_tools:pnpm-workspace.yaml",
+    ],
+    pnpm_lock = "//tools/node_tools:pnpm-lock.yaml",
+    update_pnpm_lock = True,
+    yarn_lock = "//tools/node_tools:yarn.lock",
+)
+use_repo(npm, "tools_npm")
+
+npm.npm_translate_lock(
+    name = "plugins_npm",
+    data = [
+        "//plugins:package.json",
+        "//plugins:pnpm-workspace.yaml",
+    ],
+    pnpm_lock = "//plugins:pnpm-lock.yaml",
+    update_pnpm_lock = True,
+    yarn_lock = "//plugins:yarn.lock",
+)
+use_repo(npm, "plugins_npm")
diff --git a/polygerrit-ui/resultdb-reporter.mjs b/polygerrit-ui/resultdb-reporter.mjs
new file mode 100644
index 0000000..7464754
--- /dev/null
+++ b/polygerrit-ui/resultdb-reporter.mjs
@@ -0,0 +1,229 @@
+import fs from 'fs';
+import path from 'path';
+import { URL } from 'url';
+
+const uiDir = path.dirname(new URL(import.meta.url).pathname);
+
+function getExistingDir(dirPath) {
+  if (fs.existsSync(dirPath)) return dirPath;
+  const lowerChromium = dirPath.replace('screenshots/Chromium', 'screenshots/chromium');
+  if (fs.existsSync(lowerChromium)) return lowerChromium;
+  return null;
+}
+
+function attachVisualDiff(test, artifacts) {
+  const match = test.error.message.match(/See diff for details: (\S+)/);
+  if (!match || !match[1]) return '';
+  const diffPath = match[1];
+  if (!fs.existsSync(diffPath)) return '';
+  try {
+    artifacts['image_diff'] = {
+      contents: fs.readFileSync(diffPath).toString('base64'),
+      contentType: 'image/png',
+    };
+    return '';
+  } catch (e) {
+    console.error('Failed to read visual diff artifact', e);
+    return '';
+  }
+}
+
+function attachSideBySideScreenshots(test, testFile, artifacts) {
+  try {
+    const failedDir = getExistingDir(path.join(uiDir, 'screenshots/Chromium/failed'));
+    if (!failedDir) return '';
+
+    const files = fs.readdirSync(failedDir).filter(f => f.endsWith('.png') && !f.endsWith('-diff.png'));
+    if (files.length === 0) return '';
+
+    let bestFile = null;
+    let bestScore = -9999;
+    const testBase = path.basename(testFile, path.extname(testFile)).replace('_screenshot_test', '');
+    const testNameLower = `${testBase} ${test.suiteName ? test.suiteName : ''} ${test.name}`.toLowerCase();
+
+    for (const file of files) {
+      const fileTokens = file.replace(/\.png$/, '').split(/[-_]/).filter(Boolean);
+      let matchedCount = 0;
+      for (const token of fileTokens) {
+        if (testNameLower.includes(token.toLowerCase())) matchedCount++;
+      }
+      if (matchedCount > 0) {
+        const score = matchedCount * 100 - fileTokens.length;
+        if (score > bestScore) {
+          bestScore = score;
+          bestFile = file;
+        }
+      }
+    }
+
+    if (!bestFile) return '';
+
+    const actualPath = path.join(failedDir, bestFile);
+    const baselineDir = getExistingDir(path.join(uiDir, 'screenshots/Chromium/baseline'));
+    const baselinePath = baselineDir ? path.join(baselineDir, bestFile) : null;
+    const diffPath = path.join(failedDir, bestFile.replace(/\.png$/, '-diff.png'));
+
+    if (!artifacts['image_diff'] && fs.existsSync(diffPath)) {
+      artifacts['image_diff'] = {
+        contents: fs.readFileSync(diffPath).toString('base64'),
+        contentType: 'image/png',
+      };
+    }
+    if (fs.existsSync(actualPath)) {
+      artifacts['actual_image'] = {
+        contents: fs.readFileSync(actualPath).toString('base64'),
+        contentType: 'image/png',
+      };
+    }
+    if (baselinePath && fs.existsSync(baselinePath)) {
+      artifacts['expected_image'] = {
+        contents: fs.readFileSync(baselinePath).toString('base64'),
+        contentType: 'image/png',
+      };
+    }
+
+    return '';
+  } catch (e) {
+    console.error('Failed to find screenshot artifacts', e);
+    return '';
+  }
+}
+
+function escapeHtml(str) {
+  if (!str) return '';
+  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+}
+
+export function resultDbReporter() {
+  let sinkCtx = null;
+
+  // 1. Read LUCI_CONTEXT to get the Result Sink address and token
+  const luciCtxFile = process.env['LUCI_CONTEXT'];
+  if (luciCtxFile) {
+    try {
+      const luciCtx = JSON.parse(fs.readFileSync(luciCtxFile, 'utf8'));
+      sinkCtx = luciCtx.result_sink;
+    } catch (e) {
+      console.error('Failed to read LUCI_CONTEXT', e);
+    }
+  }
+
+  async function uploadToResultSink(testResult) {
+    if (!sinkCtx) return;
+
+    const url = `http://${sinkCtx.address}/prpc/luci.resultsink.v1.Sink/ReportTestResults`;
+    const headers = {
+      'Content-Type': 'application/json',
+      'Authorization': `ResultSink ${sinkCtx.auth_token}`,
+    };
+
+    const body = JSON.stringify({
+      testResults: [testResult],
+    });
+
+    try {
+      const res = await fetch(url, { method: 'POST', headers, body });
+      if (!res.ok) {
+        console.error('Failed to report to ResultDB', await res.text());
+      }
+    } catch (e) {
+      console.error('Error reporting to ResultDB', e);
+    }
+  }
+
+  let pendingUploads = Promise.resolve();
+
+  return {
+    onTestRunFinished({ testRun, sessions }) {
+      if (!sinkCtx) return;
+
+      pendingUploads = pendingUploads.then(async () => {
+        for (const session of sessions) {
+          const testFile = path.basename(session.testFile);
+
+          // 1. Report session-level hook / setup errors if any
+          if (session.errors && session.errors.length > 0) {
+            for (let i = 0; i < session.errors.length; i++) {
+              const err = session.errors[i];
+              const testId = `gerrit > polygerrit-ui > ${testFile} > setup error ${i + 1}`;
+              const summaryHtml = `<pre>${escapeHtml(err.message)}\n${escapeHtml(err.stack)}</pre>`;
+              await uploadToResultSink({
+                testId,
+                status: 'FAIL',
+                expected: false,
+                summaryHtml,
+                failureReason: err.message ? { primaryErrorMessage: err.message } : undefined,
+              });
+            }
+          }
+
+          if (!session.testResults) continue;
+
+          // 2. Flatten the nested Mocha suites/tests
+          const tests = [];
+          function collectTests(suite, parentName = '') {
+            const name = suite.name ? (parentName ? `${parentName} > ${suite.name}` : suite.name) : parentName;
+            if (suite.tests) {
+              for (const t of suite.tests) {
+                tests.push({ ...t, suiteName: name });
+              }
+            }
+            if (suite.suites) {
+              for (const s of suite.suites) {
+                collectTests(s, name);
+              }
+            }
+          }
+          collectTests(session.testResults);
+
+          for (const test of tests) {
+            const testName = test.suiteName ? `${test.suiteName} > ${test.name}` : test.name;
+            const testId = `gerrit > polygerrit-ui > ${testFile} > ${testName}`;
+
+            let status = 'PASS';
+            let expected = true;
+            if (test.skipped) {
+              status = 'SKIP';
+              expected = true;
+            } else if (!test.passed) {
+              status = 'FAIL';
+              expected = false;
+            }
+
+            let summaryHtml = '';
+            if (test.error) {
+              summaryHtml = `<pre>${escapeHtml(test.error.message)}\n${escapeHtml(test.error.stack)}</pre>`;
+            }
+            const artifacts = {};
+
+            // 3. If visual diff failed or dimension mismatched, extract images and upload them
+            if (!test.passed && test.error) {
+              const isVisualDiff = test.error.message.includes('Visual diff failed');
+              const isDimMismatch = test.error.message.includes('Screenshot is not the same width and height as the baseline');
+
+              if (isVisualDiff || isDimMismatch) {
+                if (isVisualDiff) {
+                  attachVisualDiff(test, artifacts);
+                }
+                attachSideBySideScreenshots(test, testFile, artifacts);
+              }
+            }
+
+            await uploadToResultSink({
+              testId,
+              status,
+              expected,
+              summaryHtml: summaryHtml || undefined,
+              artifacts: Object.keys(artifacts).length > 0 ? artifacts : undefined,
+              duration: test.duration ? `${(test.duration / 1000).toFixed(9)}s` : undefined,
+              failureReason: test.error?.message ? { primaryErrorMessage: test.error.message } : undefined,
+            });
+          }
+        }
+      });
+    },
+    async stop() {
+      await pendingUploads;
+    }
+  };
+}
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-dark.png
index 3054927..8cb2891 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations-dark.png
index 0ed2eb4..4d0d695 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations.png
index f2b9ccd..4234037 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment-dark.png
index 8ff88e4..48b2792 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment.png
index 74a06c8..c92f2b7 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error-dark.png
index 22ad904..090b2f0 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error.png
index 2c454b2..e8b5847 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references-dark.png
index 68831df..f5f5b48 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references.png
index 8ac50b1a..8f2cd95 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode.png
index 5cf48ac..1a9f4b7 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-dark.png
index c86ef7d..0054460 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling-dark.png
index 1212b06..9d9fd5b 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling.png
index dff82cb..06b2bfb 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history.png
index 6b6ac64..2882d27 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open-dark.png
index f548ba7..c8ea2f2 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open.png
index a6425b7..e532a03 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items-dark.png
index 7068925..c6f5cde 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items.png
index 9f5f2ee..dfb0372 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions-dark.png
index c6f1614..b67cb3d 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions.png
index fa1e93c..d22e746 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-dark.png
index 245a38c..0f738db 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private-dark.png
index 3a7d864..d210860 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private.png
index 9436246..3904879 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page.png
index 1e89cee..a52afda 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info-dark.png
index 9fa51fc..2ff1605 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info.png
index d1227fd..ad28ceb 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-account-info.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list-dark.png
index cab7aca..9d9a80e 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list.png
index 508e19b..7890866 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-admin-group-list.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-copy-link-flow-open-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-copy-link-flow-open-dark.png
new file mode 100644
index 0000000..2a75e69
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-copy-link-flow-open-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-copy-link-flow-open.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-copy-link-flow-open.png
new file mode 100644
index 0000000..155d702
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-copy-link-flow-open.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-dark.png
index 01c139a..a33a656 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-empty-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-empty-dark.png
new file mode 100644
index 0000000..8e02f4c
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-empty-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-empty.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-empty.png
new file mode 100644
index 0000000..a6578e8
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-empty.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-with-reviewers-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-with-reviewers-dark.png
new file mode 100644
index 0000000..a32630e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-with-reviewers-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-with-reviewers.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-with-reviewers.png
new file mode 100644
index 0000000..6f577b8
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list-reviewer-flow-with-reviewers.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list.png
index cf6fc7a..2cdb5aa 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-list.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png
index d85c67c..436031d 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png
index 7406562..9aca5a8 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png
index d85c67c..1cd5d8d 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png
index 7406562..569b3bb 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-chips.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open-dark.png
index 79a6c45..ac50c8e 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open.png
index bbdd523..dd975a0 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png
index d1a268f..5bf9965 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png
index 4d753b1..b7b7977 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px-dark.png
new file mode 100644
index 0000000..bc9fb48
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png
index bf2f836..d5b01a9 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png
index 2e46be7..bd67930 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png
index 92d1d7e..9257bef 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-ai-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-ai-dark.png
new file mode 100644
index 0000000..09b4b0f
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-ai-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-ai.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-ai.png
new file mode 100644
index 0000000..c109a72
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-ai.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-normal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-normal-dark.png
new file mode 100644
index 0000000..8eab5f2
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-normal-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-normal.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-normal.png
new file mode 100644
index 0000000..87c0c65
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-normal.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved-dark.png
index d6b63bf..359ec4b 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved.png
index e9cfdd2..b047d58 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-resolved.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-dark.png
index cdfebe6..81b6312 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-inline-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-inline-dark.png
new file mode 100644
index 0000000..60eb357
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-inline-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-inline.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-inline.png
new file mode 100644
index 0000000..3cde291
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved-inline.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved.png
index a3c2cda..27a87d5 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-unresolved.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-dark.png
new file mode 100644
index 0000000..2dce03b
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree-dark.png
new file mode 100644
index 0000000..025d1be
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree.png
new file mode 100644
index 0000000..7e1a2af
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai-disagree.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai.png
new file mode 100644
index 0000000..0c25dd7
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-ai.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft-dark.png
index f1db32b..131e4f3 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft.png
index c8c9517..a000c37 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-comment-thread-with-draft.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png
new file mode 100644
index 0000000..0f4857e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png
new file mode 100644
index 0000000..2464d3f
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-left.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png
new file mode 100644
index 0000000..238c3fb
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png
new file mode 100644
index 0000000..ff32fb3
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-right.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar.png
index 91a71b4..2e0fb8c 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-copy-links-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-copy-links-dark.png
new file mode 100644
index 0000000..300993a
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-copy-links-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-copy-links.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-copy-links.png
index e41749f..28b8256 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-copy-links.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-copy-links.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-dark.png
index 201edda..8a4c591 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-error-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-error-dark.png
new file mode 100644
index 0000000..0984e2ae
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-error-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-error.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-error.png
new file mode 100644
index 0000000..fb70aae
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-error.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog.png
index 5844c3c..713ba32 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed-dark.png
index 5b7b70f..c9b50fc 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed.png
index 96bea54..44ce9b7 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-collapsed.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png
index 484426d..a6be554 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png
index 49002e6..f79baa2 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-dark.png
new file mode 100644
index 0000000..f34e01e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified-dark.png
new file mode 100644
index 0000000..c5f7ae3
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified.png
new file mode 100644
index 0000000..54826a2
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert-unified.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert.png
new file mode 100644
index 0000000..444b459
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-host-edit-mode-revert.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal-dark.png
new file mode 100644
index 0000000..1c8b944
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal.png
new file mode 100644
index 0000000..2224d4b
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-cancel-modal.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal-dark.png
new file mode 100644
index 0000000..6159b46
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal.png
new file mode 100644
index 0000000..f4773b9
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-editor-view-normal.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list-dark.png
index 76b57e8..c8d002c 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list.png
index 4b1184a..36b36e9 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-file-list.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions-dark.png
new file mode 100644
index 0000000..7f557d0
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions.png
new file mode 100644
index 0000000..90727b4e
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-fix-suggestions.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-dark.png
index 28a2960..b2e532a 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty-dark.png
index 7a7b5b5..58498e3 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty.png
index 1c793ac..f62f699 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading-dark.png
index 054acc6..1efc38a 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading.png
index 310219c..560729a 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple-dark.png
index 07d1c36..f7458c8 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple.png
index 32200a5..3550255 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader-dark.png
index 24a7544..55c4a35 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader.png
index 948d1b1..94d75a4 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows.png
index 2f308d1..d2bb4df 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png
index a26b67f..9116995 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png
index ab28bdf..8fdccb3 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label-dark.png
index a8f0a98..550e0e6 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label.png
index 8847764..aade8f9 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-ai-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-ai-dark.png
new file mode 100644
index 0000000..71511b4
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-ai-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-ai.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-ai.png
new file mode 100644
index 0000000..066e0b0
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-ai.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-normal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-normal-dark.png
new file mode 100644
index 0000000..584aa0d
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-normal-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-normal.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-normal.png
new file mode 100644
index 0000000..8585f88
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-normal.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-scores-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-scores-dark.png
new file mode 100644
index 0000000..599409d
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-scores-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-scores.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-scores.png
new file mode 100644
index 0000000..cb223a3
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-collapsed-scores.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-ai-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-ai-dark.png
new file mode 100644
index 0000000..0297d2c
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-ai-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-ai.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-ai.png
new file mode 100644
index 0000000..83e88b6
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-ai.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-scores-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-scores-dark.png
new file mode 100644
index 0000000..14f8001
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-scores-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-scores.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-scores.png
new file mode 100644
index 0000000..4f27dd6
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-message-expanded-scores.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit-dark.png
index 69e0c32..3326727 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit.png
index 8dfa9ed..58681ee 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor-dark.png
index 58fffae..0a14b00 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor.png
index 94b2f6b5..a3b6300 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-rule-editor.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png
index 34fca7b..bdd2cd9 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png
index e308091..85eb563 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-user-suggestion-fix.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card-dark.png
index 5decdca..45aff22 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card.png
index 027888a..4a9a251 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-dark.png
index 0762253..aa9c104 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-dark.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-expanded-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-expanded-dark.png
new file mode 100644
index 0000000..6a145ea
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-expanded-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-expanded.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-expanded.png
new file mode 100644
index 0000000..1497230
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-expanded.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-long-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-long-dark.png
new file mode 100644
index 0000000..cdce50d
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-long-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-long.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-long.png
new file mode 100644
index 0000000..c58b770
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-long.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal.png
index bca5f67..5956d3b 100644
--- a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal.png
+++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/user-message-long-path-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/user-message-long-path-dark.png
new file mode 100644
index 0000000..d886040
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/user-message-long-path-dark.png
Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/user-message-long-path.png b/polygerrit-ui/screenshots/Chromium/baseline/user-message-long-path.png
new file mode 100644
index 0000000..aabd8ea
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chromium/baseline/user-message-long-path.png
Binary files differ
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index 893d0fb..c8f9632 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -2,12 +2,16 @@
 import fs from 'fs';
 import { esbuildPlugin } from '@web/dev-server-esbuild';
 import { defaultReporter, summaryReporter } from '@web/test-runner';
+import { resultDbReporter } from './resultdb-reporter.mjs';
 import { visualRegressionPlugin } from '@web/test-runner-visual-regression/plugin';
 import pixelmatch from 'pixelmatch';
 import { PNG } from 'pngjs';
 import { playwrightLauncher } from '@web/test-runner-playwright';
 
 const runUnderBazel = !!process.env['RUNFILES_DIR'];
+// We set this to a non-zero value because of sub-pixel rendering noise that
+// can create false positives.
+const diffThreshold = .02;
 
 function testRunnerHtmlFactory(prefix) {
   return (testFramework) => `
@@ -34,11 +38,11 @@
     ];
   }
 
-  const runfilesRoot = path.dirname(process.cwd());
+  const cwd = process.cwd();
   return [
-    path.join(runfilesRoot, 'plugins_npm', 'node_modules'),
-    path.join(runfilesRoot, 'ui_npm', 'node_modules'),
-    path.join(runfilesRoot, 'ui_dev_npm', 'node_modules'),
+    path.join(cwd, 'plugins/node_modules'),
+    path.join(cwd, 'polygerrit-ui/node_modules'),
+    path.join(cwd, 'polygerrit-ui/app/node_modules'),
   ];
 }
 
@@ -57,7 +61,16 @@
 const pathPrefix = runUnderBazel ? 'polygerrit-ui/' : '';
 const testFiles = getArgValue('--test-files');
 const runScreenshots = process.argv.includes('--run-screenshots');
-const rootDir = getArgValue('--root-dir') ?? `${path.resolve(process.cwd())}/`;
+// Under Bazel, module imports may resolve through runfile symlinks to
+// rules_js package stores. Both the runfiles tree and those stores are under
+// bazel-out/<config>/bin, so use that as the WTR root.
+function getBazelBinDir() {
+  const cwd = path.resolve(process.cwd()).replace(/\\/g, '/');
+  const match = cwd.match(/^(.*\/bazel-out\/[^/]+\/bin)(?:\/|$)/);
+  return `${match?.[1] ?? cwd}/`;
+}
+
+const rootDir = getArgValue('--root-dir') ?? getBazelBinDir();
 const tsConfig = getArgValue('--ts-config') ?? `${pathPrefix}app/tsconfig.json`;
 
 // When running screenshots, we serve from the root directory, so we need to
@@ -65,6 +78,12 @@
 // When running under Bazel, we also need strictly fully qualified paths.
 const stylePathPrefix = 'polygerrit-ui/';
 
+const chromeExecutablePath = [
+  process.env['CHROME_BIN'],
+  process.env['CI_CHROME_BIN'],
+  '/usr/bin/google-chrome',
+].find(p => p && fs.existsSync(p));
+
 /** @type {import('@web/test-runner').TestRunnerConfig} */
 const config = {
   // Default is CPU cores / 2. Use default
@@ -78,10 +97,16 @@
     playwrightLauncher({
       product: 'chromium',
       launchOptions: {
+        ...(chromeExecutablePath ? { executablePath: chromeExecutablePath } : {}),
         args: [
+          '--no-sandbox',
+          '--disable-dev-shm-usage',
           '--disable-background-timer-throttling',
           '--disable-backgrounding-occluded-windows',
           '--disable-renderer-backgrounding',
+          '--font-render-hinting=none',
+          '--disable-font-subpixel-rendering',
+          '--disable-lcd-text',
         ],
       },
     }),
@@ -104,7 +129,14 @@
 
   nodeResolve: {
     modulePaths: getModulesDir(),
-    dedupe: ['lit', 'lit-html', 'lit-element'],
+    dedupe: [
+      'lit',
+      'lit-html',
+      'lit-element',
+      '@open-wc/testing',
+      '@open-wc/testing-helpers',
+      'sinon',
+    ],
   },
 
   testFramework: {
@@ -127,11 +159,12 @@
       tsconfig: tsConfig,
     }),
     visualRegressionPlugin({
+      baseDir: pathPrefix ? `${pathPrefix}screenshots` : 'screenshots',
       // TODO(milutin): Tweak these values - diffOptions threshold is for color change
       // and failureThreshold is for pixel change. We need to find a balance to allow
       // CI to pass, but also catch regressions.
       diffOptions: { threshold: 0.6 },
-      failureThreshold: 2,
+      failureThreshold: diffThreshold,
       failureThresholdType: 'percent',
       update: process.argv.includes('--update-screenshots'),
       // The visual regression plugin by default blindly overwrites all goldens
@@ -157,7 +190,7 @@
           try {
             basePng = PNG.sync.read(oldContent);
             newPng = PNG.sync.read(content);
-          } catch(e) {
+          } catch (e) {
             console.warn('Failed to parse PNGs for diff checking', e);
           }
 
@@ -172,7 +205,7 @@
             );
 
             const diffPercentage = (numDiffPixels / (basePng.width * basePng.height)) * 100;
-            if (diffPercentage <= 2) {
+            if (diffPercentage <= diffThreshold) {
               return;
             }
           }
@@ -188,7 +221,11 @@
   // /lib/fonts/ for screenshots tests, see middleware.
   rootDir: runUnderBazel ? rootDir : '..',
 
-  reporters: [defaultReporter(), summaryReporter()],
+  reporters: [
+    defaultReporter(),
+    summaryReporter(),
+    resultDbReporter(),
+  ],
 
   middleware: [
     // Fonts are in /lib/fonts/, but css tries to load from
diff --git a/polymer-bridges b/polymer-bridges
deleted file mode 160000
index 855f478..0000000
--- a/polymer-bridges
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 855f4781b702de120953a64da5c277ea4908deaa
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD
index 83c98e2..6b4749f 100644
--- a/prologtests/examples/BUILD
+++ b/prologtests/examples/BUILD
@@ -1,3 +1,5 @@
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+
 package(default_visibility = ["//visibility:public"])
 
 sh_test(
diff --git a/proto/BUILD b/proto/BUILD
index 4b3eae4..d523718 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -1,5 +1,5 @@
 load("@protobuf//bazel:java_proto_library.bzl", "java_proto_library")
-load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@protobuf//bazel:proto_library.bzl", "proto_library")
 
 proto_library(
     name = "cache_proto",
diff --git a/proto/cache.proto b/proto/cache.proto
index 19046a5..fbdce93 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -761,6 +761,7 @@
   string new_mode = 14; // ENUM as string
   bytes old_sha = 15;
   bytes new_sha = 16;
+  bool diffs_too_expensive_to_compute = 17;
 }
 
 // Serialized form of com.google.gerrit.server.ReviewerSet
diff --git a/proto/entities.proto b/proto/entities.proto
index e0367b6..9790cb0 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -134,15 +134,15 @@
   COMPUTED_BASE = 2;
   ONE_SIDED_MERGE_STRATEGY = 3;
   NO_MERGE_PERFORMED = 4;
- }
+}
 
 // Serialized form of com.google.gerrit.extensions.common.MergeInput.
 // Next ID: 5
 message MergeInput {
- optional string source = 1;
- optional string source_branch = 2;
- optional string strategy = 3;
- optional bool allow_conflicts = 4;
+  optional string source = 1;
+  optional string source_branch = 2;
+  optional string strategy = 3;
+  optional bool allow_conflicts = 4;
 }
 
 // Serialized form of com.google.gerrit.extensions.api.changes.ApplyPatchInput.
@@ -309,7 +309,8 @@
 // Next ID: 2
 message ObjectId {
   // Hex string representation of the ID.
-  optional string name = 1 [default="0000000000000000000000000000000000000000"];
+  optional string name = 1
+      [default = "0000000000000000000000000000000000000000"];
 }
 
 // Serialized form of a continuation token used for pagination.
@@ -321,7 +322,7 @@
 // Proto representation of the User preferences classes
 // Next ID: 4
 message UserPreferences {
-  // Next ID: 26
+  // Next ID: 28
   message GeneralPreferencesInfo {
     // Number of changes to show in a screen.
     optional int32 changes_per_page = 1 [default = 25];
@@ -404,10 +405,11 @@
     optional bool allow_autocompleting_comments = 25 [default = true];
     optional string diff_page_sidebar = 23 [default = "NONE"];
     optional string ai_chat_selected_model = 26;
+    optional string label_filter = 27;
   }
   optional GeneralPreferencesInfo general_preferences_info = 1;
 
-  // Next ID: 25
+  // Next ID: 26
   message DiffPreferencesInfo {
     optional int32 context = 1 [default = 10];
     optional int32 tab_size = 2 [default = 8];
@@ -441,6 +443,13 @@
     optional bool skip_deleted = 22;
     optional bool skip_unchanged = 23;
     optional bool skip_uncommented = 24;
+
+    enum ResponsiveMode {
+      NONE = 0;
+      SHRINK_ONLY = 1;
+      FULL_RESPONSIVE = 2;
+    }
+    optional ResponsiveMode responsive_mode = 25;
   }
   optional DiffPreferencesInfo diff_preferences_info = 2;
 
@@ -540,8 +549,9 @@
   // Next Id: 4
   message FixSuggestion {
     optional string fix_id = 1;
-    optional string  description = 2;
+    optional string description = 2;
     repeated FixReplacement replacements = 3;
   }
   repeated FixSuggestion fix_suggestions = 13;
+  optional bool is_ai = 14;
 }
diff --git a/proto/testing/BUILD b/proto/testing/BUILD
index ee01e38..4428d27 100644
--- a/proto/testing/BUILD
+++ b/proto/testing/BUILD
@@ -1,5 +1,5 @@
 load("@protobuf//bazel:java_proto_library.bzl", "java_proto_library")
-load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@protobuf//bazel:proto_library.bzl", "proto_library")
 
 proto_library(
     name = "test_proto",
diff --git a/resources/BUILD b/resources/BUILD
index d4d0df3..7235cf2 100644
--- a/resources/BUILD
+++ b/resources/BUILD
@@ -1,5 +1,5 @@
 load("@rules_java//java:defs.bzl", "java_import")
-load("//tools/bzl:genrule2.bzl", "genrule2")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
 
 java_import(
     name = "log4j-config",
diff --git a/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html b/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
index 08c890d..80112a3 100644
--- a/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
+++ b/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
@@ -1,5 +1,6 @@
 <html>
   <head>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>Gerrit Code Review - Sign In</title>
     <style type="text/css">
       #error_message {
@@ -12,6 +13,22 @@
       #cancel_link {
         margin-left: 45px;
       }
+      /* Stack the form in a single column on small screens so it fits
+         without horizontal scrolling; inputs fill the row width. */
+      @media (max-width: 600px) {
+        #login_form table,
+        #login_form table tbody,
+        #login_form table tr,
+        #login_form table td {
+          display: block;
+        }
+        #login_form input[type="text"],
+        #login_form input[type="password"] {
+          width: 100%;
+          box-sizing: border-box;
+          font-size: 16px; /* avoid iOS auto-zoom on focus */
+        }
+      }
     </style>
     <style id="gerrit_sitecss" type="text/css"></style>
   </head>
@@ -24,14 +41,14 @@
       <form method="POST" action="#" id="login_form" autocomplete="off" onsubmit="return shouldSubmit()">
         <table style="border: 0;">
         <tr>
-          <th>Username</th>
+          <td><label for="f_user">Username</label></td>
           <td><input name="username" id="f_user"
                      type="text"
                      size="25"
                      tabindex="1" /></td>
         </tr>
         <tr>
-          <th>Password</th>
+          <td><label for="f_pass">Password</label></td>
           <td><input name="password" id="f_pass"
                      type="password"
                      size="25"
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 8335b76..06f07b5 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -26,7 +26,6 @@
   {@param? faviconPath: ?}
   {@param? manifestPath: ?}
   {@param? versionInfo: ?}
-  {@param? polyfillCE: ?}
   {@param? useGoogleFonts: ?}
   {@param? changeNum: ?}
   {@param? changeRequestsPath: ?}
@@ -66,7 +65,6 @@
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
     {if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
     {if $assetsPath}window.ASSETS_PATH = '{$assetsPath}';{/if}
-    {if $polyfillCE}if (window.customElements) window.customElements.forcePolyfill = true;{/if}
     {if $gerritInitialData}
       // INITIAL_DATA is a string that represents a JSON map. It's inlined here so that we can
       // spare calls to the API when starting up the app.
@@ -89,7 +87,9 @@
   {else}
     <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
   {/if}
-  <link rel="manifest" href="{$manifestPath}">{\n}
+  {if $manifestPath}
+    <link rel="manifest" href="{$manifestPath}">{\n}
+  {/if}
   {if $changeRequestsPath}
     {if $defaultChangeDetailHex}
       <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultChangeDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
@@ -146,14 +146,8 @@
   {/if}
   <link rel="preload" as="style" href="{$staticResourcePath}/styles/main.css">{\n}
 
-  <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-loader.js"></script>{\n}
-
-  // Content between webcomponents-loader and the load of the main app element
-  // run before polymer-resin is installed so may have security consequences.
-  // Contact your local security engineer if you have any questions, and
-  // CC them on any changes that load content before gr-app.js.
-  //
-  // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
+  // Optional host-specific asset bundle loaded before gr-app.js.
+  // Be careful when changing this, because it affects early page startup.
   {if $assetsPath && $assetsBundle}
     <link rel="import" href="{$assetsPath}/{$assetsBundle}">{\n}
   {/if}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index ca1fc8c..121f301 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -51,7 +51,7 @@
 
 usage() {
     me=`basename "$0"`
-    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug-port|--debug-address ...] [--suspend]] [--count=n]"
+    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads|histogram} [-d site] [--debug [--debug-port|--debug-address ...] [--suspend]] [--count=n]"
     exit 1
 }
 
@@ -76,6 +76,13 @@
   return 0;
 }
 
+histogram_dump() {
+  test -f $1 || return 1
+  PID=`cat $1`
+  $JCMD $PID GC.class_histogram || return 1
+  return 0;
+}
+
 get_config() {
   if test -f "$GERRIT_CONFIG" ; then
     if test "x$1" = x--int ; then
@@ -258,6 +265,7 @@
 GERRIT_PID="$GERRIT_LOGS/gerrit.pid"
 GERRIT_RUN="$GERRIT_LOGS/gerrit.run"
 GERRIT_THREADS="$GERRIT_LOGS/threads"
+GERRIT_HISTOGRAM="$GERRIT_LOGS/histogram"
 GERRIT_TMP="$GERRIT_SITE/tmp"
 export GERRIT_TMP
 
@@ -333,6 +341,10 @@
   JSTACK="$JAVA_HOME/bin/jstack"
 fi
 
+if test -z "$JCMD"; then
+  JCMD="$JAVA_HOME/bin/jcmd"
+fi
+
 #####################################################
 # Add Gerrit properties to Java VM options.
 #####################################################
@@ -680,6 +692,19 @@
     exit 3
   ;;
 
+  histogram)
+    if running "$GERRIT_PID" ; then
+      mkdir -p -- "$GERRIT_HISTOGRAM"
+      HISTOGRAM="$GERRIT_HISTOGRAM/histogram-`ztime`"
+      histogram_dump "$GERRIT_PID" > "$HISTOGRAM" || exit 1
+      echo "$HISTOGRAM"
+      exit 0
+    else
+      echo "Gerrit not running?"
+    fi
+    exit 3
+  ;;
+
   *)
     usage
   ;;
diff --git a/resources/com/google/gerrit/server/BUILD b/resources/com/google/gerrit/server/BUILD
index e92c4e1..a64ed82 100644
--- a/resources/com/google/gerrit/server/BUILD
+++ b/resources/com/google/gerrit/server/BUILD
@@ -1,3 +1,5 @@
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+
 filegroup(
     name = "server",
     srcs = glob(
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index adea3c8..361b908 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -131,6 +131,27 @@
   fi
 }
 
+function test_preserve_changeid_with_non_colon_tags {
+  cat << EOF > input
+bla bla
+
+TAG=agy
+CONV=123
+Change-Id: I123
+EOF
+
+  ${hook} input || fail "failed hook execution"
+
+  found=$(grep -c '^Change-Id' input) || :
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Ids, want 1"
+  fi
+  found=$(grep -c '^Change-Id: I123' input) || :
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Id: I123, want 1"
+  fi
+}
+
 # Change-Id should not be inserted if gerrit.createChangeId=false
 function test_suppress_changeid {
   cat << EOF > input
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
index fcadb55..3856578 100644
--- a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
@@ -26,8 +26,9 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param attentionSetUser: ?}
+  {@param? attentionSetUserEmail: ?}
   {@param reason: ?}
-  {if $fromName == $attentionSetUser}
+  {if $fromName == $attentionSetUser || $fromName == $attentionSetUserEmail}
   {$fromName} added themselves to the attention set of this change.
   {else}
   {$fromName} requires the attention of {$attentionSetUser} to this change.
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
index f550bb1..af2b33a 100644
--- a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
@@ -23,9 +23,10 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param attentionSetUser: ?}
+  {@param? attentionSetUserEmail: ?}
   {@param reason: ?}
   <p>
-    {if $fromName == $attentionSetUser}
+    {if $fromName == $attentionSetUser || $fromName == $attentionSetUserEmail}
       {$fromName} added themselves to the attention set of this change.
     {else}
       {$fromName} requires the attention of {$attentionSetUser} to this change.
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index fac3c3d..9c6882e 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -19,8 +19,10 @@
 import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
 
 {template NewChangeHtml}
+  {@param? change: ?}
   {@param diffLines: ?}
   {@param email: ?}
+  {@param? fromEmail: ?}
   {@param fromName: ?}
   {@param ownerName: ?}
   {@param patchSet: ?}
@@ -36,7 +38,7 @@
           {$reviewerName}
         {/for}{sp}
         to <strong>review</strong> this change
-        {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
+        {if ($fromEmail && $change ? $fromEmail != $change.ownerEmail : $fromName != $ownerName)}{sp}authored by {$ownerName}{/if}.
         {\n}
       {/if}
       {if $email.removedReviewerNames}
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 20f9999..a485abd 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -31,7 +31,7 @@
 
   <p>
 
-    {$email.emailRegistrationLink}
+    <a href="{$email.emailRegistrationLink}">{$email.emailRegistrationLink}</a>
   </p>
   <p>
     If you have received this mail in error, you do not need to take any
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
index b33f908..d82c18b 100644
--- a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
@@ -26,8 +26,9 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param attentionSetUser: ?}
+  {@param? attentionSetUserEmail: ?}
   {@param reason: ?}
-  {if $fromName == $attentionSetUser}
+  {if $fromName == $attentionSetUser || $fromName == $attentionSetUserEmail}
   {$fromName} removed themselves from the attention set of this change.
   {else}
   {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
index b27fef8..ba6b213 100644
--- a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
@@ -23,9 +23,10 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param attentionSetUser: ?}
+  {@param? attentionSetUserEmail: ?}
   {@param reason: ?}
   <p>
-    {if $fromName == $attentionSetUser}
+    {if $fromName == $attentionSetUser || $fromName == $attentionSetUserEmail}
       {$fromName} removed themselves from the attention set of this change.
     {else}
       {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 66f21b9..f557cfd 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -244,6 +244,7 @@
 svg = application/xml
 svh = text/x-systemverilog
 swift = text/x-swift
+tada = text/x-python
 tcl = text/x-tcl
 tex = text/x-latex
 text = text/plain
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index bea4904..4e7a49b 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -77,7 +77,7 @@
   pattern=".*"
 fi
 
-if git interpret-trailers --no-divider --parse < "$1" | grep -q "^$token: $pattern$" ; then
+if git -c 'trailer.separators=:=' interpret-trailers --no-divider --parse < "$1" | grep -q "^$token: $pattern$" ; then
   exit 0
 fi
 
diff --git a/tools/BUILD b/tools/BUILD
index 9529ec7..2b8601d 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -1,9 +1,10 @@
 load(
     "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
+    "DEFAULT_TOOLCHAIN_CONFIGURATION",
     "default_java_toolchain",
 )
+load("@protobuf//bazel/toolchains:proto_lang_toolchain.bzl", "proto_lang_toolchain")
 load("@rules_java//java:defs.bzl", "java_package_configuration")
-load("@rules_proto//proto:defs.bzl", "proto_lang_toolchain")
 
 exports_files([
     "nongoogle.toml",
@@ -22,15 +23,29 @@
     name = "error_prone_warnings_toolchain_java" + VERSION,
     configuration = dict(),
     java_runtime = "@rules_java//toolchains:remotejdk_" + VERSION,
+    # TODO(davido): Remove once protobuf no longer relies on sun.misc.Unsafe
+    # methods that require this JVM flag on JDK 24+.
+    # See: protocolbuffers/protobuf#20760.
+    javabuilder_jvm_opts = ["--sun-misc-unsafe-memory-access=allow"] if int(VERSION) >= 24 else [],
+    javacopts = [
+        "-Xlint:-removal",
+    ],
     package_configuration = [
         ":error_prone",
+        ":first_party_removal_warnings",
     ],
     source_version = VERSION,
     target_version = VERSION,
+    turbine_jvm_opts = DEFAULT_TOOLCHAIN_CONFIGURATION["jvm_opts"] + ([
+        # TODO(davido): Remove once protobuf no longer relies on sun.misc.Unsafe
+        # methods that require this JVM flag on JDK 24+.
+        # See: protocolbuffers/protobuf#20760.
+        "--sun-misc-unsafe-memory-access=allow",
+    ] if int(VERSION) >= 24 else []),
     visibility = ["//visibility:public"],
 ) for VERSION in [
-    "17",
     "21",
+    "25",
 ]]
 
 # Error Prone errors enabled by default; see ../.bazelrc for how this is
@@ -92,6 +107,7 @@
         "-Xep:CanonicalDuration:ERROR",
         "-Xep:CatchAndPrintStackTrace:ERROR",
         "-Xep:CatchFail:ERROR",
+        "-Xep:CatchingUnchecked:ERROR",
         "-Xep:ChainedAssertionLosesContext:ERROR",
         "-Xep:ChainingConstructorIgnoresParameter:ERROR",
         "-Xep:CharacterGetNumericValue:ERROR",
@@ -306,6 +322,13 @@
         "-Xep:NonOverridingEquals:ERROR",
         "-Xep:NonRuntimeAnnotation:ERROR",
         "-Xep:NullOptional:ERROR",
+        # Demoted to a warning: false positive on Guava methods like
+        # Iterables.getFirst(iterable, null) when javac cannot read the
+        # @Nullable bound of <T extends @Nullable Object> from class
+        # files. Bazel's remotejdk_21 (Zulu 21.0.9) lacks the
+        # JDK-8341779 backport; JDK 25 is unaffected. See the commit
+        # message introducing this line for the full reference trail.
+        "-Xep:NullArgumentForNonNullParameter:WARN",
         "-Xep:NullTernary:ERROR",
         "-Xep:NullableConstructor:ERROR",
         "-Xep:NullablePrimitive:ERROR",
@@ -438,7 +461,15 @@
         "-Xep:ZoneIdOfZ:ERROR",
         "-Xlint:unchecked",
     ],
-    packages = ["error_prone_packages"],
+    packages = [":error_prone_packages"],
+)
+
+java_package_configuration(
+    name = "first_party_removal_warnings",
+    javacopts = [
+        "-Xlint:removal",
+    ],
+    packages = [":error_prone_packages"],
 )
 
 package_group(
diff --git a/tools/bazlets.MODULE.bazel b/tools/bazlets.MODULE.bazel
index d71945f..02a63cd 100644
--- a/tools/bazlets.MODULE.bazel
+++ b/tools/bazlets.MODULE.bazel
@@ -1,12 +1,12 @@
 # Plugin packaging support kept outside the root module declaration:
 # - bazlets dependency pin
 # - generated Gerrit API version repo for artifact versioning and stamping
-GERRIT_VERSION = "3.14.5-SNAPSHOT"
+GERRIT_VERSION = "3.15.0-SNAPSHOT"
 
 bazel_dep(name = "com_googlesource_gerrit_bazlets")
 git_override(
     module_name = "com_googlesource_gerrit_bazlets",
-    commit = "31e8579d49bc4b7f2cdc3bed3367449c606586c4",
+    commit = "d26439932150d87b3a8f56309a14d9ead2526c33",
     remote = "https://gerrit.googlesource.com/bazlets",
 )
 
@@ -18,5 +18,4 @@
 gerrit_api_version(
     name = "gerrit_api_version",
     version = GERRIT_VERSION,
-    visibility = ["//visibility:public"],
 )
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index 62f0adb..ca4a858 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -1,3 +1,6 @@
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+
 exports_files([
     "diff_allowlist.sh",
     "license-map.py",
@@ -6,6 +9,28 @@
     "war_checks.bzl",
 ])
 
+# Verifies every artifact pinned in external_deps.lock.json is present on the
+# gerrit-maven mirror. Run before the build so a non-mirrored dependency fails
+# fast, locally and in CI, without hitting Maven Central:
+#   bazel run //tools/bzl:gerrit-maven-mirror-check
+py_binary(
+    name = "gerrit-maven-mirror-check",
+    srcs = ["gerrit_maven_mirror_check.py"],
+    main = "gerrit_maven_mirror_check.py",
+    visibility = ["//visibility:public"],
+)
+
+py_test(
+    name = "gerrit_maven_mirror_check_test",
+    size = "small",
+    srcs = [
+        "gerrit_maven_mirror_check.py",
+        "gerrit_maven_mirror_check_test.py",
+    ],
+    imports = ["."],
+    main = "gerrit_maven_mirror_check_test.py",
+)
+
 sh_test(
     name = "always_pass_test",
     srcs = ["always_pass_test.sh"],
diff --git a/tools/bzl/genrule2.bzl b/tools/bzl/genrule2.bzl
index d0b0969..4dd7ac1 100644
--- a/tools/bzl/genrule2.bzl
+++ b/tools/bzl/genrule2.bzl
@@ -12,18 +12,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+"""Gerrit-specific genrule helpers."""
+
 # Syntactic sugar for native genrule() rule:
 #   expose ROOT shell variable
 #   expose TMP shell variable
 
-def genrule2(cmd, **kwargs):
-    cmd = " && ".join([
-        "ROOT=$$PWD",
-        "TMP=$$(mktemp -d || mktemp -d -t bazel-tmp)",
-        "(" + cmd + ")",
-        "rm -rf $$TMP",
-    ])
-    native.genrule(
-        cmd = cmd,
-        **kwargs
-    )
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", _genrule2 = "genrule2")
+
+def genrule2(*args, **kwargs):
+    _genrule2(*args, **kwargs)
diff --git a/tools/bzl/gerrit_maven_mirror_check.py b/tools/bzl/gerrit_maven_mirror_check.py
new file mode 100755
index 0000000..92426ec
--- /dev/null
+++ b/tools/bzl/gerrit_maven_mirror_check.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+# Copyright (C) 2026 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.
+
+"""Check whether Gerrit's RJE Maven lock is mirrored in gerrit-maven.
+
+The script reads the rules_jvm_external v3 lock file used by Gerrit
+(`external_deps.lock.json` by default), derives the Maven repository path for
+each locked artifact/classifier, and checks whether that file exists under
+Gerrit's Maven mirror.
+"""
+
+from __future__ import annotations
+
+import argparse
+import concurrent.futures
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+
+
+DEFAULT_MIRROR = "https://gerrit-maven.storage.googleapis.com"
+
+LOCK_FILE_NAME = "external_deps.lock.json"
+
+
+def default_lock_file() -> str:
+    """Locate the lock file relative to the workspace root under `bazel run`.
+
+    `bazel run` sets BUILD_WORKSPACE_DIRECTORY to the workspace root, but runs
+    the binary from its runfiles tree, so the plain relative name would not
+    resolve. Fall back to the relative name for direct CLI use from the repo
+    root (env unset).
+    """
+    workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY")
+    return os.path.join(workspace, LOCK_FILE_NAME) if workspace else LOCK_FILE_NAME
+
+
+@dataclass(frozen=True)
+class ArtifactCheck:
+    coordinate: str
+    classifier: str
+    url: str
+    status: int | str
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Check Gerrit's Maven mirror coverage for an RJE lock file."
+    )
+    parser.add_argument(
+        "--lock-file",
+        default=default_lock_file(),
+        help=f"RJE v3 lock file to scan. Default: {LOCK_FILE_NAME}",
+    )
+    parser.add_argument(
+        "--mirror",
+        default=DEFAULT_MIRROR,
+        help=f"Maven mirror base URL. Default: {DEFAULT_MIRROR}",
+    )
+    parser.add_argument(
+        "--workers",
+        type=int,
+        default=32,
+        help="Number of parallel HTTP checks. Default: 32",
+    )
+    parser.add_argument(
+        "--timeout",
+        type=float,
+        default=10.0,
+        help="Per-request timeout in seconds. Default: 10",
+    )
+    parser.add_argument(
+        "--include-ok",
+        action="store_true",
+        help="Print mirrored artifacts too, not only missing artifacts.",
+    )
+    return parser.parse_args()
+
+
+def maven_path(coordinate: str, classifier: str, version: str) -> str:
+    parts = coordinate.split(":")
+    if len(parts) == 2:
+        group, artifact = parts
+        extension = "jar"
+    elif len(parts) == 3:
+        group, artifact, extension = parts
+    else:
+        raise ValueError(f"Unsupported RJE artifact key: {coordinate}")
+
+    suffix = "" if classifier == "jar" else f"-{classifier}"
+    filename = f"{artifact}-{version}{suffix}.{extension}"
+    return "/".join(group.split(".") + [artifact, version, filename])
+
+
+def locked_artifact_urls(lock_file: Path, mirror: str) -> list[tuple[str, str, str]]:
+    data = json.loads(lock_file.read_text())
+    mirror = mirror.rstrip("/")
+    urls = []
+
+    for coordinate, artifact in sorted(data["artifacts"].items()):
+        version = artifact["version"]
+        for classifier in sorted(artifact["shasums"]):
+            path = maven_path(coordinate, classifier, version)
+            urls.append((coordinate, classifier, f"{mirror}/{path}"))
+
+    return urls
+
+
+def head(url: str, timeout: float) -> int | str:
+    request = urllib.request.Request(url, method="HEAD")
+    try:
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return response.status
+    except urllib.error.HTTPError as err:
+        return err.code
+    except urllib.error.URLError as err:
+        return str(err.reason)
+
+
+def classify(status: int | str) -> str:
+    """Bucket a probe result into ok / missing / error.
+
+    Only a definitive HTTP 404 counts as a coverage miss. A transport
+    failure (a str reason from URLError) or any other HTTP status (403,
+    429, 5xx, ...) is an infrastructure/tool error: the probe was
+    inconclusive, so the run must not be reported as a coverage miss.
+    """
+    if status == 200:
+        return "ok"
+    if status == 404:
+        return "missing"
+    return "error"
+
+
+def check_one(item: tuple[str, str, str], timeout: float) -> ArtifactCheck:
+    coordinate, classifier, url = item
+    return ArtifactCheck(coordinate, classifier, url, head(url, timeout))
+
+
+def main() -> int:
+    args = parse_args()
+    lock_file = Path(args.lock_file)
+    if not lock_file.is_file():
+        print(f"error: lock file not found: {lock_file}", file=sys.stderr)
+        return 2
+
+    try:
+        items = locked_artifact_urls(lock_file, args.mirror)
+    except json.JSONDecodeError as err:
+        print(f"error: invalid lock file JSON: {err}", file=sys.stderr)
+        return 2
+    except (KeyError, ValueError) as err:
+        print(f"error: unsupported lock file shape: {err}", file=sys.stderr)
+        return 2
+    except OSError as err:
+        print(f"error: cannot read lock file: {err}", file=sys.stderr)
+        return 2
+
+    with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
+        futures = [pool.submit(check_one, item, args.timeout) for item in items]
+        results = [future.result() for future in concurrent.futures.as_completed(futures)]
+
+    results.sort(key=lambda r: (str(r.status), r.coordinate, r.classifier))
+    missing = [r for r in results if classify(r.status) == "missing"]
+    errors = [r for r in results if classify(r.status) == "error"]
+    mirrored = len(results) - len(missing) - len(errors)
+
+    printed = results if args.include_ok else [r for r in results if classify(r.status) != "ok"]
+    for result in printed:
+        print(f"{result.status}\t{result.coordinate}:{result.classifier}\t{result.url}")
+
+    print(
+        f"checked={len(results)} mirrored={mirrored} "
+        f"missing={len(missing)} errors={len(errors)}",
+        file=sys.stderr,
+    )
+    # Errors take precedence over misses: a run with any inconclusive probe
+    # cannot reliably report coverage, so signal infra (2) rather than a miss (1).
+    if errors:
+        return 2
+    if missing:
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/bzl/gerrit_maven_mirror_check_test.py b/tools/bzl/gerrit_maven_mirror_check_test.py
new file mode 100644
index 0000000..c1c4ace
--- /dev/null
+++ b/tools/bzl/gerrit_maven_mirror_check_test.py
@@ -0,0 +1,62 @@
+# Copyright (C) 2026 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.
+
+"""Unit tests for gerrit_maven_mirror_check."""
+
+import unittest
+
+import gerrit_maven_mirror_check as checker
+
+
+class MavenPathTest(unittest.TestCase):
+    def test_jar_classifier_has_no_suffix(self):
+        self.assertEqual(
+            checker.maven_path("com.google.guava:guava", "jar", "33.5.0-jre"),
+            "com/google/guava/guava/33.5.0-jre/guava-33.5.0-jre.jar",
+        )
+
+    def test_sources_classifier_appends_suffix(self):
+        self.assertEqual(
+            checker.maven_path("com.google.guava:guava", "sources", "33.5.0-jre"),
+            "com/google/guava/guava/33.5.0-jre/guava-33.5.0-jre-sources.jar",
+        )
+
+    def test_three_part_coordinate_sets_extension(self):
+        self.assertEqual(
+            checker.maven_path("com.example:thing:pom", "jar", "1.0"),
+            "com/example/thing/1.0/thing-1.0.pom",
+        )
+
+    def test_unsupported_coordinate_raises(self):
+        with self.assertRaises(ValueError):
+            checker.maven_path("a:b:c:d", "jar", "1.0")
+
+
+class ClassifyTest(unittest.TestCase):
+    def test_200_is_ok(self):
+        self.assertEqual(checker.classify(200), "ok")
+
+    def test_only_404_is_missing(self):
+        self.assertEqual(checker.classify(404), "missing")
+
+    def test_other_http_statuses_are_errors(self):
+        for status in (403, 429, 500, 502, 503):
+            self.assertEqual(checker.classify(status), "error")
+
+    def test_transport_error_string_is_error(self):
+        self.assertEqual(checker.classify("Connection refused"), "error")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 131254e..b8a6f2d 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -76,7 +76,10 @@
             providers = [java_common.JavaRuntimeInfo],
         ),
     },
-    outputs = {"zip": "%{name}.zip"},
+    # Emit a .jar directly (a javadoc jar is a zip). This lets the artifact be
+    # consumed as a Maven `javadoc` classifier without a rename step, and matches
+    # the conventional `-javadoc.jar` name.
+    outputs = {"zip": "%{name}.jar"},
     implementation = _impl,
     fragments = ["java"],
 )
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 0827458..bb5a34a 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,153 +1,16 @@
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
-load("@npm//@bazel/terser:index.bzl", "terser_minified")
-load("//tools/bzl:genrule2.bzl", "genrule2")
+"""Gerrit-specific rules for JavaScript."""
 
-ComponentInfo = provider()
-
-def _js_component(ctx):
-    dir = ctx.outputs.zip.path + ".dir"
-    name = ctx.outputs.zip.basename
-    if name.endswith(".zip"):
-        name = name[:-4]
-    dest = "%s/%s" % (dir, name)
-    cmd = " && ".join([
-        "TZ=UTC",
-        "export TZ",
-        "mkdir -p %s" % dest,
-        "cp %s %s/" % (" ".join([s.path for s in ctx.files.srcs]), dest),
-        "cd %s" % dir,
-        "find . -exec touch -t 198001010000 '{}' ';'",
-        "zip -Xqr ../%s *" % ctx.outputs.zip.basename,
-    ])
-
-    ctx.actions.run_shell(
-        inputs = ctx.files.srcs,
-        outputs = [ctx.outputs.zip],
-        command = cmd,
-        mnemonic = "GenJsComponentZip",
-    )
-
-    licenses = []
-    if ctx.file.license:
-        licenses.append(ctx.file.license)
-
-    return [
-        ComponentInfo(
-            transitive_licenses = depset(licenses),
-            transitive_versions = depset(),
-            transitive_zipfiles = list([ctx.outputs.zip]),
-        ),
-    ]
-
-js_component = rule(
-    _js_component,
-    attrs = {
-        "srcs": attr.label_list(allow_files = [".js"]),
-        "license": attr.label(allow_single_file = True),
-    },
-    outputs = {
-        "zip": "%{name}.zip",
-    },
+load(
+    "@com_googlesource_gerrit_bazlets//js:defs.bzl",
+    _gerrit_js_bundle = "gerrit_js_bundle",
+    _js_component = "js_component",
 )
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
 
-def polygerrit_plugin(name, app, plugin_name = None):
-    """Produces plugin file set with minified javascript.
+gerrit_js_bundle = _gerrit_js_bundle
+js_component = _js_component
 
-    This rule minifies a plugin javascript file, potentially renames it, and produces a file set.
-    Output of this rule is a FileSet with "${plugin_name}.js".
-
-    Args:
-      name: String, rule name.
-      app: String, the main or root source file. This must be single JavaScript file.
-      plugin_name: String, plugin name. ${name} is used if not provided.
-    """
-    if not plugin_name:
-        plugin_name = name
-
-    terser_minified(
-        name = plugin_name + ".min",
-        sourcemap = False,
-        src = app,
-    )
-
-    native.genrule(
-        name = name + "_rename_js",
-        srcs = [plugin_name + ".min"],
-        outs = [plugin_name + ".js"],
-        cmd = "cp $< $@",
-        output_to_bindir = True,
-    )
-
-    native.filegroup(
-        name = name,
-        srcs = [plugin_name + ".js"],
-    )
-
-def gerrit_js_bundle(name, entry_point, srcs = []):
-    """Produces a Gerrit JavaScript bundle archive.
-
-    This rule bundles and minifies the javascript files of a frontend plugin and
-    produces a file archive.
-    Output of this rule is an archive with "${name}.jar" with specific layout for
-    Gerrit frontend plugins. That archive should be provided to gerrit_plugin
-    rule as resource_jars attribute.
-
-    Args:
-      name: Rule name.
-      srcs: Plugin sources.
-      entry_point: Plugin entry_point.
-    """
-
-    bundle = name + "-bundle"
-    minified = name + ".min"
-    main = name + ".js"
-
-    rollup_bundle(
-        name = bundle,
-        srcs = srcs + [
-            "@plugins_npm//:node_modules",
-        ],
-        args = [
-            "--bundleConfigAsCjs=true",
-        ],
-        entry_point = entry_point,
-        format = "iife",
-        rollup_bin = "//tools/node_tools:rollup-bin",
-        silent = True,
-        sourcemap = "hidden",
-        config_file = "//plugins:rollup.config.js",
-        deps = [
-            "@tools_npm//@rollup/plugin-node-resolve",
-        ],
-    )
-
-    terser_minified(
-        name = minified,
-        sourcemap = False,
-        src = bundle,
-    )
-
-    native.genrule(
-        name = name + "_rename_js",
-        srcs = [minified],
-        outs = [main],
-        cmd = "cp $< $@",
-        output_to_bindir = True,
-    )
-
-    genrule2(
-        name = name,
-        srcs = [main],
-        outs = [name + ".jar"],
-        cmd = " && ".join([
-            "mkdir $$TMP/static",
-            "cp $(SRCS) $$TMP/static",
-            "cd $$TMP",
-            "zip -Drq $$ROOT/$@ -g .",
-        ]),
-    )
-
-def web_test_runner(name, srcs, data):
+def web_test_runner(name, srcs, data, args = []):
     """Creates a Web Test Runner test target.
 
     It can be used both for the main Gerrit js bundle, but also for plugins. So
@@ -162,17 +25,21 @@
       srcs: The shell script to invoke, where you can set command line
         arguments for Web Test Runner and its config.
       data: The bundle of JavaScript files with the tests included.
+      args: Additional command-line arguments appended after the runner bin
+        and config paths. Useful for enabling test modes such as
+        --run-screenshots.
     """
 
-    native.sh_test(
+    sh_test(
         name = name,
         size = "enormous",
         srcs = srcs,
         args = [
             "$(location //polygerrit-ui:web_test_runner_bin)",
             "$(location //polygerrit-ui:web-test-runner.config.mjs)",
-        ],
+        ] + args,
         data = data + [
+            "//polygerrit-ui:resultdb-reporter.mjs",
             "//polygerrit-ui:web_test_runner_bin",
             "//polygerrit-ui:web-test-runner.config.mjs",
         ],
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index f2ae73f..c8d0130 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -1,3 +1,5 @@
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+
 """This file contains rules to generate and test the license map"""
 
 def normalize_target_name(target):
@@ -75,7 +77,7 @@
         # the license files themselves from this list.
         expression = 'rdeps(%s, "%s", 1) - rdeps(%s, "%s", 0)' % (target, forbidden, target, forbidden),
     )
-    native.sh_test(
+    sh_test(
         name = name,
         srcs = ["//tools/bzl:test_license.sh"],
         args = ["$(location :%s)" % txt],
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
deleted file mode 100644
index e269632..0000000
--- a/tools/bzl/maven_jar.bzl
+++ /dev/null
@@ -1,189 +0,0 @@
-GERRIT = "GERRIT:"
-
-GERRIT_API = "GERRIT_API:"
-
-MAVEN_CENTRAL = "MAVEN_CENTRAL:"
-
-MAVEN_LOCAL = "MAVEN_LOCAL:"
-
-ECLIPSE = "ECLIPSE:"
-
-MAVEN_SNAPSHOT = "https://ossrh-staging-api.central.sonatype.com/content/repositories/snapshots"
-
-SNAPSHOT = "-SNAPSHOT-"
-
-def _maven_release(ctx, parts):
-    """induce jar and url name from maven coordinates."""
-    if len(parts) not in [3, 4]:
-        fail('%s:\nexpected id="groupId:artifactId:version[:classifier]"' %
-             ctx.attr.artifact)
-    if len(parts) == 4:
-        group, artifact, version, classifier = parts
-        file_version = version + "-" + classifier
-    else:
-        group, artifact, version = parts
-        file_version = version
-
-    repository = ctx.attr.repository
-
-    if "-SNAPSHOT-" in version:
-        start = version.index(SNAPSHOT)
-        end = start + len(SNAPSHOT) - 1
-
-        # file version without snapshot constant, but with post snapshot suffix
-        file_version = version[:start] + version[end:]
-
-        # version without post snapshot suffix
-        version = version[:end]
-
-        # overwrite the repository with Maven snapshot repository
-        repository = MAVEN_SNAPSHOT
-
-    jar = artifact.lower() + "-" + file_version
-
-    url = "/".join([
-        repository,
-        group.replace(".", "/"),
-        artifact,
-        version,
-        artifact + "-" + file_version,
-    ])
-
-    return jar, url
-
-# Creates a struct containing the different parts of an artifact's FQN
-def _create_coordinates(fully_qualified_name):
-    parts = fully_qualified_name.split(":")
-    packaging = None
-    classifier = None
-
-    if len(parts) == 3:
-        group_id, artifact_id, version = parts
-    elif len(parts) == 4:
-        group_id, artifact_id, version, classifier = parts
-    elif len(parts) == 5:
-        group_id, artifact_id, version, packaging, classifier = parts
-    else:
-        fail("Invalid fully qualified name for artifact: %s" % fully_qualified_name)
-
-    return struct(
-        fully_qualified_name = fully_qualified_name,
-        group_id = group_id,
-        artifact_id = artifact_id,
-        packaging = packaging,
-        classifier = classifier,
-        version = version,
-    )
-
-def _format_deps(attr, deps):
-    formatted_deps = ""
-    if deps:
-        if len(deps) == 1:
-            formatted_deps += "%s = [\'%s\']," % (attr, deps[0])
-        else:
-            formatted_deps += "%s = [\n" % attr
-            for dep in deps:
-                formatted_deps += "        \'%s\',\n" % dep
-            formatted_deps += "    ],"
-    return formatted_deps
-
-def _generate_build_files(ctx, binjar, srcjar):
-    header = "# DO NOT EDIT: automatically generated BUILD file for maven_jar rule %s" % ctx.name
-    srcjar_attr = ""
-    if srcjar:
-        srcjar_attr = 'srcjar = "%s",' % srcjar
-    contents = """
-{header}
-load("@rules_java//java:defs.bzl", "java_import")
-package(default_visibility = ['//visibility:public'])
-java_import(
-    name = 'jar',
-    jars = ['{binjar}'],
-    {srcjar_attr}
-    {deps}
-    {exports}
-)
-java_import(
-    name = 'neverlink',
-    jars = ['{binjar}'],
-    neverlink = 1,
-    {deps}
-    {exports}
-)
-\n""".format(
-        srcjar_attr = srcjar_attr,
-        header = header,
-        binjar = binjar,
-        deps = _format_deps("deps", ctx.attr.deps),
-        exports = _format_deps("exports", ctx.attr.exports),
-    )
-    if srcjar:
-        contents += """
-java_import(
-    name = 'src',
-    jars = ['{srcjar}'],
-)
-""".format(srcjar = srcjar)
-    ctx.file("%s/BUILD" % ctx.path("jar"), contents, False)
-
-def _maven_jar_impl(ctx):
-    """rule to download a Maven archive."""
-    coordinates = _create_coordinates(ctx.attr.artifact)
-
-    name = ctx.name
-    sha1 = ctx.attr.sha1
-
-    parts = ctx.attr.artifact.split(":")
-
-    jar, url = _maven_release(ctx, parts)
-
-    binjar = jar + ".jar"
-    binjar_path = ctx.path("/".join(["jar", binjar]))
-    binurl = url + ".jar"
-
-    python = ctx.which("python3")
-    script = ctx.path(ctx.attr._download_script)
-
-    args = [python, script, "-o", binjar_path, "-u", binurl]
-    if ctx.attr.sha1:
-        args.extend(["-v", sha1])
-    for x in ctx.attr.exclude:
-        args.extend(["-x", x])
-
-    out = ctx.execute(args)
-
-    if out.return_code:
-        fail("failed %s: %s" % (args, out.stderr))
-
-    srcjar = None
-    if ctx.attr.src_sha1 or ctx.attr.attach_source:
-        srcjar = jar + "-src.jar"
-        srcurl = url
-        if coordinates.classifier != None:
-            srcurl = url.replace("-" + coordinates.classifier, "")
-        srcurl += "-sources.jar"
-        srcjar_path = ctx.path("jar/" + srcjar)
-        args = [python, script, "-o", srcjar_path, "-u", srcurl]
-        if ctx.attr.src_sha1:
-            args.extend(["-v", ctx.attr.src_sha1])
-        out = ctx.execute(args)
-        if out.return_code:
-            fail("failed %s: %s" % (args, out.stderr))
-
-    _generate_build_files(ctx, binjar, srcjar)
-
-maven_jar = repository_rule(
-    attrs = {
-        "artifact": attr.string(mandatory = True),
-        "attach_source": attr.bool(default = True),
-        "exclude": attr.string_list(),
-        "repository": attr.string(default = MAVEN_CENTRAL),
-        "sha1": attr.string(),
-        "src_sha1": attr.string(),
-        "exports": attr.string_list(),
-        "deps": attr.string_list(),
-        "_download_script": attr.label(default = Label("//tools:download_file.py")),
-    },
-    local = True,
-    implementation = _maven_jar_impl,
-)
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index e99384b..e94fb65 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -126,7 +126,7 @@
         n = n[:i]
     return n
 
-def should_skip_packaged_jar(jar_name):
+def should_skip_packaged_jar(ctx, jar_name):
     """Returns True if the packaged jar should be skipped.
 
     jar_name must be the post-processed name (war_jar_name output).
@@ -137,7 +137,7 @@
     Returns:
       True if the packaged jar should be skipped, False otherwise.
     """
-    for pfx in EXCLUDE_WAR_JAR_PREFIXES:
+    for pfx in EXCLUDE_WAR_JAR_PREFIXES + ctx.attr.exclude_jar_prefixes:
         if jar_name.startswith(pfx):
             return True
 
@@ -212,7 +212,7 @@
 
     for dep in depset(transitive = transitive_libs).to_list():
         packaged = war_jar_name(dep)
-        if should_skip_packaged_jar(packaged):
+        if should_skip_packaged_jar(ctx, packaged):
             continue
 
         cmd += _add_file(dep, build_output + "/WEB-INF/lib/")
@@ -231,7 +231,7 @@
 
     for dep in depset(transitive = transitive_pgmlibs).to_list():
         packaged = war_jar_name(dep)
-        if should_skip_packaged_jar(packaged):
+        if should_skip_packaged_jar(ctx, packaged):
             continue
 
         if dep not in inputs:
@@ -292,6 +292,7 @@
 _pkg_war = rule(
     attrs = {
         "context": attr.label_list(allow_files = True),
+        "exclude_jar_prefixes": attr.string_list(),
         "libs": attr.label_list(allow_files = jar_filetype),
         "pgmlibs": attr.label_list(allow_files = False),
     },
@@ -303,7 +304,14 @@
     implementation = _war_impl,
 )
 
-def pkg_war(name, ui = "polygerrit", context = [], doc = False, **kwargs):
+def pkg_war(
+        name,
+        ui = "polygerrit",
+        context = [],
+        doc = False,
+        additional_libs = [],
+        exclude_jar_prefixes = [],
+        **kwargs):
     """Rule for packaging the Gerrit WAR.
 
     Args:
@@ -311,6 +319,8 @@
       ui: The UI type, e.g. "polygerrit".
       context: The list of context dependencies.
       doc: Whether to include documentation.
+      additional_libs: Additional libraries to package into WEB-INF/lib.
+      exclude_jar_prefixes: Additional packaged jar prefixes to skip.
       **kwargs: Additional keyword arguments.
     """
     doc_ctx = []
@@ -324,11 +334,12 @@
 
     _pkg_war(
         name = name,
-        libs = LIBS + doc_lib,
+        libs = LIBS + doc_lib + additional_libs,
         pgmlibs = PGMLIBS,
         context = doc_ctx + context + ui_deps + [
             "//java:gerrit-main-class_deploy.jar",
             "//webapp:assets",
         ],
+        exclude_jar_prefixes = exclude_jar_prefixes,
         **kwargs
     )
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 399f1d6..24bd3b3 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -15,7 +15,6 @@
 def gerrit_plugin(
         name,
         deps = [],
-        provided_deps = [],
         srcs = [],
         resources = [],
         resource_jars = [],
@@ -33,7 +32,6 @@
     _gerrit_plugin(
         name = name,
         deps = deps,
-        provided_deps = provided_deps,
         srcs = srcs,
         resources = resources,
         resource_jars = resource_jars,
diff --git a/tools/bzl/war_checks.bzl b/tools/bzl/war_checks.bzl
index c121b7f..59acd24 100644
--- a/tools/bzl/war_checks.bzl
+++ b/tools/bzl/war_checks.bzl
@@ -1,3 +1,5 @@
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
+
 """Reusable checks for WAR content guardrails."""
 
 def war_jars_allowlist_test(name, war_jars_manifest, allowlist, **kwargs):
@@ -9,7 +11,7 @@
         allowlist: label of the checked-in allowlist file
         **kwargs: forwarded to sh_test
     """
-    native.sh_test(
+    sh_test(
         name = name,
         srcs = ["//tools/bzl:diff_allowlist.sh"],
         args = [
diff --git a/tools/deps.toml b/tools/deps.toml
index 75ffd76..0500703 100644
--- a/tools/deps.toml
+++ b/tools/deps.toml
@@ -1,14 +1,13 @@
 [versions]
 antlr = "3.5.2"
 autoValueGson = "1.3.1"
-bouncyCastle = "1.84"
-byteBuddy = "1.18.8"
+bouncyCastle = "1.85"
+byteBuddy = "1.18.12"
 caffeine = "2.9.2"
 commonmark = "0.24.0"
-gitiles = "1.6.0"
 greenmail = "1.5.5"
 httpcomp = "4.5.14"
-jetty = "12.1.10"
+jetty = "12.1.12"
 mail = "1.6.0"
 mime4j = "0.8.1"
 ow2 = "9.9.1"
@@ -38,9 +37,8 @@
 autotransient = { module = "io.sweers.autotransient:autotransient", version = "1.0.0" }
 bcpg-jdk18on = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncyCastle" }
 bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncyCastle" }
-bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" }
+bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.85.2" }
 bcutil-jdk18on = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "bouncyCastle" }
-blame-cache = { module = "com.google.gitiles:blame-cache", version.ref = "gitiles" }
 byte-buddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byteBuddy" }
 byte-buddy-agent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" }
 caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" }
@@ -48,10 +46,11 @@
 commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
 commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
 commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
+commonmark-ext-yaml-front-matter = { module = "org.commonmark:commonmark-ext-yaml-front-matter", version.ref = "commonmark" }
 commons-codec = { module = "commons-codec:commons-codec", version = "1.18.0" }
 commons-compress = { module = "org.apache.commons:commons-compress", version = "1.28.0" }
 commons-dbcp = { module = "commons-dbcp:commons-dbcp", version = "1.4" }
-commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.18.0" }
+commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.20.0" }
 commons-net = { module = "commons-net:commons-net", version = "3.6" }
 commons-pool = { module = "commons-pool:commons-pool", version = "1.5.5" }
 commons-text = { module = "org.apache.commons:commons-text", version = "1.15.0" }
@@ -60,7 +59,6 @@
 failureaccess = { module = "com.google.guava:failureaccess", version = "1.0.3" }
 flexmark-all = { module = "com.vladsch.flexmark:flexmark-all", version = "0.64.0:lib" }
 fluent-hc = { module = "org.apache.httpcomponents:fluent-hc", version.ref = "httpcomp" }
-gitiles-servlet = { module = "com.google.gitiles:gitiles-servlet", version.ref = "gitiles" }
 greenmail = { module = "com.icegreen:greenmail", version.ref = "greenmail" }
 guava = { module = "com.github.ben-manes.caffeine:guava", version.ref = "caffeine" }
 guava-retrying = { module = "com.github.rholder:guava-retrying", version = "2.0.0" }
diff --git a/tools/download_file.py b/tools/download_file.py
index 2af2c07..7abbd1b 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -86,11 +86,11 @@
 parser.add_argument('--exclude_java_sources', action='store_true')
 args = parser.parse_args()
 
-root_dir = args.o
+root_dir = path.abspath(path.dirname(args.o))
 while root_dir and path.dirname(root_dir) != root_dir:
-    root_dir, n = path.split(root_dir)
-    if n == 'WORKSPACE':
+    if path.exists(path.join(root_dir, 'MODULE.bazel')):
         break
+    root_dir = path.dirname(root_dir)
 
 redirects = download_properties(root_dir)
 cache_ent = cache_entry(args)
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 3fcc085..6bd0763 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -24,7 +24,7 @@
 MAIN = '//tools/eclipse:classpath'
 AUTO_COLLECT = '//tools/eclipse:autovalue_classpath_collect'
 
-def JRE(java_vers = '21'):
+def JRE(java_vers = '25'):
     return '/'.join([
         'org.eclipse.jdt.launching.JRE_CONTAINER',
         'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
@@ -48,7 +48,7 @@
 opts.add_argument('-b', '--batch', action='store_true',
                   dest='batch', help='Bazel batch option')
 opts.add_argument('-j', '--java', action='store',
-                  dest='java', help='Post Java 21')
+                  dest='java', help='Java version to use')
 opts.add_argument('--bazel',
                   help=('name of the bazel executable. Defaults to using'
                         ' bazelisk if found, or bazel if bazelisk is not'
diff --git a/tools/gjf.sh b/tools/gjf.sh
index 0209d7e..b346032 100755
--- a/tools/gjf.sh
+++ b/tools/gjf.sh
@@ -90,6 +90,10 @@
         sha1="3b55f08a70d53984ac4b3e7796dc992858d6bdd8"
         tag_prefix=v
     ;;
+    1.35.0)
+        sha1="c8e59165e3c971509d98018b98bf4f85a56a0ff9"
+        tag_prefix=v
+    ;;
     *)
         echo "unknown google-java-format version: $version"
         exit 1
@@ -152,7 +156,7 @@
 
 # MAIN
 
-SUPPORTED_VERSIONS=(1.7 1.22.0 1.24.0)
+SUPPORTED_VERSIONS=(1.7 1.22.0 1.24.0 1.35.0)
 HELP_TEXT="
     Usage:
 
@@ -164,8 +168,8 @@
 
 "
 
-# Keep the default version in sync with dev-contributing.txt.
-DEFAULT_VERSION="1.24.0"
+# Keep the default version in sync with dev-crafting-changes.txt.
+DEFAULT_VERSION="1.35.0"
 VERSION=${2:-$DEFAULT_VERSION}
 verify_version $VERSION
 
diff --git a/tools/java_deps.MODULE.bazel b/tools/java_deps.MODULE.bazel
index a899132..e75c3ec 100644
--- a/tools/java_deps.MODULE.bazel
+++ b/tools/java_deps.MODULE.bazel
@@ -137,6 +137,38 @@
     for coord in _GERRIT_FORCED_ARTIFACTS
 ]
 
+# We consume Guice's unshaded artifact (published as the "classes"
+# classifier) plus an external OW2 ASM (see //lib/guice and tools/nongoogle.toml),
+# because the ASM shaded into the default guice jar cannot read Java 25 class
+# files and corrupts Guice's error-message source formatting. The external ASM
+# must be >= 9.8, the first release with Java 25 (class major 69) support;
+# Gerrit pins 9.9.1.
+#
+# That only holds if the plain (fat) com.google.inject:guice never reaches the
+# classpath. These artifacts pull it in transitively, so strip that edge from
+# each; they resolve Guice solely from the //lib/guice classes-classifier jar.
+# Keep this list in sync with everything that depends on Guice transitively --
+# a missed entry silently reintroduces the shaded ASM.
+#
+# TODO(davido): Drop these exclusions (and the classes classifier) once Guice
+# ships a default jar with an updated shaded ASM that reads current class
+# files: https://github.com/google/guice/issues/1926
+_GUICE_FAT_JAR_CONSUMERS = [
+    "com.google.inject.extensions:guice-assistedinject",
+    "com.google.inject.extensions:guice-servlet",
+    "com.google.template:soy",
+    "org.openid4java:openid4java",
+]
+
+[
+    maven.amend_artifact(
+        name = "external_deps",
+        coordinates = coord,
+        exclusions = ["com.google.inject:guice"],
+    )
+    for coord in _GUICE_FAT_JAR_CONSUMERS
+]
+
 # Empty maven.install call to apply the configuration to the installation from
 # toml files.
 maven.install(
@@ -157,8 +189,10 @@
     ],
     lock_file = "//:external_deps.lock.json",
     repositories = [
-        "https://repo1.maven.org/maven2",
+        # Prefer Gerrit's mirror to reduce direct Maven Central traffic.
+        # Keep Maven Central as fallback while the mirror is populated.
         "https://gerrit-maven.storage.googleapis.com",
+        "https://repo1.maven.org/maven2",
     ],
     version_conflict_policy = "pinned",
 )
diff --git a/tools/js/BUILD b/tools/js/BUILD
index d696496..e69de29 100644
--- a/tools/js/BUILD
+++ b/tools/js/BUILD
@@ -1 +0,0 @@
-exports_files(["eslint-chdir.js"])
diff --git a/tools/js/eslint-chdir.js b/tools/js/eslint-chdir.js
deleted file mode 100644
index d9fd479..0000000
--- a/tools/js/eslint-chdir.js
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-// ESLint resolves relative paths from the current working directory.
-//
-// In workspace mode (`lint_bin`), this works with the regular Node.js module
-// layout under `polygerrit-ui/app/node_modules`.
-//
-// In Bazel test mode (`lint_test`), npm dependencies are exposed through
-// multiple runfiles trees instead. Typed linting with TypeScript does not see
-// the same module and type environment from that layout as from the workspace
-// layout.
-//
-// To align `lint_test` with `lint_bin`, synthesize a local
-// `polygerrit-ui/app/node_modules` by symlinking entries from the runfiles
-// npm trees, then prepend it to NODE_PATH so resolution matches workspace mode.
-
-const fs = require('fs');
-const Module = require('module');
-const path = require('path');
-
-function pathExists(filePath) {
-  try {
-    return fs.existsSync(filePath);
-  } catch {
-    return false;
-  }
-}
-
-function readDirEntries(dirPath) {
-  try {
-    return fs.readdirSync(dirPath, {withFileTypes: true});
-  } catch {
-    return [];
-  }
-}
-
-function ensureDir(dirPath) {
-  fs.mkdirSync(dirPath, {recursive: true});
-}
-
-function symlinkIfMissing(targetPath, linkPath) {
-  if (pathExists(linkPath)) return;
-
-  try {
-    fs.symlinkSync(targetPath, linkPath);
-  } catch {
-    // Ignore races and pre-existing entries.
-  }
-}
-
-function mergeNodeModules(destinationDir, sourceDirs) {
-  ensureDir(destinationDir);
-
-  for (const sourceDir of sourceDirs) {
-    for (const entry of readDirEntries(sourceDir)) {
-      const sourceEntry = path.join(sourceDir, entry.name);
-      const destEntry = path.join(destinationDir, entry.name);
-
-      if (entry.name.startsWith('@') && entry.isDirectory()) {
-        ensureDir(destEntry);
-
-        for (const scopedEntry of readDirEntries(sourceEntry)) {
-          symlinkIfMissing(
-            path.join(sourceEntry, scopedEntry.name),
-            path.join(destEntry, scopedEntry.name)
-          );
-        }
-        continue;
-      }
-
-      symlinkIfMissing(sourceEntry, destEntry);
-    }
-  }
-}
-
-function getRunfilesRoot() {
-  return process.env.RUNFILES_DIR || process.env.TEST_SRCDIR || '';
-}
-
-function getRunfilesNodeModules(runfilesRoot) {
-  return [
-    path.join(runfilesRoot, 'ui_dev_npm/node_modules'),
-    path.join(runfilesRoot, 'ui_npm/node_modules'),
-    path.join(runfilesRoot, '_main/node_modules'),
-  ].filter(pathExists);
-}
-
-function prependNodePath(paths) {
-  const existing = process.env.NODE_PATH
-    ? process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
-    : [];
-
-  process.env.NODE_PATH = [...paths, ...existing].join(path.delimiter);
-  Module._initPaths();
-}
-
-function getConfigDirFromArgv(argv) {
-  const configArgIndex = argv.findIndex(arg => arg === '-c' || arg === '--config');
-  if (configArgIndex < 0 || configArgIndex + 1 >= argv.length) return '';
-
-  return path.dirname(argv[configArgIndex + 1]);
-}
-
-const configDir = getConfigDirFromArgv(process.argv);
-if (!configDir) return;
-
-process.chdir(configDir);
-
-const runfilesRoot = getRunfilesRoot();
-if (!runfilesRoot) return;
-
-const runfilesNodeModules = getRunfilesNodeModules(runfilesRoot);
-if (runfilesNodeModules.length === 0) return;
-
-const localNodeModules = path.join(process.cwd(), 'node_modules');
-mergeNodeModules(localNodeModules, runfilesNodeModules);
-prependNodePath([localNodeModules, ...runfilesNodeModules]);
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index 91090d2..473330d 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -14,7 +14,7 @@
 
 """This file contains macro to run eslint and define a eslint test rule."""
 
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test")
+load("@npm//:eslint/package_json.bzl", eslint_bin = "bin")
 
 def plugin_eslint():
     """ Convenience wrapper macro of eslint() for Gerrit js plugins
@@ -28,22 +28,21 @@
         config = "eslint.config.js",
         data = [
             "tsconfig.json",
-            "//plugins:eslint.config.js",
-            "//plugins:.prettierrc.js",
-            "//plugins:tsconfig-plugins-base.json",
-            "@npm//typescript",
+            "//plugins:plugins-config-lib",
+            "//:node_modules/typescript",
         ],
         extensions = [".ts"],
         plugins = [
-            "@npm//eslint-config-google",
-            "@npm//eslint-plugin-html",
-            "@npm//eslint-plugin-import",
-            "@npm//eslint-plugin-jsdoc",
-            "@npm//eslint-plugin-lit",
-            "@npm//eslint-plugin-n",
-            "@npm//eslint-plugin-prettier",
-            "@npm//eslint-plugin-regex",
-            "@npm//gts",
+            "//:node_modules/@typescript-eslint/eslint-plugin",
+            "//:node_modules/eslint-config-google",
+            "//:node_modules/eslint-plugin-html",
+            "//:node_modules/eslint-plugin-import",
+            "//:node_modules/eslint-plugin-jsdoc",
+            "//:node_modules/eslint-plugin-lit",
+            "//:node_modules/eslint-plugin-n",
+            "//:node_modules/eslint-plugin-prettier",
+            "//:node_modules/eslint-plugin-regex",
+            "//:node_modules/gts",
         ],
     )
 
@@ -52,8 +51,8 @@
 
     Args:
         name: name of the rule
-        plugins: list of npm dependencies with plugins, for example "@npm//eslint-config-google"
-        srcs: list of files to be checked (ignored in {name}_bin rule)
+        plugins: list of npm dependencies with plugins, for example "//:node_modules/eslint-config-google"
+        srcs: list of files to be checked
         config: eslint config file
         size: eslint test size, supported values are: small, medium, large and enormous,
             with implied timeout labels: short, moderate, long, and eternal
@@ -67,35 +66,40 @@
         {name}_test rule - runs eslint tests. You can run this rule with
             'bazel test {name}_test' command. The rule tests all files from srcs with specified
             extensions inside the package where eslint macro is called.
-        {name}_bin rule - runs eslint with specified settings; ignores srcs. To use this rule
-            you must pass a folder to check, for example:
-            bazel run {name}_test -- --fix $(pwd)/polygerrit-ui/app
-    """
-    entry_point = "@npm//:node_modules/eslint/bin/eslint.js"
+        {name}_bin rule - runs eslint with specified settings. To use this rule
+            pass repo-root-relative or absolute paths, for example:
+            bazel run //polygerrit-ui/app:lint_bin -- polygerrit-ui/app
+            bazel run //polygerrit-ui/app:lint_bin -- \\
+                polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
 
+            Note: {name}_bin is intended for read-only linting. Do not use it with '--fix',
+            because ESLint runs on Bazel runfiles paths, which are not writable.
+    """
+
+    #TODO(Thomas): Use rules_lint
     bin_data = [
-        "@npm//eslint:eslint",
         config,
-        "//tools/js:eslint-chdir.js",
+        "//:node_modules/@eslint/eslintrc",
+        "//:node_modules/@eslint/js",
+        "//:node_modules/eslint",
     ] + plugins + data
+
     common_templated_args = [
-        "--node_options=--require=$$(rlocation $(rootpath //tools/js:eslint-chdir.js))",
         "--ext",
         ",".join(extensions),
-        "-c",
-        # Use rlocation/rootpath instead of location.
-        # See note and example here:
-        # https://bazelbuild.github.io/rules_nodejs/Built-ins.html#nodejs_binary
-        "$$(rlocation $(rootpath {}))".format(config),
     ]
-    nodejs_test(
+
+    eslint_bin.eslint_test(
         name = name + "_test",
-        entry_point = entry_point,
         data = bin_data + srcs,
+        chdir = native.package_name(),
         # Bazel generates 2 .js files, where names of the files are generated from the name
         # of the rule: {name}_test_require_patch.js and {name}_test_loader.js
         # Ignore these 2 files, for simplicity do not use {name} in the patterns.
-        templated_args = common_templated_args + [
+        expand_args = True,
+        fixed_args = common_templated_args + [
+            "-c",
+            config,
             "--ignore-pattern",
             "*_test_require_patch.js",
             "--ignore-pattern",
@@ -112,14 +116,24 @@
         size = size,
     )
 
-    nodejs_binary(
+    # Run from the workspace root so repo-root-relative paths remain inside ESLint's
+    # effective base path. Running from the package directory breaks linting of paths
+    # outside that directory with flat config:
+    # https://github.com/eslint/eslint/issues/19118
+    #
+    # Note that this rule is for read-only linting only. '--fix' is not supported here,
+    # because ESLint operates on Bazel runfiles paths, which are not writable.
+    eslint_bin.eslint_binary(
         name = name + "_bin",
-        entry_point = "@npm//:node_modules/eslint/bin/eslint.js",
-        data = bin_data,
+        data = bin_data + srcs,
+        chdir = "",
         # Bazel generates 2 .js files, where names of the files are generated from the name
         # of the rule: {name}_bin_require_patch.js and {name}_bin_loader.js
         # Ignore these 2 files, for simplicity do not use {name} in the patterns.
-        templated_args = common_templated_args + [
+        expand_args = True,
+        fixed_args = common_templated_args + [
+            "-c",
+            native.package_name() + "/" + config,
             "--ignore-pattern",
             "*_bin_require_patch.js",
             "--ignore-pattern",
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 379aec3..b8cf89c 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.14.5-SNAPSHOT</version>
+  <version>3.15.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 4e38371..16a13ab 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.14.5-SNAPSHOT</version>
+  <version>3.15.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 52109d2..d78532d 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.14.5-SNAPSHOT</version>
+  <version>3.15.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 4b70ba8..4a04f28 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.14.5-SNAPSHOT</version>
+  <version>3.15.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
index a36d3f8..22e016d 100644
--- a/tools/node_tools/BUILD
+++ b/tools/node_tools/BUILD
@@ -1,52 +1,3 @@
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
+load("@tools_npm//:defs.bzl", "npm_link_all_packages")
 
-package(default_visibility = ["//visibility:public"])
-
-# By default, rollup_bundle rule uses rollup from @npm workspace
-# and it expects that all plugins are installed in the same workspace.
-# This rule defines another rollup-bin from @tools_npm workspace.
-# Usage: rollup_bundle(rollup_bin = "//tools/node_tools:rollup-bin, ...)
-nodejs_binary(
-    name = "rollup-bin",
-    # Define only minimal required dependencies.
-    # Otherwise remote build execution fails with the too many
-    # files error when it builds :release target.
-    data = [
-        "@tools_npm//@rollup/plugin-terser",
-        "@tools_npm//rollup",
-    ],
-    # The entry point must be "@tools_npm:node_modules/rollup/dist/bin/rollup",
-    # But bazel doesn't run it correctly with the following command line:
-    # bazel test --test_env=GERRIT_NOTEDB=ON --spawn_strategy=standalone \
-    #    --genrule_strategy=standalone --test_output errors --test_summary detailed \
-    #    --flaky_test_attempts 3 --test_verbose_timeout_warnings --build_tests_only \
-    #    --subcommands //...
-    # This command line appears in Gerrit CI.
-    # For details, see comment in rollup-runner.js file
-    entry_point = "//tools/node_tools:rollup-runner.js",
-)
-
-# Create a tsc_wrapped compiler rule to use in the ts_library
-# compiler attribute when using self-managed dependencies
-# TODO: Would be nice to just use `tsc-bin` below instead.
-# We would prefer to not depend on @bazel/concatjs ...
-nodejs_binary(
-    name = "tsc_wrapped-bin",
-    # Point bazel to your node_modules to find the entry point
-    data = [
-        "@tools_npm//@bazel/concatjs",
-        "@tools_npm//typescript",
-    ],
-    # It seems, bazel uses different approaches to compile ts files (it runs some
-    # ts service in background). It works without any workaround.
-    entry_point = "@tools_npm//:node_modules/@bazel/concatjs/internal/tsc_wrapped/tsc_wrapped.js",
-)
-
-# Wrap a typescript into a tsc-bin binary.
-# The tsc-bin can be used as a tool to compile typescript code.
-nodejs_binary(
-    name = "tsc-bin",
-    # Point bazel to your node_modules to find the entry point
-    data = ["@tools_npm//typescript"],
-    entry_point = "@tools_npm//:node_modules/typescript/lib/tsc.js",
-)
+npm_link_all_packages(name = "node_modules")
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index f80b3ee..3e33ff7 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -1,47 +1,40 @@
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-load("@npm//@bazel/concatjs:index.bzl", "ts_library")
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("@aspect_rules_js//js:defs.bzl", "js_binary")
+load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
+load("@aspect_rules_rollup//rollup:defs.bzl", "rollup")
 
 package(default_visibility = ["//visibility:public"])
 
-# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
-# We would prefer to not depend on @bazel/concatjs ...
-ts_library(
+ts_project(
     name = "licenses-map",
     srcs = glob(["*.ts"]),
-    compiler = "//tools/node_tools:tsc_wrapped-bin",
+    declaration = True,
+    transpiler = "tsc",
     tsconfig = "tsconfig.json",
     deps = [
-        "@tools_npm//:node_modules",
+        "//tools/node_tools:node_modules",
     ],
 )
 
-# rollup_bundle - workaround for https://github.com/bazelbuild/rules_nodejs/issues/1522
-# The ts_library rule ("license-map") transpiles each .ts file to .js file.
-# The "license-map" rule includes multiple .ts files and produces multiple output files.
-# The nodejs_binary requires only one file as an entry_point. It is expected, that other
-# .js files from ts_library are also available, but because of the bug they are not available.
-# As a workaround we are using rollup_bundle to group all files together.
-rollup_bundle(
+rollup(
     name = "license-map-generator-bundle",
     args = [
         "--bundleConfigAsCjs=true",
     ],
     config_file = "rollup.config.js",
-    entry_point = "license-map-generator.ts",
+    entry_point = "license-map-generator.js",
     format = "cjs",
-    rollup_bin = "//tools/node_tools:rollup-bin",
+    node_modules = "//tools/node_tools:node_modules",
     silent = True,
     deps = [
         ":licenses-map",
-        "@tools_npm//@rollup/plugin-node-resolve",
-        "@tools_npm//rollup",
+        "//tools/node_tools:node_modules/@rollup/plugin-node-resolve",
+        "//tools/node_tools:node_modules/rollup",
     ],
 )
 
-nodejs_binary(
+js_binary(
     name = "license-map-generator-bin",
-    entry_point = "license-map-generator-bundle.js",
+    entry_point = ":license-map-generator-bundle",
 )
 
 # (TODO)dmfilippov Find a better way to fix it (another workaround or submit a bug to
diff --git a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
index 49beda3..bdd9a5f 100644
--- a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
+++ b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { PackageName, PackageVersion, DirPath, FilePath } from "./base-types";
+import {PackageName, PackageVersion, DirPath, FilePath} from "./base-types";
 import {fail} from "./utils";
 import * as path from "path";
 import * as fs from "fs";
@@ -42,19 +42,22 @@
  * It is expected, that the addPackageJson method is called first for
  * all package.json files first and then the addFile method is called for all files (including package.json)
  */
-export class InsalledPackagesBuilder {
+export class InstalledPackagesBuilder {
   private readonly rootPathToPackageMap: Map<DirPath, InstalledPackage> = new Map();
 
-  public constructor(private readonly nonPackages: Set<string>) {
-  }
+  public constructor(private readonly nonPackages: Set<string>) {}
 
   public addPackageJson(packageJsonPath: string) {
     const pack = this.createInstalledPackage(packageJsonPath);
     if (!pack) return;
-    this.rootPathToPackageMap.set(pack.rootPath, pack)
+    this.rootPathToPackageMap.set(pack.rootPath, pack);
   }
+
   public addFile(file: string) {
-    const pack = this.findPackageForFile(file)!;
+    const pack = file.includes("@file+")
+      ? this.findLocalPackageForFile(file)
+      : this.findPackageForFile(file);
+
     pack.files.push(path.relative(pack.rootPath, file));
   }
 
@@ -68,42 +71,89 @@
     const nameParts: Array<string> = [];
     const rootPath = path.dirname(packageJsonFile);
     let currentDir = rootPath;
-    while(currentDir != "") {
+    while (currentDir !== "") {
       const partName = path.basename(currentDir);
-      if(partName === "node_modules") {
+      if (partName === "node_modules") {
         const packageName = nameParts.reverse().join("/");
-        const version = JSON.parse(fs.readFileSync(packageJsonFile, {encoding: 'utf-8'}))["version"];
-        if(!version) {
+        const version = JSON.parse(
+          fs.readFileSync(packageJsonFile, {encoding: "utf-8"})
+        )["version"];
+        if (!version) {
           if (this.nonPackages.has(packageName)) {
             return undefined;
           }
-          fail(`Can't get version for ${packageJsonFile}`)
+          fail(`Can't get version for ${packageJsonFile}`);
         }
         return {
           name: packageName,
           rootPath: rootPath,
           version: version,
-          files: []
+          files: [],
         };
       }
       nameParts.push(partName);
       currentDir = path.dirname(currentDir);
     }
-    fail(`Can't create package info for '${packageJsonFile}'`)
+    fail(`Can't create package info for '${packageJsonFile}'`);
   }
 
   private findPackageForFile(filePath: FilePath): InstalledPackage {
     let currentDir = path.dirname(filePath);
-    while(currentDir != "") {
-      if(this.rootPathToPackageMap.has(currentDir)) {
+
+    while (true) {
+      if (this.rootPathToPackageMap.has(currentDir)) {
         return this.rootPathToPackageMap.get(currentDir)!;
       }
-      currentDir = path.dirname(currentDir);
+
+      const nextDir = path.dirname(currentDir);
+      if (nextDir === currentDir) {
+        break;
+      }
+      currentDir = nextDir;
     }
+
     fail(`Can't find package for '${filePath}'`);
   }
 
+  private findLocalPackageForFile(filePath: FilePath): InstalledPackage {
+    let currentDir = path.dirname(filePath);
+    currentDir = this.getPackagePathRelativeToNodeModules(currentDir);
+
+    const parts = currentDir.split("/");
+    const pack =
+      parts[0].startsWith("@") && parts.length > 1
+        ? `${parts[0]}/${parts[1]}`
+        : parts[0];
+
+    if (this.rootPathToPackageMap.has(pack)) {
+      return this.rootPathToPackageMap.get(pack)!;
+    }
+
+    const packIndex = filePath.lastIndexOf(pack);
+    if (packIndex === -1) {
+      fail(`Can't determine package root for '${filePath}'`);
+    }
+
+    const installedPack: InstalledPackage = {
+      name: pack,
+      version: "Snapshot",
+      rootPath: filePath.substring(0, packIndex + pack.length),
+      files: [],
+    };
+    this.rootPathToPackageMap.set(pack, installedPack);
+    return installedPack;
+  }
+
   public build(): InstalledPackage[] {
     return [...this.rootPathToPackageMap.values()];
   }
+
+  private getPackagePathRelativeToNodeModules(filePath: string): string {
+    const nodeModulesPathPart = "/node_modules/";
+    const index = filePath.lastIndexOf(nodeModulesPathPart);
+    if (index === -1) {
+      fail(`Path does not contain '${nodeModulesPathPart}': '${filePath}'`);
+    }
+    return filePath.substring(index + nodeModulesPathPart.length);
+  }
 }
diff --git a/tools/node_tools/node_modules_licenses/license-map-generator.ts b/tools/node_tools/node_modules_licenses/license-map-generator.ts
index c0c2315..fd31fea 100644
--- a/tools/node_tools/node_modules_licenses/license-map-generator.ts
+++ b/tools/node_tools/node_modules_licenses/license-map-generator.ts
@@ -54,12 +54,24 @@
   packages: PackageInfo[];
 }
 
+function resolveExecrootPath(filePath: string): string {
+  if (path.isAbsolute(filePath)) return filePath;
+
+  const bazelBindir = process.env["BAZEL_BINDIR"];
+  if (bazelBindir && process.cwd().endsWith(bazelBindir)) {
+    const execroot = process.cwd().slice(0, process.cwd().length - bazelBindir.length);
+    return path.join(execroot, filePath);
+  }
+
+  return path.join(process.cwd(), filePath);
+}
+
 function parseArguments(argv: string[]): LicenseMapCommandLineArgs {
   if(argv.length < 6) {
     fail("Invalid command line parameters\n" +
         "\tUsage:\n\tnode license-map-generator config.js node-modules-files.txt shared-licenses.txt json-output.json");
   }
-  const packages: PackageInfo[] = require(path.join(process.cwd(), argv[2])).default;
+  const packages: PackageInfo[] = require(resolveExecrootPath(argv[2])).default;
   const nodeModulesFiles = readMultilineParamFile(argv[3]);
   const sharedLicensesFiles = readMultilineParamFile(argv[4]);
 
@@ -69,16 +81,21 @@
       nodeModulesFiles,
       sharedLicensesFiles,
     },
-    outputJsonPath: argv[5]
-  }
+    outputJsonPath: resolveExecrootPath(argv[5]),
+  };
 }
 
 function main() {
   const args = parseArguments(process.argv);
-  const generator = new LicenseMapGenerator(args.generatorParams.packages, new SharedLicensesProvider(args.generatorParams.sharedLicensesFiles));
+  const generator = new LicenseMapGenerator(
+      args.generatorParams.packages,
+      new SharedLicensesProvider(args.generatorParams.sharedLicensesFiles)
+  );
   const licenseMap = generator.generateMap(args.generatorParams.nodeModulesFiles);
   // JSON is quite small, so there are no reasons to minify it.
   // Write it as multiline file with tabs (spaces).
+
+  fs.mkdirSync(path.dirname(args.outputJsonPath), {recursive: true});
   fs.writeFileSync(args.outputJsonPath, JSON.stringify(licenseMap, null, 2), "utf-8");
 }
 
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index b63685d..55fb004 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -19,7 +19,7 @@
 import * as fs from "fs";
 import {isSharedFileLicenseInfo, LicenseInfo, PackageInfo} from "./package-license-info";
 import {LicenseName, PackageName, FilePath, PackageVersion} from "./base-types";
-import { InstalledPackage, InsalledPackagesBuilder } from "./installed-node-modules-map";
+import { InstalledPackage, InstalledPackagesBuilder } from "./installed-node-modules-map";
 import {SharedLicensesProvider} from "./shared-licenses-provider";
 import {fail} from "./utils";
 
@@ -225,7 +225,7 @@
         fullNonPackageNames.push(...p.nonPackages.map(name => `${p.name}/${name}`));
       }
     }
-    const builder = new InsalledPackagesBuilder(new Set(fullNonPackageNames));
+    const builder = new InstalledPackagesBuilder(new Set(fullNonPackageNames));
     // Register all package.json files - such files exists in the root folder of each module
     let packageJsonFiles = nodeModulesFiles.filter(f => path.basename(f) === "package.json");
     // The `safevalue` node module has an unusual setup: It also includes (meaningless) package.json
diff --git a/tools/node_tools/node_modules_licenses/node_modules_licenses.bzl b/tools/node_tools/node_modules_licenses/node_modules_licenses.bzl
deleted file mode 100644
index 64c8b79..0000000
--- a/tools/node_tools/node_modules_licenses/node_modules_licenses.bzl
+++ /dev/null
@@ -1,42 +0,0 @@
-"""This file contains the rule to generate a license map for node modules"""
-
-def _node_modules_licenses_impl(ctx):
-    """Wrapper for the license-map-generator command-line tool"""
-
-    node_modules_args = ctx.actions.args()
-    node_modules_args.add_all(ctx.files.node_modules)
-    node_modules_args.use_param_file("%s", use_always = True)
-
-    licenses_texts_args = ctx.actions.args()
-    licenses_texts_args.add_all(ctx.files.licenses_texts)
-    licenses_texts_args.use_param_file("%s", use_always = True)
-
-    ctx.actions.run(
-        executable = ctx.executable._license_map_generator,
-        arguments = [ctx.file.licenses_config.path, node_modules_args, licenses_texts_args, ctx.outputs.json.path],
-        outputs = [ctx.outputs.json],
-        inputs = depset([ctx.file.licenses_config] + ctx.files.licenses_texts, transitive = [ctx.attr.node_modules.files]),
-    )
-
-# Rule to run license-map-generator.ts
-# node_modules - label of npm workspace in the format @npm//:node_modules
-# The output contains information about licenses for the workspace.
-# licenses_texts is a list of shared licenses
-# For details - see comments in the
-# tools/node_tools/node_modules_licenses/license-map-generator.ts file
-node_modules_licenses = rule(
-    implementation = _node_modules_licenses_impl,
-    attrs = {
-        "node_modules": attr.label(mandatory = True),
-        "licenses_texts": attr.label_list(allow_files = True, mandatory = True),
-        "licenses_config": attr.label(allow_single_file = True, mandatory = True),
-        "_license_map_generator": attr.label(
-            default = Label("//tools/node_tools/node_modules_licenses:license-map-generator-bin"),
-            executable = True,
-            cfg = "host",
-        ),
-    },
-    outputs = {
-        "json": "%{name}.json",
-    },
-)
diff --git a/tools/node_tools/node_modules_licenses/tsconfig.json b/tools/node_tools/node_modules_licenses/tsconfig.json
index 9a76949..3301307 100644
--- a/tools/node_tools/node_modules_licenses/tsconfig.json
+++ b/tools/node_tools/node_modules_licenses/tsconfig.json
@@ -8,14 +8,17 @@
         ]
       }
     ],
+    "declaration": true,
     "target": "es2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
     "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "../../../.ts-out/tools/node_modules_licenses", // Not used in bazel,
-    "types": ["node"]
+    "types": ["node"],
+    "typeRoots": [
+      "../node_modules/@types",
+    ],
   },
   "include": ["*.ts"]
 }
diff --git a/tools/node_tools/node_modules_licenses/utils.ts b/tools/node_tools/node_modules_licenses/utils.ts
index e73bf96..555571a 100644
--- a/tools/node_tools/node_modules_licenses/utils.ts
+++ b/tools/node_tools/node_modules_licenses/utils.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import * as path from "path";
 import * as fs from "fs";
 
 export function fail(message: string): never {
@@ -28,9 +29,22 @@
       str.slice(1, -1) : str;
 }
 
-export function readMultilineParamFile(path: string): string[] {
-  return fs.readFileSync(path, {encoding: 'utf-8'})
+function resolveExecrootPath(filePath: string): string {
+  if (path.isAbsolute(filePath)) return filePath;
+
+  const bazelBindir = process.env["BAZEL_BINDIR"];
+  if (bazelBindir && process.cwd().endsWith(bazelBindir)) {
+    const execroot = process.cwd().slice(0, process.cwd().length - bazelBindir.length);
+    return path.join(execroot, filePath);
+  }
+
+  return path.join(process.cwd(), filePath);
+}
+
+export function readMultilineParamFile(filePath: string): string[] {
+  return fs.readFileSync(resolveExecrootPath(filePath), {encoding: 'utf-8'})
       .split(/\r?\n/)
       .filter(f => f.length > 0)
-      .map(removeSurrondedQuotes);
+      .map(removeSurrondedQuotes)
+      .map(resolveExecrootPath);
 }
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 4016d1f..42b4759 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,14 +3,7 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^5.8.1",
-    "@bazel/typescript": "^5.8.1",
-    "@bazel/concatjs": "^5.8.1",
     "@types/node": "^22.15.2",
-    "@types/parse5": "^4.0.0",
-    "@types/parse5-html-rewriting-stream": "^5.1.2",
-    "dom5": "^3.0.1",
-    "parse5-html-rewriting-stream": "^5.1.1",
     "rollup": "^4.40.0",
     "@rollup/plugin-commonjs": "^28.0.3",
     "@rollup/plugin-replace": "^6.0.2",
@@ -22,4 +15,4 @@
   "devDependencies": {},
   "license": "Apache-2.0",
   "private": true
-}
\ No newline at end of file
+}
diff --git a/tools/node_tools/pnpm-lock.yaml b/tools/node_tools/pnpm-lock.yaml
new file mode 100644
index 0000000..9a5aa77
--- /dev/null
+++ b/tools/node_tools/pnpm-lock.yaml
@@ -0,0 +1,573 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      '@rollup/plugin-commonjs':
+        specifier: ^28.0.3
+        version: 28.0.3(rollup@4.40.2)
+      '@rollup/plugin-node-resolve':
+        specifier: ^16.0.1
+        version: 16.0.1(rollup@4.40.2)
+      '@rollup/plugin-replace':
+        specifier: ^6.0.2
+        version: 6.0.2(rollup@4.40.2)
+      '@rollup/plugin-terser':
+        specifier: ^0.4.4
+        version: 0.4.4(rollup@4.40.2)
+      '@types/node':
+        specifier: ^22.15.2
+        version: 22.15.18
+      rollup:
+        specifier: ^4.40.0
+        version: 4.40.2
+      terser:
+        specifier: ~5.39.0
+        version: 5.39.2
+      typescript:
+        specifier: ^5.8.3
+        version: 5.8.3
+
+packages:
+
+  '@jridgewell/gen-mapping@0.3.8':
+    resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/resolve-uri@3.1.2':
+    resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/set-array@1.2.1':
+    resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
+    engines: {node: '>=6.0.0'}
+
+  '@jridgewell/source-map@0.3.6':
+    resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
+
+  '@jridgewell/sourcemap-codec@1.5.0':
+    resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+
+  '@jridgewell/trace-mapping@0.3.25':
+    resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+
+  '@rollup/plugin-commonjs@28.0.3':
+    resolution: {integrity: sha512-pyltgilam1QPdn+Zd9gaCfOLcnjMEJ9gV+bTw6/r73INdvzf1ah9zLIJBm+kW7R6IUFIQ1YO+VqZtYxZNWFPEQ==}
+    engines: {node: '>=16.0.0 || 14 >= 14.17'}
+    peerDependencies:
+      rollup: ^2.68.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/plugin-node-resolve@16.0.1':
+    resolution: {integrity: sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^2.78.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/plugin-replace@6.0.2':
+    resolution: {integrity: sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/plugin-terser@0.4.4':
+    resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^2.0.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/pluginutils@5.1.4':
+    resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
+    engines: {node: '>=14.0.0'}
+    peerDependencies:
+      rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+    peerDependenciesMeta:
+      rollup:
+        optional: true
+
+  '@rollup/rollup-android-arm-eabi@4.40.2':
+    resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==}
+    cpu: [arm]
+    os: [android]
+
+  '@rollup/rollup-android-arm64@4.40.2':
+    resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==}
+    cpu: [arm64]
+    os: [android]
+
+  '@rollup/rollup-darwin-arm64@4.40.2':
+    resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@rollup/rollup-darwin-x64@4.40.2':
+    resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==}
+    cpu: [x64]
+    os: [darwin]
+
+  '@rollup/rollup-freebsd-arm64@4.40.2':
+    resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@rollup/rollup-freebsd-x64@4.40.2':
+    resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.40.2':
+    resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==}
+    cpu: [arm]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm-musleabihf@4.40.2':
+    resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==}
+    cpu: [arm]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-arm64-gnu@4.40.2':
+    resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-arm64-musl@4.40.2':
+    resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==}
+    cpu: [arm64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-loongarch64-gnu@4.40.2':
+    resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==}
+    cpu: [loong64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
+    resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==}
+    cpu: [ppc64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-gnu@4.40.2':
+    resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-riscv64-musl@4.40.2':
+    resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==}
+    cpu: [riscv64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-linux-s390x-gnu@4.40.2':
+    resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==}
+    cpu: [s390x]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-gnu@4.40.2':
+    resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==}
+    cpu: [x64]
+    os: [linux]
+    libc: [glibc]
+
+  '@rollup/rollup-linux-x64-musl@4.40.2':
+    resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==}
+    cpu: [x64]
+    os: [linux]
+    libc: [musl]
+
+  '@rollup/rollup-win32-arm64-msvc@4.40.2':
+    resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==}
+    cpu: [arm64]
+    os: [win32]
+
+  '@rollup/rollup-win32-ia32-msvc@4.40.2':
+    resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==}
+    cpu: [ia32]
+    os: [win32]
+
+  '@rollup/rollup-win32-x64-msvc@4.40.2':
+    resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==}
+    cpu: [x64]
+    os: [win32]
+
+  '@types/estree@1.0.7':
+    resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
+
+  '@types/node@22.15.18':
+    resolution: {integrity: sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==}
+
+  '@types/resolve@1.20.2':
+    resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
+
+  acorn@8.14.1:
+    resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
+    engines: {node: '>=0.4.0'}
+    hasBin: true
+
+  buffer-from@1.1.2:
+    resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
+  commander@2.20.3:
+    resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
+
+  commondir@1.0.1:
+    resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
+
+  deepmerge@4.3.1:
+    resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+    engines: {node: '>=0.10.0'}
+
+  estree-walker@2.0.2:
+    resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+  fdir@6.4.4:
+    resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==}
+    peerDependencies:
+      picomatch: ^3 || ^4
+    peerDependenciesMeta:
+      picomatch:
+        optional: true
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  hasown@2.0.2:
+    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
+    engines: {node: '>= 0.4'}
+
+  is-core-module@2.16.1:
+    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
+    engines: {node: '>= 0.4'}
+
+  is-module@1.0.0:
+    resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+
+  is-reference@1.2.1:
+    resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
+
+  magic-string@0.30.17:
+    resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
+
+  path-parse@1.0.7:
+    resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+  picomatch@4.0.2:
+    resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
+    engines: {node: '>=12'}
+
+  randombytes@2.1.0:
+    resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
+
+  resolve@1.22.10:
+    resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
+    engines: {node: '>= 0.4'}
+    hasBin: true
+
+  rollup@4.40.2:
+    resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==}
+    engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+    hasBin: true
+
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+  serialize-javascript@6.0.2:
+    resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
+
+  smob@1.5.0:
+    resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
+
+  source-map-support@0.5.21:
+    resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
+  source-map@0.6.1:
+    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+    engines: {node: '>=0.10.0'}
+
+  supports-preserve-symlinks-flag@1.0.0:
+    resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+    engines: {node: '>= 0.4'}
+
+  terser@5.39.2:
+    resolution: {integrity: sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==}
+    engines: {node: '>=10'}
+    hasBin: true
+
+  typescript@5.8.3:
+    resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  undici-types@6.21.0:
+    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+snapshots:
+
+  '@jridgewell/gen-mapping@0.3.8':
+    dependencies:
+      '@jridgewell/set-array': 1.2.1
+      '@jridgewell/sourcemap-codec': 1.5.0
+      '@jridgewell/trace-mapping': 0.3.25
+
+  '@jridgewell/resolve-uri@3.1.2': {}
+
+  '@jridgewell/set-array@1.2.1': {}
+
+  '@jridgewell/source-map@0.3.6':
+    dependencies:
+      '@jridgewell/gen-mapping': 0.3.8
+      '@jridgewell/trace-mapping': 0.3.25
+
+  '@jridgewell/sourcemap-codec@1.5.0': {}
+
+  '@jridgewell/trace-mapping@0.3.25':
+    dependencies:
+      '@jridgewell/resolve-uri': 3.1.2
+      '@jridgewell/sourcemap-codec': 1.5.0
+
+  '@rollup/plugin-commonjs@28.0.3(rollup@4.40.2)':
+    dependencies:
+      '@rollup/pluginutils': 5.1.4(rollup@4.40.2)
+      commondir: 1.0.1
+      estree-walker: 2.0.2
+      fdir: 6.4.4(picomatch@4.0.2)
+      is-reference: 1.2.1
+      magic-string: 0.30.17
+      picomatch: 4.0.2
+    optionalDependencies:
+      rollup: 4.40.2
+
+  '@rollup/plugin-node-resolve@16.0.1(rollup@4.40.2)':
+    dependencies:
+      '@rollup/pluginutils': 5.1.4(rollup@4.40.2)
+      '@types/resolve': 1.20.2
+      deepmerge: 4.3.1
+      is-module: 1.0.0
+      resolve: 1.22.10
+    optionalDependencies:
+      rollup: 4.40.2
+
+  '@rollup/plugin-replace@6.0.2(rollup@4.40.2)':
+    dependencies:
+      '@rollup/pluginutils': 5.1.4(rollup@4.40.2)
+      magic-string: 0.30.17
+    optionalDependencies:
+      rollup: 4.40.2
+
+  '@rollup/plugin-terser@0.4.4(rollup@4.40.2)':
+    dependencies:
+      serialize-javascript: 6.0.2
+      smob: 1.5.0
+      terser: 5.39.2
+    optionalDependencies:
+      rollup: 4.40.2
+
+  '@rollup/pluginutils@5.1.4(rollup@4.40.2)':
+    dependencies:
+      '@types/estree': 1.0.7
+      estree-walker: 2.0.2
+      picomatch: 4.0.2
+    optionalDependencies:
+      rollup: 4.40.2
+
+  '@rollup/rollup-android-arm-eabi@4.40.2':
+    optional: true
+
+  '@rollup/rollup-android-arm64@4.40.2':
+    optional: true
+
+  '@rollup/rollup-darwin-arm64@4.40.2':
+    optional: true
+
+  '@rollup/rollup-darwin-x64@4.40.2':
+    optional: true
+
+  '@rollup/rollup-freebsd-arm64@4.40.2':
+    optional: true
+
+  '@rollup/rollup-freebsd-x64@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm-gnueabihf@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm-musleabihf@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-gnu@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-arm64-musl@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-loongarch64-gnu@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-gnu@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-riscv64-musl@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-s390x-gnu@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-x64-gnu@4.40.2':
+    optional: true
+
+  '@rollup/rollup-linux-x64-musl@4.40.2':
+    optional: true
+
+  '@rollup/rollup-win32-arm64-msvc@4.40.2':
+    optional: true
+
+  '@rollup/rollup-win32-ia32-msvc@4.40.2':
+    optional: true
+
+  '@rollup/rollup-win32-x64-msvc@4.40.2':
+    optional: true
+
+  '@types/estree@1.0.7': {}
+
+  '@types/node@22.15.18':
+    dependencies:
+      undici-types: 6.21.0
+
+  '@types/resolve@1.20.2': {}
+
+  acorn@8.14.1: {}
+
+  buffer-from@1.1.2: {}
+
+  commander@2.20.3: {}
+
+  commondir@1.0.1: {}
+
+  deepmerge@4.3.1: {}
+
+  estree-walker@2.0.2: {}
+
+  fdir@6.4.4(picomatch@4.0.2):
+    optionalDependencies:
+      picomatch: 4.0.2
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  hasown@2.0.2:
+    dependencies:
+      function-bind: 1.1.2
+
+  is-core-module@2.16.1:
+    dependencies:
+      hasown: 2.0.2
+
+  is-module@1.0.0: {}
+
+  is-reference@1.2.1:
+    dependencies:
+      '@types/estree': 1.0.7
+
+  magic-string@0.30.17:
+    dependencies:
+      '@jridgewell/sourcemap-codec': 1.5.0
+
+  path-parse@1.0.7: {}
+
+  picomatch@4.0.2: {}
+
+  randombytes@2.1.0:
+    dependencies:
+      safe-buffer: 5.2.1
+
+  resolve@1.22.10:
+    dependencies:
+      is-core-module: 2.16.1
+      path-parse: 1.0.7
+      supports-preserve-symlinks-flag: 1.0.0
+
+  rollup@4.40.2:
+    dependencies:
+      '@types/estree': 1.0.7
+    optionalDependencies:
+      '@rollup/rollup-android-arm-eabi': 4.40.2
+      '@rollup/rollup-android-arm64': 4.40.2
+      '@rollup/rollup-darwin-arm64': 4.40.2
+      '@rollup/rollup-darwin-x64': 4.40.2
+      '@rollup/rollup-freebsd-arm64': 4.40.2
+      '@rollup/rollup-freebsd-x64': 4.40.2
+      '@rollup/rollup-linux-arm-gnueabihf': 4.40.2
+      '@rollup/rollup-linux-arm-musleabihf': 4.40.2
+      '@rollup/rollup-linux-arm64-gnu': 4.40.2
+      '@rollup/rollup-linux-arm64-musl': 4.40.2
+      '@rollup/rollup-linux-loongarch64-gnu': 4.40.2
+      '@rollup/rollup-linux-powerpc64le-gnu': 4.40.2
+      '@rollup/rollup-linux-riscv64-gnu': 4.40.2
+      '@rollup/rollup-linux-riscv64-musl': 4.40.2
+      '@rollup/rollup-linux-s390x-gnu': 4.40.2
+      '@rollup/rollup-linux-x64-gnu': 4.40.2
+      '@rollup/rollup-linux-x64-musl': 4.40.2
+      '@rollup/rollup-win32-arm64-msvc': 4.40.2
+      '@rollup/rollup-win32-ia32-msvc': 4.40.2
+      '@rollup/rollup-win32-x64-msvc': 4.40.2
+      fsevents: 2.3.3
+
+  safe-buffer@5.2.1: {}
+
+  serialize-javascript@6.0.2:
+    dependencies:
+      randombytes: 2.1.0
+
+  smob@1.5.0: {}
+
+  source-map-support@0.5.21:
+    dependencies:
+      buffer-from: 1.1.2
+      source-map: 0.6.1
+
+  source-map@0.6.1: {}
+
+  supports-preserve-symlinks-flag@1.0.0: {}
+
+  terser@5.39.2:
+    dependencies:
+      '@jridgewell/source-map': 0.3.6
+      acorn: 8.14.1
+      commander: 2.20.3
+      source-map-support: 0.5.21
+
+  typescript@5.8.3: {}
+
+  undici-types@6.21.0: {}
diff --git a/tools/node_tools/pnpm-workspace.yaml b/tools/node_tools/pnpm-workspace.yaml
new file mode 100644
index 0000000..7d40629
--- /dev/null
+++ b/tools/node_tools/pnpm-workspace.yaml
@@ -0,0 +1 @@
+onlyBuiltDependencies: []
diff --git a/tools/node_tools/polygerrit_app_preprocessor/.gitignore b/tools/node_tools/polygerrit_app_preprocessor/.gitignore
deleted file mode 100644
index 6a3417b..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/out/
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
deleted file mode 100644
index 52f6a3e..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/BUILD
+++ /dev/null
@@ -1,83 +0,0 @@
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
-load("@npm//@bazel/concatjs:index.bzl", "ts_library")
-
-package(default_visibility = ["//visibility:public"])
-
-# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
-# We would prefer to not depend on @bazel/concatjs ...
-ts_library(
-    name = "preprocessor",
-    srcs = glob(["*.ts"]),
-    tsconfig = "tsconfig.json",
-    deps = [
-        "//tools/node_tools/utils",
-        "@tools_npm//:node_modules",
-    ],
-)
-
-#rollup_bundle - workaround for https://github.com/bazelbuild/rules_nodejs/issues/1522
-rollup_bundle(
-    name = "preprocessor-bundle",
-    args = [
-        "--bundleConfigAsCjs=true",
-    ],
-    config_file = "rollup.config.js",
-    entry_point = "preprocessor.ts",
-    format = "cjs",
-    rollup_bin = "//tools/node_tools:rollup-bin",
-    silent = True,
-    deps = [
-        ":preprocessor",
-        "@tools_npm//@rollup/plugin-node-resolve",
-    ],
-)
-
-rollup_bundle(
-    name = "links-updater-bundle",
-    args = [
-        "--bundleConfigAsCjs=true",
-    ],
-    config_file = "rollup.config.js",
-    entry_point = "links-updater.ts",
-    format = "cjs",
-    rollup_bin = "//tools/node_tools:rollup-bin",
-    silent = True,
-    deps = [
-        ":preprocessor",
-        "@tools_npm//@rollup/plugin-node-resolve",
-    ],
-)
-
-nodejs_binary(
-    name = "preprocessor-bin",
-    data = ["@tools_npm//:node_modules"],
-    entry_point = "preprocessor-bundle.js",
-)
-
-nodejs_binary(
-    name = "links-updater-bin",
-    data = ["@tools_npm//:node_modules"],
-    entry_point = "links-updater-bundle.js",
-)
-
-# TODO(dmfilippov): Find a better way to fix it (another workaround or submit a bug to
-# Bazel IJ plugin's) authors or to a ts_config rule author).
-# The following genrule is a workaround for a bazel intellij plugin's bug.
-# According to the documentation, the ts_config_rules section should be added
-# to a .bazelproject file if a project uses typescript
-# (https://ij.bazel.build/docs/dynamic-languages-typescript.html)
-# Unfortunately, this doesn't work. It seems, that the plugin expects some output from
-# the ts_config rule, but the rule doesn't produce any output.
-# To workaround the issue, the tsconfig_editor genrule was added. The genrule only copies
-# input file to the output file, but this is enough to make bazel IJ plugins works.
-# So, if you have any problem a typescript editor (import errors, types not found, etc...) -
-# try to build this rule from the command line
-# (bazel build tools/node_tools/node_modules/licenses:tsconfig_editor) and then sync bazel project
-# in intellij.
-genrule(
-    name = "tsconfig_editor",
-    srcs = ["tsconfig.json"],
-    outs = ["tsconfig_editor.json"],
-    cmd = "cp $< $@",
-)
diff --git a/tools/node_tools/polygerrit_app_preprocessor/README.md b/tools/node_tools/polygerrit_app_preprocessor/README.md
deleted file mode 100644
index 91f2a2b..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-This directory contains bazel rules and CLI tools to preprocess HTML and JS files before bundling.
-
-There are 2 different tools here:
-* links-updater (and update_links rule) - updates link in HTML files.
- Receives list of input and output files as well as a redirect.json file with information
- about redirects.
-* preprocessor (and prepare_for_bundling rule) - split each HTML files to a pair of one HTML
- and one JS files. The output HTML doesn't contain `<script>` tags and JS file contains
-  all scripts and imports from HTML file. For more details see source code.
diff --git a/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts b/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
deleted file mode 100644
index 9f872cc..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-import * as fs from "fs";
-import RewritingStream from "parse5-html-rewriting-stream";
-import * as dom5 from "dom5";
-import {HtmlFileUtils, RedirectsResolver} from "./utils";
-import {Node} from 'dom5';
-import {readMultilineParamFile} from "../utils/command-line";
-import { fail } from "../utils/common";
-import {JSONRedirects} from "./redirects";
-
-/** Update links in HTML file
- * input_output_param_files - is a list of paths; each path is placed on a separate line
- *   The first line is the path to a first input file (relative to process working directory)
- *   The second line is the path to the output file  (relative to process working directory)
- *   The next 2 lines describe the second file and so on.
- * redirectFile.json describes how to update links (see {@link JSONRedirects} for exact format)
- * Additionaly, update some test links (related to web-component-tester)
- */
-
-async function main() {
-  if (process.argv.length < 4) {
-    console.info("Usage:\n\tnode links_updater.js input_output_param_files redirectFile.json\n");
-    process.exit(1);
-  }
-
-  const jsonRedirects: JSONRedirects = JSON.parse(fs.readFileSync(process.argv[3], {encoding: "utf-8"})) as JSONRedirects;
-  const redirectsResolver = new RedirectsResolver(jsonRedirects.redirects);
-
-  const input = readMultilineParamFile(process.argv[2]);
-  const updater = new HtmlFileUpdater(redirectsResolver);
-  for(let i = 0; i < input.length; i += 2) {
-    const srcFile = input[i];
-    const targetFile = input[i + 1];
-    await updater.updateFile(srcFile, targetFile);
-  }
-}
-
-/** Update all links in HTML file based on redirects.
- * Additionally, update references to web-component-tester */
-class HtmlFileUpdater {
-  private static readonly Predicates = {
-    isScriptWithSrcTag: (node: Node) => node.tagName === "script" && dom5.hasAttribute(node, "src"),
-
-    isWebComponentTesterImport: (node: Node) => HtmlFileUpdater.Predicates.isScriptWithSrcTag(node) &&
-        dom5.getAttribute(node, "src")!.endsWith("/bower_components/web-component-tester/browser.js"),
-
-    isHtmlImport: (node: Node) => node.tagName === "link" && dom5.getAttribute(node, "rel") === "import" &&
-        dom5.hasAttribute(node, "href")
-  };
-
-  public constructor(private readonly redirectsResolver: RedirectsResolver) {
-  }
-
-  public async updateFile(srcFile: string, targetFile: string) {
-    const html = fs.readFileSync(srcFile, "utf-8");
-    const readStream = fs.createReadStream(srcFile, {encoding: "utf-8"});
-    const rewriterOutput = srcFile === targetFile ? targetFile + ".tmp" : targetFile;
-    const writeStream = fs.createWriteStream(rewriterOutput, {encoding: "utf-8"});
-    const rewriter = new RewritingStream();
-    (rewriter as any).tokenizer.preprocessor.bufferWaterline = Infinity;
-    rewriter.on("startTag", (tag: any) => {
-      if (HtmlFileUpdater.Predicates.isWebComponentTesterImport(tag)) {
-        dom5.setAttribute(tag, "src", "/components/wct-browser-legacy/browser.js");
-      } else if (HtmlFileUpdater.Predicates.isHtmlImport(tag)) {
-        this.updateRefAttribute(tag, srcFile, "href");
-      } else if (HtmlFileUpdater.Predicates.isScriptWithSrcTag(tag)) {
-        this.updateRefAttribute(tag, srcFile, "src");
-      } else {
-        const location = tag.sourceCodeLocation;
-        const raw = html.substring(location.startOffset, location.endOffset);
-        rewriter.emitRaw(raw);
-        return;
-      }
-      rewriter.emitStartTag(tag);
-    });
-    return new Promise<void>((resolve, reject) => {
-      writeStream.on("close", () => {
-        writeStream.close();
-        if (rewriterOutput !== targetFile) {
-          fs.renameSync(rewriterOutput, targetFile);
-        }
-        resolve();
-      });
-      readStream.pipe(rewriter).pipe(writeStream);
-    });
-  }
-
-  private getResolvedPath(parentHtml: string, href: string) {
-    const originalPath = '/' + HtmlFileUtils.getPathRelativeToRoot(parentHtml, href);
-
-    const resolvedInfo = this.redirectsResolver.resolve(originalPath, true);
-    if (!resolvedInfo.insideNodeModules && resolvedInfo.target === originalPath) {
-      return href;
-    }
-    if (resolvedInfo.insideNodeModules) {
-      return '/node_modules/' + resolvedInfo.target;
-    }
-    if (href.startsWith('/')) {
-      return resolvedInfo.target;
-    }
-    return HtmlFileUtils.getPathRelativeToRoot(parentHtml, resolvedInfo.target);
-  }
-
-  private updateRefAttribute(node: Node, parentHtml: string, attributeName: string) {
-    const ref = dom5.getAttribute(node, attributeName);
-    if (!ref) {
-      fail(`Internal error - ${node} in ${parentHtml} doesn't have attribute ${attributeName}`);
-    }
-    const newRef = this.getResolvedPath(parentHtml, ref);
-    if (newRef === ref) {
-      return;
-    }
-    dom5.setAttribute(node, attributeName, newRef);
-  }
-}
-
-main();
diff --git a/tools/node_tools/polygerrit_app_preprocessor/preprocessor.ts b/tools/node_tools/polygerrit_app_preprocessor/preprocessor.ts
deleted file mode 100644
index a886373..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/preprocessor.ts
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-import * as fs from "fs";
-import * as parse5 from "parse5";
-import * as dom5 from "dom5";
-import * as path from "path";
-import {Node} from 'dom5';
-import {fail, unexpectedSwitchValue} from "../utils/common";
-import {readMultilineParamFile} from "../utils/command-line";
-import {
-  HtmlSrcFilePath,
-  JsSrcFilePath,
-  HtmlTargetFilePath,
-  JsTargetFilePath,
-  FileUtils,
-  FilePath
-} from "../utils/file-utils";
-import {
-  AbsoluteWebPath,
-  getRelativeImport,
-  NodeModuleImportPath,
-  SrcWebSite
-} from "../utils/web-site-utils";
-
-/**
- * Update source code by moving all scripts out of HTML files.
- * Input:
- *   input_output_html_param_file - list of file paths, each file path on a separate line
- *      The first 3 line contains the path to the first input HTML file and 2 output paths
- *         (for HTML and JS files)
- *      The second 3 line contains paths for the second HTML file, and so on.
- *
- *   input_output_js_param_file - similar to input_output_html_param_file, but has only 2 lines
- *      per file (input JS file and output JS file)
- *
- *   input_web_root_path - path (in filesystem) which should be treated as a web-site root path.
-
- *    For each HTML file it creates 2 output files - HTML and JS file.
- *      HTML file contains everything from HTML input file, except <script> tags.
- *      JS file contains (in the same order, as in original HTML):
- *      - inline javascript code from HTML file
- *      - each <script src = "path/to/file.js" > from HTML is converted to
- *           import 'path/to/output/file.js'
- *        statement. Such import statement run all side-effects in file.js (i.e. it run all    #
- *        global code).
- *      - each <link rel="import" href = "path/to/file.html"> adds to .js file as
- *           import 'path/to/output/file.html.js
- *        i.e. instead of html, the .js script imports
- *    Because output JS keeps the order of imports, all global variables are
- *    initialized in a correct order (this is important for gerrit; it is impossible to use
- *    AMD modules here).
- */
-
-enum RefType {
-  Html,
-  InlineJS,
-  JSFile
-}
-
-type LinkOrScript = HtmlFileRef | HtmlFileNodeModuleRef | JsFileReference | JsFileNodeModuleReference | InlineJS;
-
-interface HtmlFileRef {
-  type: RefType.Html,
-  path: HtmlSrcFilePath;
-  isNodeModule: false;
-}
-
-interface HtmlFileNodeModuleRef {
-  type: RefType.Html,
-  path: NodeModuleImportPath;
-  isNodeModule: true;
-}
-
-
-function isHtmlFileRef(ref: LinkOrScript): ref is HtmlFileRef {
-  return ref.type === RefType.Html;
-}
-
-interface JsFileReference {
-  type: RefType.JSFile,
-  path: JsSrcFilePath;
-  isModule: boolean;
-  isNodeModule: false;
-}
-
-interface JsFileNodeModuleReference {
-  type: RefType.JSFile,
-  path: NodeModuleImportPath;
-  isModule: boolean;
-  isNodeModule: true;
-}
-
-interface InlineJS {
-  type: RefType.InlineJS,
-  isModule: boolean;
-  content: string;
-}
-
-interface HtmlOutputs {
-  html: HtmlTargetFilePath;
-  js: JsTargetFilePath;
-}
-
-interface JsOutputs {
-  js: JsTargetFilePath;
-}
-
-type HtmlSrcToOutputMap = Map<HtmlSrcFilePath, HtmlOutputs>;
-type JsSrcToOutputMap = Map<JsSrcFilePath, JsOutputs>;
-
-interface HtmlFileInfo {
-  src: HtmlSrcFilePath;
-  ast: parse5.AST.Document;
-  linksAndScripts: LinkOrScript[]
-}
-
-/** HtmlScriptAndLinksCollector walks through HTML file and collect
- * all links and inline scripts.
- */
-class HtmlScriptAndLinksCollector {
-  public constructor(private readonly webSite: SrcWebSite) {
-  }
-  public collect(src: HtmlSrcFilePath): HtmlFileInfo {
-    const ast = HtmlScriptAndLinksCollector.getAst(src);
-    const isHtmlImport = (node: Node) => node.tagName == "link" &&
-        dom5.getAttribute(node, "rel") == "import";
-    const isScriptTag = (node: Node) => node.tagName == "script";
-
-    const linksAndScripts: LinkOrScript[] = dom5
-      .nodeWalkAll(ast as Node, (node) => isHtmlImport(node) || isScriptTag(node))
-      .map((node) => {
-        if (isHtmlImport(node)) {
-          const href = dom5.getAttribute(node, "href");
-          if (!href) {
-            fail(`Tag <link rel="import...> in the file '${src}' doesn't have href attribute`);
-          }
-          if(this.webSite.isNodeModuleReference(href)) {
-            return {
-              type: RefType.Html,
-              path: this.webSite.getNodeModuleImport(href),
-              isNodeModule: true,
-            }
-          } else {
-            return {
-              type: RefType.Html,
-              path: this.webSite.resolveHtmlImport(src, href),
-              isNodeModule: false,
-            }
-          }
-        } else {
-          const isModule = dom5.getAttribute(node, "type") === "module";
-          if (dom5.hasAttribute(node, "src")) {
-            let srcPath = dom5.getAttribute(node, "src")!;
-            if(this.webSite.isNodeModuleReference(srcPath)) {
-              return {
-                type: RefType.JSFile,
-                isModule: isModule,
-                path: this.webSite.getNodeModuleImport(srcPath),
-                isNodeModule: true
-              };
-            } else {
-              return {
-                type: RefType.JSFile,
-                isModule: isModule,
-                path: this.webSite.resolveScriptSrc(src, srcPath),
-                isNodeModule: false
-              };
-            }
-          }
-          return {
-            type: RefType.InlineJS,
-            isModule: isModule,
-            content: dom5.getTextContent(node)
-          };
-        }
-      });
-    return {
-      src,
-      ast,
-      linksAndScripts
-    };
-  };
-
-  private static getAst(file: string): parse5.AST.Document {
-    const html = fs.readFileSync(file, "utf-8");
-    return parse5.parse(html, {locationInfo: true});
-  }
-
-}
-
-/** Generate js files */
-class ScriptGenerator {
-  public constructor(private readonly pathMapper: SrcToTargetPathMapper) {
-  }
-  public generateFromJs(src: JsSrcFilePath) {
-    FileUtils.copyFile(src, this.pathMapper.getJsTargetForJs(src));
-  }
-
-  public generateFromHtml(html: HtmlFileInfo) {
-    const content: string[] = [];
-    const src = html.src;
-    const targetJsFile: JsTargetFilePath = this.pathMapper.getJsTargetForHtml(src);
-    html.linksAndScripts.forEach((linkOrScript) => {
-      switch (linkOrScript.type) {
-        case RefType.Html:
-          if(linkOrScript.isNodeModule) {
-            const importPath = this.pathMapper.getJsTargetForHtmlInNodeModule(linkOrScript.path)
-            content.push(`import '${importPath}';`);
-          } else {
-            const importPath = this.pathMapper.getJsTargetForHtml(linkOrScript.path);
-            const htmlRelativePath = getRelativeImport(targetJsFile, importPath);
-            content.push(`import '${htmlRelativePath}';`);
-          }
-          break;
-        case RefType.JSFile:
-          if(linkOrScript.isNodeModule) {
-            content.push(`import '${linkOrScript.path}'`);
-          } else {
-            const importFromJs = this.pathMapper.getJsTargetForJs(linkOrScript.path);
-            const scriptRelativePath = getRelativeImport(targetJsFile, importFromJs);
-            content.push(`import '${scriptRelativePath}';`);
-          }
-          break;
-        case RefType.InlineJS:
-          content.push(linkOrScript.content);
-          break;
-        default:
-          unexpectedSwitchValue(linkOrScript);
-      }
-    });
-    FileUtils.writeContent(targetJsFile, content.join("\n"));
-  }
-}
-
-/** Generate html files*/
-class HtmlGenerator {
-  constructor(private readonly pathMapper: SrcToTargetPathMapper) {
-  }
-  public generateFromHtml(html: HtmlFileInfo) {
-    const ast = html.ast;
-    dom5.nodeWalkAll(ast as Node, (node) => node.tagName === "script")
-      .forEach((scriptNode) => dom5.remove(scriptNode));
-    const newContent = parse5.serialize(ast);
-    if(newContent.indexOf("<script") >= 0) {
-      fail(`Has content ${html.src}`);
-    }
-    FileUtils.writeContent(this.pathMapper.getHtmlTargetForHtml(html.src), newContent);
-  }
-}
-
-function readHtmlSrcToTargetMap(paramFile: string): HtmlSrcToOutputMap {
-  const htmlSrcToTarget: HtmlSrcToOutputMap = new Map();
-  const input = readMultilineParamFile(paramFile);
-  for(let i = 0; i < input.length; i += 3) {
-    const srcHtmlFile = path.resolve(input[i]) as HtmlSrcFilePath;
-    const targetHtmlFile = path.resolve(input[i + 1]) as HtmlTargetFilePath;
-    const targetJsFile = path.resolve(input[i + 2]) as JsTargetFilePath;
-    htmlSrcToTarget.set(srcHtmlFile, {
-      html: targetHtmlFile,
-      js: targetJsFile
-    });
-  }
-  return htmlSrcToTarget;
-}
-
-function readJsSrcToTargetMap(paramFile: string): JsSrcToOutputMap {
-  const jsSrcToTarget: JsSrcToOutputMap = new Map();
-  const input = readMultilineParamFile(paramFile);
-  for(let i = 0; i < input.length; i += 2) {
-    const srcJsFile = path.resolve(input[i]) as JsSrcFilePath;
-    const targetJsFile = path.resolve(input[i + 1]) as JsTargetFilePath;
-    jsSrcToTarget.set(srcJsFile as JsSrcFilePath, {
-      js: targetJsFile as JsTargetFilePath
-    });
-  }
-  return jsSrcToTarget;
-}
-
-class SrcToTargetPathMapper {
-  public constructor(
-      private readonly htmlSrcToTarget: HtmlSrcToOutputMap,
-      private readonly jsSrcToTarget: JsSrcToOutputMap) {
-  }
-  public getJsTargetForHtmlInNodeModule(file: NodeModuleImportPath): JsTargetFilePath {
-    return `${file}_gen.js` as JsTargetFilePath;
-  }
-
-  public getJsTargetForHtml(html: HtmlSrcFilePath): JsTargetFilePath {
-    return this.getHtmlOutputs(html).js;
-  }
-  public getHtmlTargetForHtml(html: HtmlSrcFilePath): HtmlTargetFilePath {
-    return this.getHtmlOutputs(html).html;
-  }
-  public getJsTargetForJs(js: JsSrcFilePath): JsTargetFilePath {
-    return this.getJsOutputs(js).js;
-  }
-
-  private getHtmlOutputs(html: HtmlSrcFilePath): HtmlOutputs {
-    if(!this.htmlSrcToTarget.has(html)) {
-      fail(`There are no outputs for the file '${html}'`);
-    }
-    return this.htmlSrcToTarget.get(html)!;
-  }
-  private getJsOutputs(js: JsSrcFilePath): JsOutputs {
-    if(!this.jsSrcToTarget.has(js)) {
-      fail(`There are no outputs for the file '${js}'`);
-    }
-    return this.jsSrcToTarget.get(js)!;
-  }
-}
-
-function main() {
-  if(process.argv.length < 5) {
-    const execFileName = path.basename(__filename);
-    fail(`Usage:\nnode ${execFileName} input_web_root_path input_output_html_param_file input_output_js_param_file\n`);
-  }
-
-  const srcWebSite = new SrcWebSite(path.resolve(process.argv[2]) as FilePath);
-  const htmlSrcToTarget: HtmlSrcToOutputMap = readHtmlSrcToTargetMap(process.argv[3]);
-  const jsSrcToTarget: JsSrcToOutputMap = readJsSrcToTargetMap(process.argv[4]);
-  const pathMapper = new SrcToTargetPathMapper(htmlSrcToTarget, jsSrcToTarget);
-
-  const scriptGenerator = new ScriptGenerator(pathMapper);
-  const htmlGenerator = new HtmlGenerator(pathMapper);
-  const scriptAndLinksCollector = new HtmlScriptAndLinksCollector(srcWebSite);
-
-  htmlSrcToTarget.forEach((targets, src) => {
-    const htmlFileInfo = scriptAndLinksCollector.collect(src);
-    scriptGenerator.generateFromHtml(htmlFileInfo);
-    htmlGenerator.generateFromHtml(htmlFileInfo);
-  });
-  jsSrcToTarget.forEach((targets, src) => {
-    scriptGenerator.generateFromJs(src);
-  });
-}
-
-main();
diff --git a/tools/node_tools/polygerrit_app_preprocessor/redirects.ts b/tools/node_tools/polygerrit_app_preprocessor/redirects.ts
deleted file mode 100644
index 0ccd78f..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/redirects.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-/** redirects.json schema*/
-export interface JSONRedirects {
-  /** Short text description. Do not used anywhere*/
-  description?: string;
-  /** List of redirects, from the highest to lower priority. */
-  redirects: Redirect[];
-}
-
-/** Redirect - describes one redirect.
- * Each link in the html file is converted to a path relative to site root
- * Redirect is applied, if converted link started with 'from'
- * */
-export interface Redirect {
-  /** from - path prefix. The '/' is added to the end of string if not present */
-  from: string;
-  /** New location - can be either other directory or node module*/
-  to: PathRedirect;
-}
-
-export type PathRedirect = RedirectToDir | RedirectToNodeModule;
-
-/** RedirectToDir - use another dir instead of original one*/
-export interface RedirectToDir {
-  /** New dir (relative to site root)*/
-  dir: string;
-  /** Redirects for files inside directory
-   * Key is the original relative path, value is the new relative path (relative to new dir) */
-  files?: { [name: string]: string }
-}
-
-export interface RedirectToNodeModule {
-  /** Import from this node module instead of directory*/
-  npm_module: string;
-  /** Redirects for files inside node module
-   * Key is the original relative path, value is the new relative path (relative to npm_module) */
-  files?: { [name: string]: string }
-}
-
-export function isRedirectToNodeModule(redirect: PathRedirect): redirect is RedirectToNodeModule {
-  return (redirect as RedirectToNodeModule).npm_module !== undefined;
-}
-
-export function isRedirectToDir(redirect: PathRedirect): redirect is RedirectToDir {
-  return (redirect as RedirectToDir).dir !== undefined;
-}
diff --git a/tools/node_tools/polygerrit_app_preprocessor/rollup.config.js b/tools/node_tools/polygerrit_app_preprocessor/rollup.config.js
deleted file mode 100644
index bf0f8511..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/rollup.config.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-export default {
-  external: ['fs', 'path', 'parse5', 'dom5', 'parse5-html-rewriting-stream'],
-  onwarn: warn => {
-    // Typescript adds helper methods for await and promise which look like
-    // var __awaiter = (this && this.__awaiter)
-    // It is safe to ignore this warning
-    if(warn.code === 'THIS_IS_UNDEFINED') { return; }
-    throw new Error(warn.message);
-  }
-};
diff --git a/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json b/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json
deleted file mode 100644
index 56e8b2d..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "compilerOptions": {
-    "target": "es2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021' or 'ESNEXT'. */
-    "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
-    "allowSyntheticDefaultImports": true,
-    "esModuleInterop": true,
-    "strict": true,
-    "moduleResolution": "node",
-    "outDir": "out"
-  },
-  "include": ["*.ts"]
-}
diff --git a/tools/node_tools/polygerrit_app_preprocessor/utils.ts b/tools/node_tools/polygerrit_app_preprocessor/utils.ts
deleted file mode 100644
index 4163d26..0000000
--- a/tools/node_tools/polygerrit_app_preprocessor/utils.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-import * as fs from "fs";
-import * as path from "path";
-import {FileUtils} from "../utils/file-utils";
-import {
-  Redirect,
-  isRedirectToNodeModule,
-  isRedirectToDir,
-  RedirectToNodeModule,
-  PathRedirect
-} from "./redirects";
-
-export class HtmlFileUtils {
-  public static getPathRelativeToRoot(parentHtml: string, fileHref: string): string {
-    if (fileHref.startsWith('/')) {
-      return fileHref.substring(1);
-    }
-    return path.join(path.dirname(parentHtml), fileHref);
-  }
-
-  public static getImportPathRelativeToParent(rootDir: string, parentFile: string, importPath: string) {
-    if (importPath.startsWith('/')) {
-      importPath = importPath.substr(1);
-    }
-    const parentDir = path.dirname(
-        path.resolve(path.join(rootDir, parentFile)));
-    const fullImportPath = path.resolve(path.join(rootDir, importPath));
-    const relativePath = path.relative(parentDir, fullImportPath);
-    return relativePath.startsWith('../') ?
-        relativePath : "./" + relativePath;
-  }
-}
-interface RedirectForFile {
-  to: PathRedirect;
-  pathToFile: string;
-}
-
-interface ResolvedPath {
-  target: string;
-  insideNodeModules: boolean;
-}
-
-/** RedirectsResolver based on the list of redirects, calculates
- *  new import path
- */
-export class RedirectsResolver {
-  public constructor(private readonly redirects: Redirect[]) {
-  }
-
-  /** resolve returns new path instead of pathRelativeToRoot; */
-  public resolve(pathRelativeToRoot: string, resolveNodeModules: boolean): ResolvedPath {
-    const redirect = this.findRedirect(pathRelativeToRoot);
-    if (!redirect) {
-      return {target: pathRelativeToRoot, insideNodeModules: false};
-    }
-    if (isRedirectToNodeModule(redirect.to)) {
-      return {
-        target: resolveNodeModules ? RedirectsResolver.resolveNodeModuleFile(redirect.to,
-            redirect.pathToFile) : pathRelativeToRoot,
-        insideNodeModules: resolveNodeModules
-      };
-    }
-    if (isRedirectToDir(redirect.to)) {
-      let newDir = redirect.to.dir;
-      if (!newDir.endsWith('/')) {
-        newDir = newDir + '/';
-      }
-      return {target: `${newDir}${redirect.pathToFile}`, insideNodeModules: false}
-    }
-    throw new Error(`Invalid redirect for path: ${pathRelativeToRoot}`);
-  }
-
-  private static resolveNodeModuleFile(npmRedirect: RedirectToNodeModule, pathToFile: string): string {
-    if(npmRedirect.files && npmRedirect.files[pathToFile]) {
-      pathToFile = npmRedirect.files[pathToFile];
-    }
-    return `${npmRedirect.npm_module}/${pathToFile}`;
-  }
-
-  private findRedirect(relativePathToRoot: string): RedirectForFile | undefined {
-    if(!relativePathToRoot.startsWith('/')) {
-      relativePathToRoot = '/' + relativePathToRoot;
-    }
-    for(const redirect of this.redirects) {
-      const normalizedFrom = redirect.from + (redirect.from.endsWith('/') ? '' : '/');
-      if(relativePathToRoot.startsWith(normalizedFrom)) {
-        return {
-          to: redirect.to,
-          pathToFile: relativePathToRoot.substring(normalizedFrom.length)
-        };
-      }
-    }
-    return undefined;
-  }
-}
diff --git a/tools/node_tools/utils/BUILD b/tools/node_tools/utils/BUILD
deleted file mode 100644
index 0a6e768..0000000
--- a/tools/node_tools/utils/BUILD
+++ /dev/null
@@ -1,14 +0,0 @@
-load("@npm//@bazel/concatjs:index.bzl", "ts_library")
-
-package(default_visibility = ["//visibility:public"])
-
-# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
-# We would prefer to not depend on @bazel/concatjs ...
-ts_library(
-    name = "utils",
-    srcs = glob(["*.ts"]),
-    tsconfig = "tsconfig.json",
-    deps = [
-        "@tools_npm//:node_modules",
-    ],
-)
diff --git a/tools/node_tools/utils/command-line.ts b/tools/node_tools/utils/command-line.ts
deleted file mode 100644
index 48e3c87..0000000
--- a/tools/node_tools/utils/command-line.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-import * as fs from "fs";
-
-export function readMultilineParamFile(path: string): string[] {
-  return fs.readFileSync(path, {encoding: 'utf-8'}).split(/\r?\n/).filter(f => f.length > 0);
-}
diff --git a/tools/node_tools/utils/common.ts b/tools/node_tools/utils/common.ts
deleted file mode 100644
index 9b976ba..0000000
--- a/tools/node_tools/utils/common.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-export function fail(message: string): never {
-  console.error(message);
-  process.exit(1);
-}
-
-export function unexpectedSwitchValue(_: never): never {
-  fail(`Internal error - unexpected switch value`);
-}
diff --git a/tools/node_tools/utils/file-utils.ts b/tools/node_tools/utils/file-utils.ts
deleted file mode 100644
index d8e1581..0000000
--- a/tools/node_tools/utils/file-utils.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-import * as path from "path";
-import * as fs from "fs";
-
-export type FilePath = string & {__filePath: undefined};
-export type TypedFilePath<T> = FilePath & { __type?: T, __typedFilePath: undefined };
-
-export enum FileType{
-  HtmlSrc,
-  HtmlTarget,
-  JsSrc,
-  JsTarget
-}
-
-export type HtmlSrcFilePath = TypedFilePath<FileType.HtmlSrc>;
-export type HtmlTargetFilePath = TypedFilePath<FileType.HtmlTarget>;
-export type JsSrcFilePath = TypedFilePath<FileType.JsSrc>;
-export type JsTargetFilePath = TypedFilePath<FileType.JsTarget>;
-
-export class FileUtils {
-  public static ensureDirExistsForFile(filePath: string) {
-    const dirName = path.dirname(filePath);
-    if (!fs.existsSync(dirName)) {
-      fs.mkdirSync(dirName, {recursive: true, mode: 0o744});
-    }
-  }
-
-  public static writeContent(file: string, content: string) {
-    if(fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) {
-      throw new Error(`Output file '${file}' is a symbolic link. Inplace update for links are not supported.`);
-    }
-    FileUtils.ensureDirExistsForFile(file);
-    fs.writeFileSync(file, content);
-  }
-
-  public static copyFile(src: string, dst: string) {
-    FileUtils.ensureDirExistsForFile(dst);
-    fs.copyFileSync(src, dst);
-  }
-}
diff --git a/tools/node_tools/utils/tsconfig.json b/tools/node_tools/utils/tsconfig.json
deleted file mode 100644
index 60cb677..0000000
--- a/tools/node_tools/utils/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "compilerOptions": {
-    "target": "es2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021' or 'ESNEXT'. */
-    "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
-    "allowSyntheticDefaultImports": true,
-    "esModuleInterop": true,
-    "strict": true,
-    "moduleResolution": "node",
-    "outDir": "../../../.ts-out/tools/utils" // Not used in bazel
-  },
-  "include": ["*.ts"]
-}
diff --git a/tools/node_tools/utils/web-site-utils.ts b/tools/node_tools/utils/web-site-utils.ts
deleted file mode 100644
index eb30ce4..0000000
--- a/tools/node_tools/utils/web-site-utils.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-import * as path from "path";
-import {fail} from "./common";
-import {FilePath, HtmlSrcFilePath, JsSrcFilePath} from "./file-utils";
-
-export type AbsoluteWebPath = string & { __absoluteWebPath: undefined };
-export type RelativeWebPath = string & { __relativeWebPath: undefined };
-export type WebPath = AbsoluteWebPath | RelativeWebPath;
-
-export type NodeModuleImportPath = string & {__nodeModuleImportPath: undefined};
-
-export type AbsoluteTypedWebPath<T> = AbsoluteWebPath & { __type?: T, __absoluteTypedFilePath: undefined };
-export type RelativeTypedWebPath<T> = RelativeWebPath & { __type?: T, __relativeTypedFilePath: undefined };
-
-export type TypedWebPath<T> = AbsoluteTypedWebPath<T> | RelativeTypedWebPath<T>;
-
-export function isAbsoluteWebPath(path: WebPath): path is AbsoluteWebPath {
-  return path.startsWith("/");
-}
-
-export function isRelativeWebPath(path: WebPath): path is RelativeWebPath {
-  return !isAbsoluteWebPath(path);
-}
-const node_modules_path_prefix = "/node_modules/";
-
-/** Contains method to resolve absolute and relative paths */
-export class SrcWebSite {
-  public constructor(private readonly webSiteRoot: FilePath) {
-  }
-
-  public getFilePath(webPath: AbsoluteWebPath): FilePath {
-    return path.resolve(this.webSiteRoot, webPath.substr(1)) as FilePath;
-  }
-
-  public getAbsoluteWebPathToFile(file: FilePath): AbsoluteWebPath {
-    const relativePath = path.relative(this.webSiteRoot, file);
-    if(relativePath.startsWith("..")) {
-      fail(`The file ${file} is not under webSiteRoot`);
-    }
-    return ("/" + relativePath) as AbsoluteWebPath;
-  }
-
-  public static resolveReference(from: AbsoluteWebPath, to: WebPath): AbsoluteWebPath {
-    return isAbsoluteWebPath(to) ? to : path.resolve(from, to) as AbsoluteWebPath;
-  }
-
-  public static getRelativePath(from: AbsoluteWebPath, to: AbsoluteWebPath): RelativeWebPath {
-    return path.relative(from, to) as RelativeWebPath;
-  }
-
-  public resolveHtmlImport(from: HtmlSrcFilePath, href: string): HtmlSrcFilePath {
-    return this.resolveReferenceToAbsPath(from, href) as HtmlSrcFilePath;
-
-  }
-  public resolveScriptSrc(from: HtmlSrcFilePath, src: string): JsSrcFilePath {
-    return this.resolveReferenceToAbsPath(from, src) as JsSrcFilePath;
-  }
-
-  public isNodeModuleReference(ref: string): boolean {
-    return ref.startsWith(node_modules_path_prefix);
-  }
-
-  public getNodeModuleImport(ref: string): NodeModuleImportPath {
-    if(!this.isNodeModuleReference(ref)) {
-      fail(`Internal error! ${ref} must be inside node modules`);
-    }
-    return ref.substr(node_modules_path_prefix.length) as NodeModuleImportPath;
-  }
-
-  private resolveReferenceToAbsPath(from: string, ref: string): string {
-    if(ref.startsWith("/")) {
-      const relativeToRootPath = ref.substr(1);
-      return path.resolve(this.webSiteRoot, relativeToRootPath);
-    }
-    return path.resolve(path.dirname(from), ref);
-  }
-}
-
-export function getRelativeImport(from: FilePath, ref: FilePath) {
-  const relativePath = path.relative(path.dirname(from), ref);
-  if(relativePath.startsWith("../")) {
-    return relativePath
-  } else {
-    return "./" + relativePath;
-  }
-}
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 0e24b81..a37eac1 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -288,45 +288,17 @@
   resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
   integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==
 
-"@types/node@*", "@types/node@^22.15.2":
-  version "22.15.18"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.18.tgz#2f8240f7e932f571c2d45f555ba0b6c3f7a75963"
-  integrity sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==
-  dependencies:
-    undici-types "~6.21.0"
-
 "@types/node@^10.1.0":
   version "10.17.60"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b"
   integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==
 
-"@types/parse5-html-rewriting-stream@^5.1.2":
-  version "5.1.2"
-  resolved "https://registry.yarnpkg.com/@types/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.2.tgz#919d5bbf69ef61e11d873e7195891c3811491a03"
-  integrity sha512-7CHY6QlayurvYRST5xatE/ipIueph5V+EW2xU12P0CsNucuwygnuiE4foYsdQUEkhnKrTU62KmikANPnoxiGrg==
+"@types/node@^22.15.2":
+  version "22.15.18"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.18.tgz#2f8240f7e932f571c2d45f555ba0b6c3f7a75963"
+  integrity sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==
   dependencies:
-    "@types/parse5-sax-parser" "*"
-
-"@types/parse5-sax-parser@*":
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/@types/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz#6b6fd46ef05c3c58392fc4d915f07969f7ce1a90"
-  integrity sha512-wR3hG33tMsLOdqtcJT0XuGi1Ik3dTP+/ZWN1AckYmEagO/SWoL99GPyv21ZI/FAHOtYWB02TEV3+Y5zqkC4L+Q==
-  dependencies:
-    parse5-sax-parser "*"
-
-"@types/parse5@^2.2.34":
-  version "2.2.34"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
-  integrity sha512-p3qOvaRsRpFyEmaS36RtLzpdxZZnmxGuT1GMgzkTtTJVFuEw7KFjGK83MFODpJExgX1bEzy9r0NYjMC3IMfi7w==
-  dependencies:
-    "@types/node" "*"
-
-"@types/parse5@^4.0.0":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-4.0.1.tgz#ec53c3f948f284be08454f622ba83765efdd06d7"
-  integrity sha512-CgJIkoNLclXUUg5cEo/SybyhxgVuKGqGcw8BPBpkKWX7wGcNfDlO2Ot+5O9u5E6k3NDg6RmJzm5w5N2prDIE8Q==
-  dependencies:
-    "@types/node" "*"
+    undici-types "~6.21.0"
 
 "@types/resolve@1.20.2":
   version "1.20.2"
@@ -343,11 +315,6 @@
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
-clone@^2.1.0:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
-  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
-
 commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
@@ -363,20 +330,6 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
   integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
-dom5@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/dom5/-/dom5-3.0.1.tgz#cdfc7331f376e284bf379e6ea054afc136702944"
-  integrity sha512-JPFiouQIr16VQ4dX6i0+Hpbg3H2bMKPmZ+WZgBOSSvOPx9QHwwY8sPzeM2baUtViESYto6wC2nuZOMC/6gulcA==
-  dependencies:
-    "@types/parse5" "^2.2.34"
-    clone "^2.1.0"
-    parse5 "^4.0.0"
-
-entities@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51"
-  integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==
-
 estree-walker@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
@@ -440,45 +393,6 @@
   dependencies:
     "@jridgewell/sourcemap-codec" "^1.5.0"
 
-parse5-html-rewriting-stream@^5.1.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#fc18570ba0d09b5091250956d1c3f716ef0a07b7"
-  integrity sha512-rbXBeMlJ3pk3tKxLKAUaqvQTZM5KTohXmZvYEv2gU9sQC70w65BxPsh3PVVnwiVNCnNYDtNZRqCKmiMlfdG07Q==
-  dependencies:
-    parse5 "^5.1.1"
-    parse5-sax-parser "^5.1.1"
-
-parse5-sax-parser@*:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz#4c05064254f0488676aca75fb39ca069ec96dee5"
-  integrity sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==
-  dependencies:
-    parse5 "^7.0.0"
-
-parse5-sax-parser@^5.1.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-5.1.1.tgz#02834a9d08b23ea2d99584841c38be09d5247a15"
-  integrity sha512-9HIh6zd7bF1NJe95LPCUC311CekdOi55R+HWXNCsGY6053DWaMijVKOv1oPvdvPTvFicifZyimBVJ6/qvG039Q==
-  dependencies:
-    parse5 "^5.1.1"
-
-parse5@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
-  integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
-
-parse5@^5.1.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
-  integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
-
-parse5@^7.0.0:
-  version "7.3.0"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
-  integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
-  dependencies:
-    entities "^6.0.0"
-
 path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
diff --git a/tools/nongoogle.toml b/tools/nongoogle.toml
index e413547..121f222 100644
--- a/tools/nongoogle.toml
+++ b/tools/nongoogle.toml
@@ -3,14 +3,14 @@
 
 [versions]
 autoCommon = "1.2.2"
-autoFactory = "1.0.1"
-autoValue= "1.11.0"
+autoFactory = "1.1.0"
+autoValue= "1.11.1"
 flogger = "0.8"
 # Also update the GUAVA version in java/com/google/gerrit/extensions/BUILD
 # to keep Bazel and extension dependencies in sync.
 guava = "33.5.0-jre"
 guice = "6.0.0"
-lucene = "10.4.0"
+lucene = "10.5.1"
 slf4j = "2.0.18"
 sshd = "2.19.0"
 truth = "1.4.4"
@@ -32,9 +32,9 @@
 guava = { module = "com.google.guava:guava", version.ref = "guava" }
 guava-testlib = { module = "com.google.guava:guava-testlib", version.ref = "guava" }
 guice-assistedinject = { module = "com.google.inject.extensions:guice-assistedinject", version.ref = "guice" }
-guice-library = { module = "com.google.inject:guice", version.ref = "guice" }
+guice-library = { module = "com.google.inject:guice", version.ref = "guice", classifier = "classes" }
 guice-servlet = { module = "com.google.inject.extensions:guice-servlet", version.ref = "guice" }
-h2 = { module = "com.h2database:h2", version = "2.4.240" }
+h2 = { module = "com.h2database:h2", version = "2.5.250" }
 hamcrest = { module = "org.hamcrest:hamcrest", version = "3.0" }
 impl-log4j = { module = "org.slf4j:slf4j-reload4j", version.ref = "slf4j" }
 j2objc = { module = "com.google.j2objc:j2objc-annotations", version = "1.1" }
@@ -49,10 +49,10 @@
 lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" }
 lucene-misc = { module = "org.apache.lucene:lucene-misc", version.ref = "lucene" }
 lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" }
-mina-core = { module = "org.apache.mina:mina-core", version = "2.2.4" }
+mina-core = { module = "org.apache.mina:mina-core", version = "2.2.9" }
 nekohtml = { module = "net.sourceforge.nekohtml:nekohtml", version = "1.9.10" }
 openid-consumer = { module = "org.openid4java:openid4java", version = "1.0.0" }
-protobuf-java = { module = "com.google.protobuf:protobuf-java", version = "4.33.4" }
+protobuf-java = { module = "com.google.protobuf:protobuf-java", version = "4.36.1" }
 soy = { module = "com.google.template:soy", version = "2024-01-30" }
 sshd-mina = { module = "org.apache.sshd:sshd-mina", version.ref = "sshd" }
 sshd-osgi = { module = "org.apache.sshd:sshd-osgi", version.ref = "sshd" }
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 8c2386e..1bfa7e20 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -31,12 +31,9 @@
 
 # Set several flags related to specifying the platform, toolchain and java
 # properties.
-build:remote_shared --crosstool_top=@rbe_autoconfig//cc:toolchain
-build:remote_shared --extra_toolchains=@rbe_autoconfig//config:cc-toolchain
-build:remote_shared --extra_execution_platforms=@rbe_autoconfig//config:platform
-build:remote_shared --host_platform=@rbe_autoconfig//config:platform
-build:remote_shared --platforms=@rbe_autoconfig//config:platform
-build:remote_shared --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
+build:remote_shared --extra_execution_platforms=//tools/remote:platform
+build:remote_shared --host_platform=//tools/remote:platform
+build:remote_shared --platforms=//tools/remote:platform
 
 # Set various strategies so that all actions execute remotely. Mixing remote
 # and local execution will lead to errors unless the toolchain and remote
diff --git a/tools/remote/BUILD b/tools/remote/BUILD
new file mode 100644
index 0000000..57dd4a0
--- /dev/null
+++ b/tools/remote/BUILD
@@ -0,0 +1,12 @@
+platform(
+    name = "platform",
+    constraint_values = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:linux",
+    ],
+    exec_properties = {
+        "OSFamily": "Linux",
+        "container-image": "docker://gcr.io/bazel-public/ubuntu2404@sha256:57bbaa84bec679736c53dcd1d326e8f835b3b9ce3e36c12a3c12a07eb59177d3",
+    },
+    visibility = ["//visibility:public"],
+)
diff --git a/tools/repos.MODULE.bazel b/tools/repos.MODULE.bazel
index c2ac5af..bc38f21 100644
--- a/tools/repos.MODULE.bazel
+++ b/tools/repos.MODULE.bazel
@@ -13,3 +13,9 @@
     name = "java-prettify",
     path = "modules/java-prettify",
 )
+
+# Gitiles source repository consumed from git submodule.
+local_repository(
+    name = "gitiles",
+    path = "modules/gitiles",
+)
diff --git a/tools/run_gjf.sh b/tools/run_gjf.sh
deleted file mode 100755
index e5962a0..0000000
--- a/tools/run_gjf.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#
-# 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.
-
-NEW_SCRIPT=$(dirname $0)/gjf.sh
-VERSION=${1:-""}
-
-echo
-echo "WARNING:"
-echo "  Calling $0 is deprecated and $0 will be removed in 3.13.
-echo "  Call \"$NEW_SCRIPT run $VERSION\" instead"."
-echo
-
-$NEW_SCRIPT run $VERSION
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
deleted file mode 100755
index 61a02db..0000000
--- a/tools/setup_gjf.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/bin/bash
-#
-# 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.
-
-NEW_SCRIPT=$(dirname $0)/gjf.sh
-VERSION=${1:-""}
-
-echo
-echo "WARNING:"
-echo "  Calling $0 is deprecated and $0 will be removed in 3.13.
-echo "  Call \"$NEW_SCRIPT setup $VERSION\" instead"."
-echo
-
-$NEW_SCRIPT setup $VERSION
-
diff --git a/tools/util.py b/tools/util.py
index 4c7c3b6..c77f353 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -35,8 +35,7 @@
 
     A special case is supported, when prefix neither exists in
     REPO_ROOTS, no in redirects set: the url is returned as is.
-    This enables plugins to pass custom maven_repository URL as is
-    directly to maven_jar().
+    This enables plugins to pass custom maven_repository URL.
 
     Returns a resolved path for Maven artifact.
     """
diff --git a/web-dev-server.config.mjs b/web-dev-server.config.mjs
index 8820ecd..463c471 100644
--- a/web-dev-server.config.mjs
+++ b/web-dev-server.config.mjs
@@ -25,15 +25,7 @@
     // polygerrit-ui/app/rules.bzl
     async (context, next) => {
 
-      if ( context.url.includes("/bower_components/webcomponentsjs/webcomponents-loader.js") ) {
-        context.response.redirect("/node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js");
-      } else if (context.url.includes("/bower_components/webcomponentsjs/bundles/")) {
-        const bundlePath = context.url.replace(
-          "/bower_components/webcomponentsjs/bundles/",
-          "/node_modules/@webcomponents/webcomponentsjs/bundles/"
-        );
-        context.response.redirect(bundlePath);
-      } else if ( context.url.startsWith( "/fonts/" ) ) {
+      if ( context.url.startsWith( "/fonts/" ) ) {
         const fontFile = path.join( "lib/fonts", path.basename(context.url) );
         context.body = fs.createReadStream( fontFile );
       }
diff --git a/webapp/BUILD b/webapp/BUILD
index f907be9b..8ea82d3 100644
--- a/webapp/BUILD
+++ b/webapp/BUILD
@@ -1,4 +1,4 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
+load("@com_googlesource_gerrit_bazlets//tools:genrule2.bzl", "genrule2")
 
 genrule2(
     name = "assets",