diff --git a/.github/benchmark/benchmark.jmx b/.github/benchmark/benchmark.jmx
new file mode 100644
index 0000000000..97aecff820
--- /dev/null
+++ b/.github/benchmark/benchmark.jmx
@@ -0,0 +1,327 @@
+
+
+
+
+
+ Parametrized LDAP benchmark. Admin bind is cached once per thread (Once Only Controller, labelled ADMIN_CONNECT, excluded from metrics). Data ops (ADD/SEARCH/COMPARE/MODIFY/DELETE/READD) reuse the cached admin connection. Entries use mail as the naming/searchable attribute (equality-indexed by default on BOTH OpenDJ and OpenLDAP); only mail + objectClass (both indexed on both servers) are stored, keeping the write cost symmetric. Every created value is unique: ADD uses a per-iteration counter, READD uses a UUID. The measured user authentication is a single bind/unbind (test=sbind, own connection) after MODIFY has set the userPassword.
+ false
+ true
+ false
+
+
+
+
+
+
+
+ continue
+
+ false
+ -1
+
+ ${__P(threads,200)}
+ ${__P(rampup,0)}
+ true
+ ${__P(duration,300)}
+
+
+
+
+
+ 1
+
+ 1
+ iter
+
+ true
+ false
+
+
+
+
+
+
+
+ ${__P(host,localhost)}
+ ${__P(port,1389)}
+ ou=People,${__P(basedn,dc=example\,dc=com)}
+ 2
+
+
+
+ false
+ false
+ 60000
+ false
+ false
+ ${__P(adminbinddn,cn=Directory Manager)}
+ ${__P(adminbindpw,password)}
+
+
+
+
+ bind
+
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ false
+ false
+
+ false
+ false
+
+
+
+
+
+
+ add
+ mail=u_${__threadNum}_${iter}@test.com
+
+
+
+ mail
+ u_${__threadNum}_${iter}@test.com
+ =
+
+
+ objectClass
+ top
+ =
+
+
+ objectClass
+ locality
+ =
+
+
+ objectClass
+ extensibleObject
+ =
+
+
+
+
+
+
+
+
+
+
+
+ 2
+ 0
+ 0
+ mail:dn:objectClass
+ false
+ false
+
+ false
+ false
+
+
+
+
+
+
+ search
+
+ (mail=u_${__threadNum}_${iter}@test.com)
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ false
+ false
+
+ false
+ false
+
+
+ mail=u_${__threadNum}_${iter}@test.com
+ mail=u_${__threadNum}_${iter}@test.com
+
+
+ compare
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ false
+ false
+
+ false
+ false
+
+
+
+
+
+
+ modify
+ mail=u_${__threadNum}_${iter}@test.com
+
+
+
+ description
+ mod_${__threadNum}_${iter}
+ replace
+ =
+
+
+ userPassword
+ ${__P(benchpw,benchPass1)}
+ replace
+ =
+
+
+
+
+
+
+
+
+ ${__P(host,localhost)}
+ ${__P(port,1389)}
+ ou=People,${__P(basedn,dc=example\,dc=com)}
+ 2
+
+
+
+ false
+ false
+ 60000
+ false
+ false
+ mail=u_${__threadNum}_${iter}@test.com,ou=People,${__P(basedn,dc=example\,dc=com)}
+ ${__P(benchpw,benchPass1)}
+
+
+
+
+ sbind
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ false
+ false
+
+ false
+ false
+
+
+
+
+
+
+ delete
+ mail=u_${__threadNum}_${iter}@test.com
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ false
+ false
+
+ false
+ false
+
+
+
+
+
+
+ add
+ mail=u_${__UUID}@test.com
+
+
+
+ mail
+ u_${__UUID}@test.com
+ =
+
+
+ objectClass
+ top
+ =
+
+
+ objectClass
+ locality
+ =
+
+
+ objectClass
+ extensibleObject
+ =
+
+
+
+
+
+
+
+
+
diff --git a/.github/benchmark/compare-opendj.sh b/.github/benchmark/compare-opendj.sh
new file mode 100644
index 0000000000..1825921229
--- /dev/null
+++ b/.github/benchmark/compare-opendj.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# The contents of this file are subject to the terms of the Common Development and
+# Distribution License (the License). You may not use this file except in compliance with the
+# License.
+#
+# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+# specific language governing permission and limitations under the License.
+#
+# When distributing Covered Software, include this CDDL Header Notice in each file and include
+# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+# Header, with the fields enclosed by brackets [] replaced by your own identifying
+# information: "Portions copyright [year] [name of copyright owner]".
+#
+# Copyright 2026 3A Systems, LLC.
+#
+# Compare two OpenDJ Docker images with the LDAP benchmark (.github/benchmark/benchmark.jmx) and
+# append the comparison report to $GITHUB_STEP_SUMMARY. Both sides are OpenDJ, so no per-server
+# hashing/index setup is needed (identical product => identical default password scheme).
+#
+# Usage:
+# compare-opendj.sh
+# Env: THREADS (default 200), DURATION (default 150), JMETER_VERSION (default 5.6.3).
+set -euo pipefail
+
+A_NAME="${1:?server A name required}"
+A_IMAGE="${2:?server A image required}"
+B_NAME="${3:?server B name required}"
+B_IMAGE="${4:?server B image required}"
+
+THREADS="${THREADS:-200}"
+DURATION="${DURATION:-150}"
+JMETER_VERSION="${JMETER_VERSION:-5.6.3}"
+BASEDN="dc=example,dc=com"
+BENCHPW="benchPass1"
+HERE="$(cd "$(dirname "$0")" && pwd)" # .github/benchmark
+
+# ---------------------------------------------------------------- dependencies
+if ! command -v ldapsearch >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq ldap-utils jq
+fi
+JM="$HOME/jmeter/apache-jmeter-$JMETER_VERSION/bin/jmeter"
+if [ ! -x "$JM" ]; then
+ mkdir -p "$HOME/jmeter"
+ curl -fsSL "https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-$JMETER_VERSION.tgz" -o /tmp/jmeter.tgz
+ tar -xzf /tmp/jmeter.tgz -C "$HOME/jmeter"
+fi
+
+wait_dj() { # poll OpenDJ readiness on localhost:1389
+ for _ in $(seq 1 90); do
+ ldapsearch -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \
+ -b "$BASEDN" -s base dn >/dev/null 2>&1 && return 0
+ sleep 2
+ done
+ return 1
+}
+
+# bench_one -> prints the captured server version (stdout only)
+bench_one() {
+ local image="$1" out="$2" ver=""
+ docker rm -f opendj-bench >/dev/null 2>&1 || true
+ if docker run -d --name opendj-bench -p 1389:1389 \
+ -e ROOT_PASSWORD=password -e BASE_DN="$BASEDN" -e ADD_BASE_ENTRY=--addBaseEntry \
+ "$image" >/dev/null 2>&1; then
+ wait_dj || echo "WARN: $image not ready in time" >&2
+ ldapadd -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \
+ -f "$HERE/people.ldif" >/dev/null 2>&1 || true
+ ver="$( { ldapsearch -x -LLL -H ldap://localhost:1389 -D 'cn=Directory Manager' -w password \
+ -b '' -s base fullVendorVersion 2>/dev/null || true; } | sed -n 's/^fullVendorVersion: //p')"
+ rm -rf "$out" "$out.jtl"
+ HEAP="-Xms1g -Xmx2g" "$JM" -n -t "$HERE/benchmark.jmx" \
+ -Jhost=localhost -Jport=1389 -Jbasedn="$BASEDN" \
+ -Jadminbinddn="cn=Directory Manager" -Jadminbindpw=password -Jbenchpw="$BENCHPW" \
+ -Jthreads="$THREADS" -Jduration="$DURATION" -Jrampup=0 \
+ -Jjmeter.reportgenerator.sample_filter='^(?!ADMIN_CONNECT).*' \
+ -l "$out.jtl" -e -o "$out" > "$out.jmeter.out" 2>&1 || true
+ docker logs opendj-bench > "$out.docker.log" 2>&1 || true
+ # surface distinct error messages to the step log (stderr; stdout carries the version)
+ if [ -f "$out.jtl" ]; then
+ local errs
+ errs="$(awk -F',' 'NR==1{for(i=1;i<=NF;i++)h[$i]=i; next}
+ tolower($h["success"])=="false"{print $h["label"]" | "$h["responseCode"]" | "$h["responseMessage"]}' \
+ "$out.jtl" 2>/dev/null | sort | uniq -c | sort -rn | head -10)"
+ [ -z "$errs" ] || { echo "[$out] errors (count | op | code | message):" >&2; echo "$errs" >&2; }
+ fi
+ else
+ echo "ERROR: failed to start image $image" >&2
+ fi
+ docker rm -f opendj-bench >/dev/null 2>&1 || true
+ [ -n "$ver" ] || ver="$image"
+ printf '%s' "$ver"
+}
+
+echo "Benchmarking ${A_NAME} (${A_IMAGE}) @ ${THREADS} threads / ${DURATION}s ..."
+A_VER="$(bench_one "$A_IMAGE" a)"
+echo "Benchmarking ${B_NAME} (${B_IMAGE}) @ ${THREADS} threads / ${DURATION}s ..."
+B_VER="$(bench_one "$B_IMAGE" b)"
+
+{
+ bash "$HERE/summary.sh" \
+ "$A_NAME" a/statistics.json "$A_VER" "$A_IMAGE" \
+ "$B_NAME" b/statistics.json "$B_VER" "$B_IMAGE"
+ echo ""
+ echo "### Notes"
+ echo ""
+ echo "- **${A_NAME}** = freshly built image; **${B_NAME}** = latest released image. Both are"
+ echo " OpenDJ, so they share the same default password storage scheme (hashing parity is automatic)."
+ echo "- The admin connection bind (\`ADMIN_CONNECT\`) is cached per thread and excluded; \`BIND\` is"
+ echo " the measured user authentication (\`test=sbind\`, single bind/unbind)."
+} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
diff --git a/.github/benchmark/people.ldif b/.github/benchmark/people.ldif
new file mode 100644
index 0000000000..d6a350093e
--- /dev/null
+++ b/.github/benchmark/people.ldif
@@ -0,0 +1,17 @@
+# The contents of this file are subject to the terms of the Common Development and
+# Distribution License (the License). You may not use this file except in compliance with the
+# License.
+#
+# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+# specific language governing permission and limitations under the License.
+#
+# When distributing Covered Software, include this CDDL Header Notice in each file and include
+# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+# Header, with the fields enclosed by brackets [] replaced by your own identifying
+# information: "Portions copyright [year] [name of copyright owner]".
+#
+# Copyright 2026 3A Systems, LLC.
+dn: ou=People,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: People
diff --git a/.github/benchmark/summary.sh b/.github/benchmark/summary.sh
new file mode 100644
index 0000000000..9ea5ce4fb1
--- /dev/null
+++ b/.github/benchmark/summary.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# The contents of this file are subject to the terms of the Common Development and
+# Distribution License (the License). You may not use this file except in compliance with the
+# License.
+#
+# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+# specific language governing permission and limitations under the License.
+#
+# When distributing Covered Software, include this CDDL Header Notice in each file and include
+# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+# Header, with the fields enclosed by brackets [] replaced by your own identifying
+# information: "Portions copyright [year] [name of copyright owner]".
+#
+# Copyright 2026 3A Systems, LLC.
+#
+# Render an LDAP benchmark comparison report (versions + per-operation table + QuickChart charts)
+# for two servers A and B to stdout โ intended to be appended to $GITHUB_STEP_SUMMARY. The report
+# is generic: server names, versions and images are all parameters, and benchmark-specific notes
+# are NOT included here (append them separately, e.g. `cat notes.md >> $GITHUB_STEP_SUMMARY`).
+#
+# Usage:
+# summary.sh \
+#
+#
+# The admin connection bind is labelled ADMIN_CONNECT in the plan and is intentionally
+# skipped here, so it never pollutes the per-operation comparison.
+set -euo pipefail
+
+A_NAME="${1:?server A name required}"
+A_JSON="${2:?server A statistics.json required}"
+A_VER="${3:-unknown}"
+A_IMG="${4:-}"
+B_NAME="${5:?server B name required}"
+B_JSON="${6:?server B statistics.json required}"
+B_VER="${7:-unknown}"
+B_IMG="${8:-}"
+
+A_COLOR="#4e79a7" # blue = server A
+B_COLOR="#f28e2b" # orange = server B
+
+# Operations to compare, in workflow order. ADMIN_CONNECT and Total are excluded.
+OPS=("ADD" "SEARCH" "COMPARE" "MODIFY" "BIND" "DELETE" "READD")
+
+# m