From b9489f170636cee8af4c5edb824a34e21cc63195 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Fri, 30 May 2025 09:18:41 +0000
Subject: [PATCH] [#462] RFC5805 Lightweight Directory Access Protocol (LDAP) Transactions (#469)

---
 opendj-server-legacy/src/main/java/org/opends/server/api/ClientConnection.java                                                                 |   52 +
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendDeleteOperation.java                             |   24 
 opendj-server-legacy/src/main/java/org/opends/server/extensions/StartTransactionExtendedOperation.java                                         |   66 ++
 opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedRequest.java                                            |  101 +++
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java                             |   24 
 opendj-core/src/main/java/org/forgerock/opendj/ldap/controls/TransactionSpecificationRequestControl.java                                       |   82 +++
 opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/Rfc5808TestCase.java                                                        |  260 +++++++++
 opendj-server-legacy/resource/schema/02-config.ldif                                                                                            |   11 
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyDNOperation.java                           |   24 
 opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java                                |   25 
 opendj-server-legacy/src/main/java/org/opends/server/extensions/TraditionalWorkerThread.java                                                   |   29 +
 opendj-server-legacy/src/main/java/org/opends/server/types/AbstractOperation.java                                                              |   14 
 opendj-server-legacy/resource/config/config.ldif                                                                                               |   17 
 opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedResult.java                                               |  168 ++++++
 opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-standards.adoc                                                                   |    9 
 opendj-server-legacy/src/main/java/org/opends/server/types/operation/RollbackOperation.java                                                    |   26 
 opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/StartTransactionExtendedOperationHandlerConfiguration.xml |   55 ++
 opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/AbortedTransactionExtendedResult.java                                           |   94 +++
 opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedResult.java                                             |   96 +++
 opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/EndTransactionExtendedOperationHandlerConfiguration.xml   |   75 ++
 opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedRequest.java                                              |  182 ++++++
 opendj-server-legacy/src/main/java/org/opends/server/extensions/EndTransactionExtendedOperation.java                                           |  131 ++++
 22 files changed, 1,531 insertions(+), 34 deletions(-)

diff --git a/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/AbortedTransactionExtendedResult.java b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/AbortedTransactionExtendedResult.java
new file mode 100644
index 0000000..35c4981
--- /dev/null
+++ b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/AbortedTransactionExtendedResult.java
@@ -0,0 +1,94 @@
+/*
+ * 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 2025 3A Systems, LLC
+ */
+package com.forgerock.opendj.ldap.extensions;
+
+import org.forgerock.opendj.io.ASN1;
+import org.forgerock.opendj.io.ASN1Writer;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.ByteStringBuilder;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.responses.AbstractExtendedResult;
+import org.forgerock.util.Reject;
+
+import java.io.IOException;
+
+/*
+    The Aborted Transaction Notice is an Unsolicited Notification message
+   where the responseName is 1.3.6.1.1.21.4 and responseValue is present
+   and contains a transaction identifier.
+ */
+public class AbortedTransactionExtendedResult extends AbstractExtendedResult<AbortedTransactionExtendedResult> {
+    @Override
+    public String getOID() {
+        return "1.3.6.1.1.21.4";
+    }
+
+    private AbortedTransactionExtendedResult(final ResultCode resultCode) {
+        super(resultCode);
+    }
+
+    public static AbortedTransactionExtendedResult newResult(final ResultCode resultCode) {
+        Reject.ifNull(resultCode);
+        return new AbortedTransactionExtendedResult(resultCode);
+    }
+
+    private String transactionID = null;
+
+    public AbortedTransactionExtendedResult setTransactionID(final String transactionID) {
+        this.transactionID = transactionID;
+        return this;
+    }
+
+    public String getTransactionID() {
+        return transactionID;
+    }
+
+    @Override
+    public ByteString getValue() {
+        final ByteStringBuilder buffer = new ByteStringBuilder();
+        final ASN1Writer writer = ASN1.getWriter(buffer);
+        try {
+            writer.writeOctetString(transactionID);
+        } catch (final IOException ioe) {
+            throw new RuntimeException(ioe);
+        }
+        return buffer.toByteString();
+    }
+
+    @Override
+    public boolean hasValue() {
+        return true;
+    }
+
+    @Override
+    public String toString() {
+        return "AbortedTransactionExtendedResult(resultCode=" +
+                getResultCode() +
+                ", matchedDN=" +
+                getMatchedDN() +
+                ", diagnosticMessage=" +
+                getDiagnosticMessage() +
+                ", referrals=" +
+                getReferralURIs() +
+                ", responseName=" +
+                getOID() +
+                ", transactionID=" +
+                transactionID +
+                ", controls=" +
+                getControls() +
+                ")";
+    }
+}
diff --git a/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedRequest.java b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedRequest.java
new file mode 100644
index 0000000..5b898a1
--- /dev/null
+++ b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedRequest.java
@@ -0,0 +1,182 @@
+/*
+ * 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 2025 3A Systems, LLC
+ */
+package com.forgerock.opendj.ldap.extensions;
+
+import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.opendj.io.ASN1;
+import org.forgerock.opendj.io.ASN1Reader;
+import org.forgerock.opendj.io.ASN1Writer;
+import org.forgerock.opendj.ldap.*;
+import org.forgerock.opendj.ldap.controls.Control;
+import org.forgerock.opendj.ldap.requests.*;
+import org.forgerock.opendj.ldap.responses.AbstractExtendedResultDecoder;
+import org.forgerock.opendj.ldap.responses.ExtendedResult;
+import org.forgerock.opendj.ldap.responses.ExtendedResultDecoder;
+import org.forgerock.util.Reject;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.forgerock.opendj.ldap.CoreMessages.ERR_EXTOP_PASSMOD_CANNOT_DECODE_REQUEST;
+import static com.forgerock.opendj.util.StaticUtils.getExceptionMessage;
+
+/*
+ An End Transaction Request is an LDAPMessage of CHOICE extendedReq
+   where the requestName is 1.3.6.1.1.21.3 and the requestValue is
+   present and contains a BER-encoded txnEndReq.
+
+      txnEndReq ::= SEQUENCE {
+           commit         BOOLEAN DEFAULT TRUE,
+           identifier     OCTET STRING }
+
+   A commit value of TRUE indicates a request to commit the transaction
+   identified by the identifier.  A commit value of FALSE indicates a
+   request to abort the identified transaction.
+ */
+public class EndTransactionExtendedRequest extends AbstractExtendedRequest<EndTransactionExtendedRequest, EndTransactionExtendedResult> {
+
+    public static final String END_TRANSACTION_REQUEST_OID ="1.3.6.1.1.21.3";
+
+    @Override
+    public String getOID() {
+        return END_TRANSACTION_REQUEST_OID;
+    }
+
+    @Override
+    public ExtendedResultDecoder<EndTransactionExtendedResult> getResultDecoder() {
+        return RESULT_DECODER;
+    }
+
+    Boolean commit=true;
+    public EndTransactionExtendedRequest setCommit(final Boolean commit) {
+        this.commit = commit;
+        return this;
+    }
+
+    String transactionID;
+    public EndTransactionExtendedRequest setTransactionID(final String transactionID) {
+        Reject.ifNull(transactionID);
+        this.transactionID = transactionID;
+        return this;
+    }
+    public boolean isCommit() {
+        return commit;
+    }
+
+    public String getTransactionID() {
+        return transactionID;
+    }
+
+    @Override
+    public ByteString getValue() {
+        Reject.ifNull(transactionID);
+        final ByteStringBuilder buffer = new ByteStringBuilder();
+        final ASN1Writer writer = ASN1.getWriter(buffer);
+        try {
+            writer.writeStartSequence();
+            if (commit!=null) {
+                writer.writeBoolean(commit);
+            }
+            writer.writeOctetString(transactionID);
+            writer.writeEndSequence();
+        } catch (final IOException ioe) {
+            throw new RuntimeException(ioe);
+        }
+        return buffer.toByteString();
+    }
+
+    @Override
+    public boolean hasValue() {
+        return true;
+    }
+
+    private static final EndTransactionExtendedRequest.ResultDecoder RESULT_DECODER = new EndTransactionExtendedRequest.ResultDecoder();
+
+    private static final class ResultDecoder extends  AbstractExtendedResultDecoder<EndTransactionExtendedResult> {
+        @Override
+        public EndTransactionExtendedResult newExtendedErrorResult(final ResultCode resultCode,final String matchedDN, final String diagnosticMessage) {
+            if (!resultCode.isExceptional()) {
+                throw new IllegalArgumentException("No response name and value for result code "+ resultCode.intValue());
+            }
+            return EndTransactionExtendedResult.newResult(resultCode).setMatchedDN(matchedDN).setDiagnosticMessage(diagnosticMessage);
+        }
+
+        /*
+        txnEndRes ::= SEQUENCE {
+           messageID MessageID OPTIONAL,
+                -- msgid associated with non-success resultCode
+           updatesControls SEQUENCE OF updateControls SEQUENCE {
+                messageID MessageID,
+                     -- msgid associated with controls
+                controls  Controls
+           } OPTIONAL
+      }
+         */
+        @Override
+        public EndTransactionExtendedResult decodeExtendedResult(final ExtendedResult result,final DecodeOptions options) throws DecodeException {
+            if (result instanceof EndTransactionExtendedResult) {
+                return (EndTransactionExtendedResult) result;
+            }
+
+            final ResultCode resultCode = result.getResultCode();
+            final EndTransactionExtendedResult newResult =
+                    EndTransactionExtendedResult.newResult(resultCode)
+                            .setMatchedDN(result.getMatchedDN())
+                            .setDiagnosticMessage(result.getDiagnosticMessage());
+
+            final ByteString responseValue = result.getValue();
+            if (!resultCode.isExceptional() && responseValue == null) {
+                throw DecodeException.error(LocalizableMessage.raw("Empty response value"));
+            }
+            if (responseValue != null) {
+                try {
+                    final ASN1Reader reader = ASN1.getReader(responseValue);
+                    if (reader.hasNextElement()) {
+                        reader.readStartSequence();
+                        if (reader.hasNextElement() && reader.peekType() == ASN1.UNIVERSAL_INTEGER_TYPE) {
+                            newResult.setFailedMessageID(Math.toIntExact(reader.readInteger()));
+                        } else if (reader.hasNextElement() && reader.peekType() == ASN1.UNIVERSAL_SEQUENCE_TYPE) {
+                            reader.readStartSequence();
+                            while (reader.hasNextElement() && reader.peekType() == ASN1.UNIVERSAL_SEQUENCE_TYPE) {
+                                reader.readStartSequence();
+                                final long messageId = reader.readInteger();
+                                final List<Control> controls = new ArrayList<>();
+                                reader.readStartSequence();
+                                while (reader.hasNextElement() && reader.peekType() == ASN1.UNIVERSAL_OCTET_STRING_TYPE) {
+                                    final ByteString controlEncoded = reader.readOctetString();
+                                    //TODO decode Control
+                                }
+                                reader.readEndSequence();
+                                //newResult.success(messageId, controls.toArray(new Control[]{}));
+                                reader.readEndSequence();
+                            }
+                            reader.readEndSequence();
+                        }
+                        reader.readEndSequence();
+                    }
+                } catch (final IOException e) {
+                    throw DecodeException.error(LocalizableMessage.raw("Error decoding response value"), e);
+                }
+            }
+            for (final Control control : result.getControls()) {
+                newResult.addControl(control);
+            }
+            return newResult;
+        }
+    }
+}
+
diff --git a/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedResult.java b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedResult.java
new file mode 100644
index 0000000..a4ba23e
--- /dev/null
+++ b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/EndTransactionExtendedResult.java
@@ -0,0 +1,168 @@
+/*
+ * 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 2025 3A Systems, LLC
+ */
+package com.forgerock.opendj.ldap.extensions;
+
+import org.forgerock.opendj.io.ASN1;
+import org.forgerock.opendj.io.ASN1Writer;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.ByteStringBuilder;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.controls.Control;
+import org.forgerock.opendj.ldap.responses.AbstractExtendedResult;
+import org.forgerock.util.Reject;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/*
+   An End Transaction Response is an LDAPMessage sent in response to a
+   End Transaction Request.  Its response name is absent.  The
+   responseValue when present contains a BER-encoded txnEndRes.
+
+      txnEndRes ::= SEQUENCE {
+           messageID MessageID OPTIONAL,
+                -- msgid associated with non-success resultCode
+           updatesControls SEQUENCE OF updateControls SEQUENCE {
+                messageID MessageID,
+                     -- msgid associated with controls
+                controls  Controls
+           } OPTIONAL
+      }
+      -- where MessageID and Controls are as specified in RFC 4511
+
+   The txnEndRes.messageID provides the message id of the update request
+   associated with a non-success response.  txnEndRes.messageID is
+   absent when resultCode of the End Transaction Response is success
+
+   The txnEndRes.updatesControls provides a facility for returning
+   response controls that normally (i.e., in the absence of
+   transactions) would be returned in an update response.  The
+   updateControls.messageID provides the message id of the update
+   request associated with the response controls provided in
+   updateControls.controls.
+
+   The txnEndRes.updatesControls is absent when there are no update
+   response controls to return.
+
+   If both txnEndRes.messageID and txnEndRes.updatesControl are absent,
+   the responseValue of the End Transaction Response is absent.
+ */
+public class EndTransactionExtendedResult extends AbstractExtendedResult<EndTransactionExtendedResult> {
+    @Override
+    public String getOID() {
+        return EndTransactionExtendedRequest.END_TRANSACTION_REQUEST_OID;
+    }
+
+    private EndTransactionExtendedResult(final ResultCode resultCode) {
+        super(resultCode);
+    }
+
+    public static EndTransactionExtendedResult newResult(final ResultCode resultCode) {
+        Reject.ifNull(resultCode);
+        return new EndTransactionExtendedResult(resultCode);
+    }
+
+    // The message ID for the operation that failed, if applicable.
+    Integer failedOpMessageID=null;
+
+    public EndTransactionExtendedResult setFailedMessageID(final Integer failedOpMessageID) {
+        Reject.ifNull(failedOpMessageID);
+        this.failedOpMessageID = failedOpMessageID;
+        return this;
+    }
+
+    // A mapping of the response controls for the operations performed as part of
+    // the transaction.
+    Map<Integer, List<Control>> opResponseControls= new TreeMap<>();
+
+    public EndTransactionExtendedResult success(Integer messageID, List<Control> responses) {
+        Reject.ifNull(messageID);
+        Reject.ifNull(responses);
+        opResponseControls.put(messageID,responses);
+        return this;
+    }
+
+    /*
+     txnEndRes ::= SEQUENCE {
+           messageID MessageID OPTIONAL,
+                -- msgid associated with non-success resultCode
+           updatesControls SEQUENCE OF updateControls SEQUENCE {
+                messageID MessageID,
+                     -- msgid associated with controls
+                controls  Controls
+           } OPTIONAL
+      }
+    */
+    @Override
+    public ByteString getValue() {
+        final ByteStringBuilder buffer = new ByteStringBuilder();
+        final ASN1Writer writer = ASN1.getWriter(buffer);
+        try {
+            if (failedOpMessageID!=null || (opResponseControls!=null && !opResponseControls.isEmpty()) ) {
+                writer.writeStartSequence();
+                if (failedOpMessageID != null) {
+                    writer.writeInteger(failedOpMessageID);
+                }
+                if (opResponseControls != null && !opResponseControls.isEmpty()) {
+                    writer.writeStartSequence();
+                    for (Map.Entry<Integer, List<Control>> entry : opResponseControls.entrySet()) {
+                        writer.writeStartSequence();
+                            writer.writeInteger(entry.getKey());
+                            writer.writeStartSequence();
+                                for (Control control : entry.getValue()) {
+                                   writer.writeOctetString(control.getValue());
+                                }
+                            writer.writeEndSequence();
+                        writer.writeEndSequence();
+                    }
+                    writer.writeEndSequence();
+                }
+                writer.writeEndSequence();
+            }
+        } catch (final IOException ioe) {
+            throw new RuntimeException(ioe);
+        }
+        return buffer.toByteString();
+    }
+
+    @Override
+    public boolean hasValue() {
+        return true;
+    }
+
+    @Override
+    public String toString() {
+        return "EndTransactionExtendedResult(resultCode=" +
+                getResultCode() +
+                ", matchedDN=" +
+                getMatchedDN() +
+                ", diagnosticMessage=" +
+                getDiagnosticMessage() +
+                ", referrals=" +
+                getReferralURIs() +
+                ", responseName=" +
+                getOID() +
+                ", failedOpMessageID=" +
+                failedOpMessageID +
+                ", opResponseControls=" +
+                opResponseControls +
+                ", controls=" +
+                getControls() +
+                ")";
+    }
+}
\ No newline at end of file
diff --git a/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedRequest.java b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedRequest.java
new file mode 100644
index 0000000..647063d
--- /dev/null
+++ b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedRequest.java
@@ -0,0 +1,101 @@
+/*
+ * 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 2025 3A Systems, LLC
+ */
+package com.forgerock.opendj.ldap.extensions;
+
+import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.opendj.io.ASN1;
+import org.forgerock.opendj.io.ASN1Reader;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.DecodeException;
+import org.forgerock.opendj.ldap.DecodeOptions;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.controls.Control;
+import org.forgerock.opendj.ldap.requests.AbstractExtendedRequest;
+import org.forgerock.opendj.ldap.responses.AbstractExtendedResultDecoder;
+import org.forgerock.opendj.ldap.responses.ExtendedResult;
+import org.forgerock.opendj.ldap.responses.ExtendedResultDecoder;
+
+import java.io.IOException;
+
+/*
+ A Start Transaction Request is an LDAPMessage of CHOICE extendedReq
+   where the requestName is 1.3.6.1.1.21.1 and the requestValue is
+   absent.
+ */
+public class StartTransactionExtendedRequest extends AbstractExtendedRequest<StartTransactionExtendedRequest, StartTransactionExtendedResult> {
+    public static final String START_TRANSACTION_REQUEST_OID ="1.3.6.1.1.21.1";
+
+    @Override
+    public String getOID() {
+        return START_TRANSACTION_REQUEST_OID;
+    }
+    @Override
+    public ExtendedResultDecoder<StartTransactionExtendedResult> getResultDecoder() {
+        return RESULT_DECODER;
+    }
+
+    @Override
+    public ByteString getValue() {
+        return null;
+    }
+
+    @Override
+    public boolean hasValue() {
+        return false;
+    }
+
+    private static final StartTransactionExtendedRequest.ResultDecoder RESULT_DECODER = new StartTransactionExtendedRequest.ResultDecoder();
+
+    private static final class ResultDecoder extends  AbstractExtendedResultDecoder<StartTransactionExtendedResult> {
+        @Override
+        public StartTransactionExtendedResult newExtendedErrorResult(final ResultCode resultCode,final String matchedDN, final String diagnosticMessage) {
+            if (!resultCode.isExceptional()) {
+                throw new IllegalArgumentException("No response name and value for result code "+ resultCode.intValue());
+            }
+            return StartTransactionExtendedResult.newResult(resultCode).setMatchedDN(matchedDN).setDiagnosticMessage(diagnosticMessage);
+        }
+
+        @Override
+        public StartTransactionExtendedResult decodeExtendedResult(final ExtendedResult result,final DecodeOptions options) throws DecodeException {
+            if (result instanceof StartTransactionExtendedResult) {
+                return (StartTransactionExtendedResult) result;
+            }
+
+            final ResultCode resultCode = result.getResultCode();
+            final StartTransactionExtendedResult newResult =
+                    StartTransactionExtendedResult.newResult(resultCode)
+                            .setMatchedDN(result.getMatchedDN())
+                            .setDiagnosticMessage(result.getDiagnosticMessage());
+
+            final ByteString responseValue = result.getValue();
+            if (!resultCode.isExceptional() && responseValue == null) {
+                throw DecodeException.error(LocalizableMessage.raw("Empty response value"));
+            }
+            if (responseValue != null) {
+                try {
+                    final ASN1Reader reader = ASN1.getReader(responseValue);
+                    newResult.setTransactionID(reader.readOctetStringAsString());
+                } catch (final IOException e) {
+                    throw DecodeException.error(LocalizableMessage.raw("Error decoding response value"), e);
+                }
+            }
+            for (final Control control : result.getControls()) {
+                newResult.addControl(control);
+            }
+            return newResult;
+        }
+    }
+}
diff --git a/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedResult.java b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedResult.java
new file mode 100644
index 0000000..40dbf04
--- /dev/null
+++ b/opendj-core/src/main/java/com/forgerock/opendj/ldap/extensions/StartTransactionExtendedResult.java
@@ -0,0 +1,96 @@
+/*
+ * 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 2025 3A Systems, LLC
+ */
+package com.forgerock.opendj.ldap.extensions;
+
+import org.forgerock.opendj.io.ASN1;
+import org.forgerock.opendj.io.ASN1Writer;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.ByteStringBuilder;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.responses.AbstractExtendedResult;
+import org.forgerock.util.Reject;
+
+import java.io.IOException;
+
+/*
+    A Start Transaction Response is an LDAPMessage of CHOICE extendedRes
+   sent in response to a Start Transaction Request.  Its responseName is
+   absent.  When the resultCode is success (0), responseValue is present
+   and contains a transaction identifier.  Otherwise, the responseValue
+   is absent.
+ */
+public class StartTransactionExtendedResult extends AbstractExtendedResult<StartTransactionExtendedResult> {
+    @Override
+    public String getOID() {
+        return StartTransactionExtendedRequest.START_TRANSACTION_REQUEST_OID;
+    }
+
+    private StartTransactionExtendedResult(final ResultCode resultCode) {
+        super(resultCode);
+    }
+
+    public static StartTransactionExtendedResult newResult(final ResultCode resultCode) {
+        Reject.ifNull(resultCode);
+        return new StartTransactionExtendedResult(resultCode);
+    }
+
+    private String transactionID = null;
+
+    public StartTransactionExtendedResult setTransactionID(final String transactionID) {
+        this.transactionID = transactionID;
+        return this;
+    }
+
+    public String getTransactionID() {
+        return transactionID;
+    }
+
+    @Override
+    public ByteString getValue() {
+        final ByteStringBuilder buffer = new ByteStringBuilder();
+        final ASN1Writer writer = ASN1.getWriter(buffer);
+        try {
+            writer.writeOctetString(transactionID);
+        } catch (final IOException ioe) {
+            throw new RuntimeException(ioe);
+        }
+        return buffer.toByteString();
+    }
+
+    @Override
+    public boolean hasValue() {
+        return true;
+    }
+
+    @Override
+    public String toString() {
+        return "StartTransactionExtendedResult(resultCode=" +
+                getResultCode() +
+                ", matchedDN=" +
+                getMatchedDN() +
+                ", diagnosticMessage=" +
+                getDiagnosticMessage() +
+                ", referrals=" +
+                getReferralURIs() +
+                ", responseName=" +
+                getOID() +
+                ", transactionID=" +
+                transactionID +
+                ", controls=" +
+                getControls() +
+                ")";
+    }
+}
diff --git a/opendj-core/src/main/java/org/forgerock/opendj/ldap/controls/TransactionSpecificationRequestControl.java b/opendj-core/src/main/java/org/forgerock/opendj/ldap/controls/TransactionSpecificationRequestControl.java
new file mode 100644
index 0000000..2d4f589
--- /dev/null
+++ b/opendj-core/src/main/java/org/forgerock/opendj/ldap/controls/TransactionSpecificationRequestControl.java
@@ -0,0 +1,82 @@
+/*
+ * 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 2025 3A Systems,LLC.
+ */
+package org.forgerock.opendj.ldap.controls;
+
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.DecodeException;
+import org.forgerock.opendj.ldap.DecodeOptions;
+import org.forgerock.util.Reject;
+
+import static com.forgerock.opendj.ldap.CoreMessages.ERR_TRANSACTION_ID_CONTROL_BAD_OID;
+import static com.forgerock.opendj.ldap.CoreMessages.ERR_TRANSACTION_ID_CONTROL_DECODE_NULL;
+
+public class TransactionSpecificationRequestControl implements Control{
+
+    public final static String OID="1.3.6.1.1.21.2";
+
+    final String transactionId;
+    public TransactionSpecificationRequestControl(String transactionId) {
+        Reject.ifNull(transactionId);
+        this.transactionId = transactionId;
+    }
+
+    @Override
+    public String getOID() {
+        return OID;
+    }
+
+    @Override
+    public ByteString getValue() {
+        return ByteString.valueOfUtf8(transactionId);
+    }
+
+    @Override
+    public boolean hasValue() {
+        return true;
+    }
+
+    @Override
+    public boolean isCritical() {
+        return true;
+    }
+
+    public static final ControlDecoder<TransactionSpecificationRequestControl> DECODER = new ControlDecoder<TransactionSpecificationRequestControl>() {
+        @Override
+        public TransactionSpecificationRequestControl decodeControl(final Control control, final DecodeOptions options)
+                throws DecodeException {
+            Reject.ifNull(control);
+
+            if (control instanceof TransactionSpecificationRequestControl) {
+                return (TransactionSpecificationRequestControl) control;
+            }
+
+            if (!control.getOID().equals(OID)) {
+                throw DecodeException.error(ERR_TRANSACTION_ID_CONTROL_BAD_OID.get(control.getOID(), OID));
+            }
+
+            if (!control.hasValue()) {
+                throw DecodeException.error(ERR_TRANSACTION_ID_CONTROL_DECODE_NULL.get());
+            }
+
+            return new TransactionSpecificationRequestControl(control.getValue().toString());
+        }
+
+        @Override
+        public String getOID() {
+            return OID;
+        }
+    };
+}
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-standards.adoc b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-standards.adoc
index 5d3707f..926f3b9 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-standards.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-standards.adoc
@@ -24,7 +24,7 @@
 [#appendix-standards]
 == Standards, RFCs, & Internet-Drafts
 
-OpenDJ 3.5 software implements the following RFCs, Internet-Drafts, and standards:
+OpenDJ software implements the following RFCs, Internet-Drafts, and standards:
 --
 
 [#rfc1274]
@@ -396,6 +396,13 @@
 +
 Describes the Lightweight Directory Access Protocol (LDAP) / X.500 'entryDN' operational attribute, that provides a copy of the entry's distinguished name for use in attribute value assertions.
 
+[#rfc5805]
+link:http://tools.ietf.org/html/rfc5805[RFC 5805: Lightweight Directory Access Protocol (LDAP) Transactions, window=\_top]::
++
+Lightweight Directory Access Protocol (LDAP) update operations, such as Add, Delete, and Modify operations, have atomic, consistency,
+isolation, durability (ACID) properties.  Each of these update operations act upon an entry.  It is often desirable to update two or
+more entries in a single unit of interaction, a transaction.
+
 [#fips180-1]
 link:http://www.itl.nist.gov/fipspubs/fip180-1.htm[FIPS 180-1: Secure Hash Standard (SHA-1), window=\_top]::
 +
diff --git a/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/EndTransactionExtendedOperationHandlerConfiguration.xml b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/EndTransactionExtendedOperationHandlerConfiguration.xml
new file mode 100644
index 0000000..3631751
--- /dev/null
+++ b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/EndTransactionExtendedOperationHandlerConfiguration.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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 2025 3A Systems,LLC.
+  ! -->
+<adm:managed-object name="end-transaction-extended-operation-handler"
+  plural-name="end-transaction-extended-operation-handlers"
+  package="org.forgerock.opendj.server.config"
+  extends="extended-operation-handler"
+  xmlns:adm="http://opendj.forgerock.org/admin"
+  xmlns:ldap="http://opendj.forgerock.org/admin-ldap">
+  <adm:synopsis>
+    The
+    <adm:user-friendly-name />
+    An End Transaction Request is an LDAPMessage of CHOICE extendedReq
+    where the requestName is 1.3.6.1.1.21.3 and the requestValue is
+    present and contains a BER-encoded txnEndReq.
+
+    txnEndReq ::= SEQUENCE {
+    commit         BOOLEAN DEFAULT TRUE,
+    identifier     OCTET STRING }
+
+    A commit value of TRUE indicates a request to commit the transaction
+    identified by the identifier.  A commit value of FALSE indicates a
+    request to abort the identified transaction.
+
+    An End Transaction Response is an LDAPMessage sent in response to a
+    End Transaction Request.  Its response name is absent.  The
+    responseValue when present contains a BER-encoded txnEndRes.
+
+    txnEndRes ::= SEQUENCE {
+    messageID MessageID OPTIONAL,
+    -- msgid associated with non-success resultCode
+    updatesControls SEQUENCE OF updateControls SEQUENCE {
+    messageID MessageID,
+    -- msgid associated with controls
+    controls  Controls
+    } OPTIONAL
+    }
+    -- where MessageID and Controls are as specified in RFC 4511
+
+    The txnEndRes.messageID provides the message id of the update request
+    associated with a non-success response.  txnEndRes.messageID is
+    absent when resultCode of the End Transaction Response is success
+    (0).
+  </adm:synopsis>
+  <adm:profile name="ldap">
+    <ldap:object-class>
+      <ldap:name>
+        ds-cfg-end-transaction-extended-operation-handler
+      </ldap:name>
+      <ldap:superior>ds-cfg-extended-operation-handler</ldap:superior>
+    </ldap:object-class>
+  </adm:profile>
+  <adm:property-override name="java-class" advanced="true">
+    <adm:default-behavior>
+      <adm:defined>
+        <adm:value>
+          org.opends.server.extensions.EndTransactionExtendedOperation
+        </adm:value>
+      </adm:defined>
+    </adm:default-behavior>
+  </adm:property-override>
+</adm:managed-object>
diff --git a/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/StartTransactionExtendedOperationHandlerConfiguration.xml b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/StartTransactionExtendedOperationHandlerConfiguration.xml
new file mode 100644
index 0000000..e70290a
--- /dev/null
+++ b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/StartTransactionExtendedOperationHandlerConfiguration.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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 2025 3A Systems,LLC.
+  ! -->
+<adm:managed-object name="start-transaction-extended-operation-handler"
+  plural-name="start-transaction-extended-operation-handlers"
+  package="org.forgerock.opendj.server.config"
+  extends="extended-operation-handler"
+  xmlns:adm="http://opendj.forgerock.org/admin"
+  xmlns:ldap="http://opendj.forgerock.org/admin-ldap">
+  <adm:synopsis>
+    The
+    <adm:user-friendly-name />
+    A client wishing to perform a sequence of directory updates as a
+    transaction issues a Start Transaction Request.  A server that is
+    willing and able to support transactions responds to this request
+    with a Start Transaction Response providing a transaction identifier
+    and with a resultCode of success (0).  Otherwise, the server responds
+    with a Start Transaction Response with a resultCode other than
+    success indicating the nature of the failure.
+
+    The transaction identifier provided upon successful start of a
+    transaction is used in subsequent protocol messages to identify this
+    transaction.
+  </adm:synopsis>
+  <adm:profile name="ldap">
+    <ldap:object-class>
+      <ldap:name>
+        ds-cfg-start-transaction-extended-operation-handler
+      </ldap:name>
+      <ldap:superior>ds-cfg-extended-operation-handler</ldap:superior>
+    </ldap:object-class>
+  </adm:profile>
+  <adm:property-override name="java-class" advanced="true">
+    <adm:default-behavior>
+      <adm:defined>
+        <adm:value>
+          org.opends.server.extensions.StartTransactionExtendedOperation
+        </adm:value>
+      </adm:defined>
+    </adm:default-behavior>
+  </adm:property-override>
+</adm:managed-object>
diff --git a/opendj-server-legacy/resource/config/config.ldif b/opendj-server-legacy/resource/config/config.ldif
index f836b18..0683e93 100644
--- a/opendj-server-legacy/resource/config/config.ldif
+++ b/opendj-server-legacy/resource/config/config.ldif
@@ -14,6 +14,7 @@
 # Portions Copyright 2012-2014 Manuel Gaupp
 # Portions Copyright 2010-2016 ForgeRock AS.
 # Portions copyright 2015 Edan Idzerda
+# Portions Copyright 2025 3A Systems, LLC.
 
 # This file contains the primary Directory Server configuration.  It must not
 # be directly edited while the server is online.  The server configuration
@@ -603,6 +604,22 @@
 ds-cfg-java-class: org.opends.server.extensions.WhoAmIExtendedOperation
 ds-cfg-enabled: true
 
+dn: cn=Start Transaction,cn=Extended Operations,cn=config
+objectClass: top
+objectClass: ds-cfg-extended-operation-handler
+objectClass: ds-cfg-start-transaction-extended-operation-handler
+cn: Start Transaction
+ds-cfg-java-class: org.opends.server.extensions.StartTransactionExtendedOperation
+ds-cfg-enabled: true
+
+dn: cn=End Transaction,cn=Extended Operations,cn=config
+objectClass: top
+objectClass: ds-cfg-extended-operation-handler
+objectClass: ds-cfg-end-transaction-extended-operation-handler
+cn: End Transaction
+ds-cfg-java-class: org.opends.server.extensions.EndTransactionExtendedOperation
+ds-cfg-enabled: true
+
 dn: cn=Group Implementations,cn=config
 objectClass: top
 objectClass: ds-cfg-branch
diff --git a/opendj-server-legacy/resource/schema/02-config.ldif b/opendj-server-legacy/resource/schema/02-config.ldif
index a950d83..2f8cc52 100644
--- a/opendj-server-legacy/resource/schema/02-config.ldif
+++ b/opendj-server-legacy/resource/schema/02-config.ldif
@@ -6265,4 +6265,13 @@
   STRUCTURAL
   MAY ds-cfg-pbkdf2-iterations
   X-ORIGIN 'OpenDJ Directory Server' )
-  
\ No newline at end of file
+objectClasses: ( 1.3.6.1.4.1.60142.1.2.1
+  NAME 'ds-cfg-start-transaction-extended-operation-handler'
+  SUP ds-cfg-extended-operation-handler
+  STRUCTURAL
+  X-ORIGIN 'OpenDS Directory Server' )
+objectClasses: ( 1.3.6.1.4.1.60142.1.2.2
+  NAME 'ds-cfg-end-transaction-extended-operation-handler'
+  SUP ds-cfg-extended-operation-handler
+  STRUCTURAL
+  X-ORIGIN 'OpenDS Directory Server' )
\ No newline at end of file
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/api/ClientConnection.java b/opendj-server-legacy/src/main/java/org/opends/server/api/ClientConnection.java
index 7487b3d..a891fee 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/api/ClientConnection.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/api/ClientConnection.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2009 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2025 3A Systems, LLC.
  */
 package org.opends.server.api;
 
@@ -20,11 +21,8 @@
 import java.nio.channels.ByteChannel;
 import java.nio.channels.Selector;
 import java.nio.channels.SocketChannel;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -53,6 +51,7 @@
 import org.opends.server.types.Privilege;
 import org.opends.server.types.SearchResultEntry;
 import org.opends.server.types.SearchResultReference;
+import org.opends.server.types.operation.RollbackOperation;
 import org.opends.server.util.TimeThread;
 
 import static org.opends.messages.CoreMessages.*;
@@ -1576,4 +1575,47 @@
   {
     return getConnectionID() < 0;
   }
+
+  public class Transaction {
+      final String transactionId=UUID.randomUUID().toString().toLowerCase();
+
+      public Transaction() {
+          transactions.put(getTransactionId(),this);
+      }
+
+      public String getTransactionId() {
+          return transactionId;
+      }
+
+      final Queue<Operation> waiting=new LinkedList<>();
+      public void add(Operation operation) {
+          waiting.add(operation);
+      }
+
+      public Queue<Operation> getWaiting() {
+          return waiting;
+      }
+
+      public void clear() {
+          transactions.remove(getTransactionId());
+      }
+      final Deque<RollbackOperation> completed =new ArrayDeque<>();
+      public void success(RollbackOperation operation) {
+          completed.add(operation);
+      }
+
+      public Deque<RollbackOperation> getCompleted() {
+          return completed;
+      }
+  }
+
+  Map<String,Transaction> transactions=new ConcurrentHashMap<>();
+
+  public Transaction startTransaction() {
+      return new Transaction();
+  }
+
+  public Transaction getTransaction(String id) {
+      return transactions.get(id);
+  }
 }
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/EndTransactionExtendedOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/EndTransactionExtendedOperation.java
new file mode 100644
index 0000000..9ebb554
--- /dev/null
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/EndTransactionExtendedOperation.java
@@ -0,0 +1,131 @@
+/*
+ * 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 2025 3A Systems, LLC
+ */
+package org.opends.server.extensions;
+
+import com.forgerock.opendj.ldap.extensions.EndTransactionExtendedRequest;
+import com.forgerock.opendj.ldap.extensions.EndTransactionExtendedResult;
+import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.i18n.slf4j.LocalizedLogger;
+import org.forgerock.opendj.config.server.ConfigException;
+import org.forgerock.opendj.io.ASN1;
+import org.forgerock.opendj.io.ASN1Reader;
+import org.forgerock.opendj.ldap.DecodeException;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.server.config.server.EndTransactionExtendedOperationHandlerCfg;
+import org.opends.server.api.ClientConnection;
+import org.opends.server.api.ExtendedOperationHandler;
+import org.opends.server.core.ExtendedOperation;
+import org.opends.server.types.DirectoryException;
+import org.opends.server.types.InitializationException;
+import org.opends.server.types.Operation;
+import org.opends.server.types.operation.RollbackOperation;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.LinkedList;
+import java.util.Queue;
+
+import static com.forgerock.opendj.ldap.CoreMessages.ERR_EXTOP_PASSMOD_CANNOT_DECODE_REQUEST;
+import static com.forgerock.opendj.util.StaticUtils.getExceptionMessage;
+
+
+public class EndTransactionExtendedOperation extends ExtendedOperationHandler<EndTransactionExtendedOperationHandlerCfg>
+{
+  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
+
+  public EndTransactionExtendedOperation()
+  {
+    super();
+  }
+
+  @Override
+  public void initializeExtendedOperationHandler(EndTransactionExtendedOperationHandlerCfg config) throws ConfigException, InitializationException
+  {
+    super.initializeExtendedOperationHandler(config);
+  }
+
+  @Override
+  public void processExtendedOperation(ExtendedOperation operation)
+  {
+    final EndTransactionExtendedRequest request =new EndTransactionExtendedRequest();
+    if (operation.getRequestValue()!= null) {
+        final ASN1Reader reader = ASN1.getReader(operation.getRequestValue());
+        try {
+            reader.readStartSequence();
+            if (reader.hasNextElement()&& (reader.peekType() == ASN1.UNIVERSAL_BOOLEAN_TYPE)) {
+              request.setCommit(reader.readBoolean());
+            }
+            request.setTransactionID(reader.readOctetStringAsString());
+            reader.readEndSequence();
+        } catch (IOException e) {
+          throw new RuntimeException(e);
+        }
+    }
+
+    final ClientConnection.Transaction trn=operation.getClientConnection().getTransaction(request.getTransactionID());
+    if (trn==null) {
+        operation.setResultCode(ResultCode.CANCELLED);
+        operation.appendErrorMessage(LocalizableMessage.raw("unknown transactionId="+request.getTransactionID()));
+        return;
+    }
+
+    final EndTransactionExtendedResult res=EndTransactionExtendedResult.newResult(ResultCode.SUCCESS);
+    operation.setResultCode(res.getResultCode());
+    Operation currentOperation=null;
+    try {
+        while((currentOperation=trn.getWaiting().poll())!=null) {
+            if (request.isCommit()) {
+                currentOperation.run();
+                if (!ResultCode.SUCCESS.equals(currentOperation.getResultCode())) {
+                    throw new InterruptedException();
+                }
+                currentOperation.operationCompleted();
+                //res.success(currentOperation.getMessageID(),currentOperation.getResponseControls());
+            }
+        }
+    }catch (Throwable e){
+        res.setFailedMessageID(currentOperation.getMessageID());
+        operation.setResultCode(currentOperation.getResultCode());
+        operation.setErrorMessage(currentOperation.getErrorMessage());
+        //rollback
+        RollbackOperation cancelOperation=null;
+        while((cancelOperation=trn.getCompleted().pollLast())!=null) {
+            try {
+                cancelOperation.rollback();
+            }catch (Throwable e2){
+                throw new RuntimeException("rollback error",e2);
+            }
+        }
+    }finally {
+        trn.clear();
+    }
+    operation.setResponseOID(res.getOID());
+    operation.setResponseValue(res.getValue());
+  }
+
+  @Override
+  public String getExtendedOperationOID()
+  {
+    return EndTransactionExtendedRequest.END_TRANSACTION_REQUEST_OID;
+  }
+
+  @Override
+  public String getExtendedOperationName()
+  {
+    return "End Transaction";
+  }
+}
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/StartTransactionExtendedOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/StartTransactionExtendedOperation.java
new file mode 100644
index 0000000..b7fb0a3
--- /dev/null
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/StartTransactionExtendedOperation.java
@@ -0,0 +1,66 @@
+/*
+ * 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 2025 3A Systems, LLC
+ */
+package org.opends.server.extensions;
+
+import com.forgerock.opendj.ldap.extensions.StartTransactionExtendedRequest;
+import com.forgerock.opendj.ldap.extensions.StartTransactionExtendedResult;
+import org.forgerock.i18n.slf4j.LocalizedLogger;
+import org.forgerock.opendj.config.server.ConfigException;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.server.config.server.StartTransactionExtendedOperationHandlerCfg;
+import org.opends.server.api.ExtendedOperationHandler;
+import org.opends.server.core.ExtendedOperation;
+import org.opends.server.types.InitializationException;
+
+public class StartTransactionExtendedOperation extends ExtendedOperationHandler<StartTransactionExtendedOperationHandlerCfg>
+{
+  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
+
+  public StartTransactionExtendedOperation()
+  {
+    super();
+  }
+
+  @Override
+  public void initializeExtendedOperationHandler(StartTransactionExtendedOperationHandlerCfg config) throws ConfigException, InitializationException
+  {
+    super.initializeExtendedOperationHandler(config);
+  }
+
+  @Override
+  public void processExtendedOperation(ExtendedOperation operation)
+  {
+    final StartTransactionExtendedResult res=StartTransactionExtendedResult
+            .newResult(ResultCode.SUCCESS)
+            .setTransactionID(operation.getClientConnection().startTransaction().getTransactionId());
+
+    operation.setResponseOID(res.getOID());
+    operation.setResponseValue(res.getValue());
+    operation.setResultCode(res.getResultCode());
+  }
+
+  @Override
+  public String getExtendedOperationOID()
+  {
+    return StartTransactionExtendedRequest.START_TRANSACTION_REQUEST_OID;
+  }
+
+  @Override
+  public String getExtendedOperationName()
+  {
+    return "Start Transaction";
+  }
+}
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/TraditionalWorkerThread.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/TraditionalWorkerThread.java
index d88bdca..1ceae70 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/extensions/TraditionalWorkerThread.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/TraditionalWorkerThread.java
@@ -13,15 +13,19 @@
  *
  * Copyright 2006-2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2025 3A Systems, LLC.
  */
 package org.opends.server.extensions;
 
 import java.util.Map;
 
 import org.forgerock.i18n.LocalizableMessage;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.opends.server.api.ClientConnection;
 import org.opends.server.api.DirectoryThread;
 import org.opends.server.core.DirectoryServer;
 import org.forgerock.i18n.slf4j.LocalizedLogger;
+import org.opends.server.types.AbstractOperation;
 import org.opends.server.types.CancelRequest;
 import org.opends.server.types.DisconnectReason;
 import org.opends.server.types.Operation;
@@ -145,8 +149,29 @@
         {
           // The operation is not null, so process it.  Make sure that when
           // processing is complete.
-          operation.run();
-          operation.operationCompleted();
+
+          //check has transactionId control
+          ClientConnection.Transaction transaction=null;
+          if (operation instanceof AbstractOperation) {
+            String transactionId = ((AbstractOperation) operation).getTransactionId();
+            if (transactionId!=null){
+              transaction=operation.getClientConnection().getTransaction(transactionId);
+              if (transaction==null){ //unknown transactionId
+                operation.setResultCode(ResultCode.CANCELLED);
+                operation.appendErrorMessage(LocalizableMessage.raw("unknown transactionId="+transactionId));
+                operation.getClientConnection().sendResponse(operation);
+                continue;
+              }
+            }
+          }
+          if (transaction==null) {  //run
+            operation.run();
+            operation.operationCompleted();
+          }else { //suspend for commit
+            transaction.add(operation);
+            operation.setResultCode(ResultCode.SUCCESS);
+            operation.getClientConnection().sendResponse(operation);
+          }
         }
       }
       catch (Throwable t)
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/types/AbstractOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/types/AbstractOperation.java
index 61ee9f2..5f5c67c 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/types/AbstractOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/types/AbstractOperation.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2023-2025 3A Systems, LLC.
  */
 package org.opends.server.types;
 
@@ -29,6 +30,7 @@
 import org.forgerock.i18n.slf4j.LocalizedLogger;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.controls.TransactionSpecificationRequestControl;
 import org.forgerock.util.Reject;
 import org.opends.server.api.ClientConnection;
 import org.opends.server.api.plugin.PluginResult.OperationResult;
@@ -721,4 +723,16 @@
     }
     return true;
   }
+
+  public String getTransactionId() {
+      for (Control control : getRequestControls()) {
+          if (control.getOID().equals(TransactionSpecificationRequestControl.OID)) {
+            if ((control instanceof LDAPControl) && ((LDAPControl)control).getValue()!=null){
+              return ((LDAPControl) control).getValue().toString();
+            }
+          }
+      }
+      return null;
+  }
+
 }
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/types/operation/RollbackOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/types/operation/RollbackOperation.java
new file mode 100644
index 0000000..cdeb151
--- /dev/null
+++ b/opendj-server-legacy/src/main/java/org/opends/server/types/operation/RollbackOperation.java
@@ -0,0 +1,26 @@
+/*
+ * 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 2025 3A Systems,LLC.
+ */
+package org.opends.server.types.operation;
+
+import org.opends.server.api.ClientConnection;
+import org.opends.server.types.CanceledOperationException;
+import org.opends.server.types.DirectoryException;
+
+public interface RollbackOperation
+{
+  void rollback() throws CanceledOperationException, DirectoryException;
+}
+
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
index 1b86927..03a1e3b 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendAddOperation.java
@@ -13,7 +13,7 @@
  *
  * Copyright 2008-2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
- * Portions copyright 2024 3A Systems,LLC.
+ * Portions Copyright 2024-2025 3A Systems,LLC.
  */
 package org.opends.server.workflowelement.localbackend;
 
@@ -39,6 +39,7 @@
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
+import org.forgerock.opendj.ldap.controls.TransactionSpecificationRequestControl;
 import org.forgerock.opendj.ldap.schema.AttributeType;
 import org.forgerock.opendj.ldap.schema.ObjectClass;
 import org.forgerock.opendj.ldap.schema.Syntax;
@@ -61,6 +62,7 @@
 import org.opends.server.core.PasswordPolicy;
 import org.opends.server.core.PersistentSearch;
 import org.opends.server.core.ServerContext;
+import org.opends.server.protocols.ldap.LDAPControl;
 import org.opends.server.schema.AuthPasswordSyntax;
 import org.opends.server.schema.UserPasswordSyntax;
 import org.opends.server.types.Attribute;
@@ -73,10 +75,7 @@
 import org.opends.server.types.LockManager.DNLock;
 import org.opends.server.types.Privilege;
 import org.opends.server.types.SearchFilter;
-import org.opends.server.types.operation.PostOperationAddOperation;
-import org.opends.server.types.operation.PostResponseAddOperation;
-import org.opends.server.types.operation.PostSynchronizationAddOperation;
-import org.opends.server.types.operation.PreOperationAddOperation;
+import org.opends.server.types.operation.*;
 import org.opends.server.util.TimeThread;
 
 /**
@@ -86,7 +85,7 @@
 public class LocalBackendAddOperation
        extends AddOperationWrapper
        implements PreOperationAddOperation, PostOperationAddOperation,
-                  PostResponseAddOperation, PostSynchronizationAddOperation
+                  PostResponseAddOperation, PostSynchronizationAddOperation,RollbackOperation
 {
   private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
@@ -470,6 +469,9 @@
         }
 
         backend.addEntry(entry, this);
+        if (trx!=null) {
+          trx.success(this);
+        }
       }
 
       LocalBackendWorkflowElement.addPostReadResponse(this, postReadRequest,
@@ -496,7 +498,10 @@
     }
   }
 
-
+  @Override
+  public void rollback() throws CanceledOperationException, DirectoryException {
+    backend.deleteEntry(entryDN,null);
+  }
 
   private void processSynchPostOperationPlugins()
   {
@@ -968,6 +973,10 @@
       {
         RelaxRulesControlRequested = true;
       }
+      else if (TransactionSpecificationRequestControl.OID.equals(oid))
+      {
+        trx=getClientConnection().getTransaction(((LDAPControl)c).getValue().toString());
+      }
       else if (c.isCritical() && !backend.supportsControl(oid))
       {
         throw newDirectoryException(entryDN, ResultCode.UNAVAILABLE_CRITICAL_EXTENSION,
@@ -975,9 +984,11 @@
       }
     }
   }
+  ClientConnection.Transaction trx=null;
 
   private AccessControlHandler<?> getAccessControlHandler()
   {
     return AccessControlConfigManager.getInstance().getAccessControlHandler();
   }
+
 }
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendDeleteOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendDeleteOperation.java
index 7fb633e..0a10664 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendDeleteOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendDeleteOperation.java
@@ -13,7 +13,7 @@
  *
  * Copyright 2008-2009 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
- * Portions Copyright 2022-2024 3A Systems, LLC.
+ * Portions Copyright 2022-2025 3A Systems, LLC.
  */
 package org.opends.server.workflowelement.localbackend;
 
@@ -23,6 +23,7 @@
 import org.forgerock.i18n.slf4j.LocalizedLogger;
 import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.TransactionSpecificationRequestControl;
 import org.opends.server.api.AccessControlHandler;
 import org.opends.server.api.LocalBackend;
 import org.opends.server.api.ClientConnection;
@@ -35,6 +36,7 @@
 import org.opends.server.core.DeleteOperationWrapper;
 import org.opends.server.core.DirectoryServer;
 import org.opends.server.core.PersistentSearch;
+import org.opends.server.protocols.ldap.LDAPControl;
 import org.opends.server.types.CanceledOperationException;
 import org.opends.server.types.Control;
 import org.forgerock.opendj.ldap.DN;
@@ -43,10 +45,7 @@
 import org.opends.server.types.LockManager.DNLock;
 import org.opends.server.types.SearchFilter;
 import org.opends.server.types.SynchronizationProviderResult;
-import org.opends.server.types.operation.PostOperationDeleteOperation;
-import org.opends.server.types.operation.PostResponseDeleteOperation;
-import org.opends.server.types.operation.PostSynchronizationDeleteOperation;
-import org.opends.server.types.operation.PreOperationDeleteOperation;
+import org.opends.server.types.operation.*;
 
 import static org.opends.messages.CoreMessages.*;
 import static org.opends.server.core.DirectoryServer.*;
@@ -63,7 +62,7 @@
        extends DeleteOperationWrapper
        implements PreOperationDeleteOperation, PostOperationDeleteOperation,
                   PostResponseDeleteOperation,
-                  PostSynchronizationDeleteOperation
+                  PostSynchronizationDeleteOperation, RollbackOperation
 {
   private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
@@ -299,6 +298,9 @@
           return;
         }
         backend.deleteEntry(entryDN, this);
+        if (trx!=null) {
+          trx.success(this);
+        }
       }
 
       LocalBackendWorkflowElement.addPreReadResponse(this, preReadRequest, entry);
@@ -416,6 +418,10 @@
       {
         continue;
       }
+      else if (TransactionSpecificationRequestControl.OID.equals(oid))
+      {
+        trx=getClientConnection().getTransaction(((LDAPControl)c).getValue().toString());
+      }
       else if (c.isCritical() && !backend.supportsControl(oid))
       {
         throw newDirectoryException(entry, ResultCode.UNAVAILABLE_CRITICAL_EXTENSION,
@@ -423,6 +429,7 @@
       }
     }
   }
+  ClientConnection.Transaction trx=null;
 
   /**
    * Handle conflict resolution.
@@ -488,4 +495,9 @@
       }
       return true;
   }
+
+  @Override
+  public void rollback() throws CanceledOperationException, DirectoryException {
+    backend.addEntry(entry,null);
+  }
 }
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyDNOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyDNOperation.java
index a0a575d..effec13 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyDNOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyDNOperation.java
@@ -13,7 +13,7 @@
  *
  * Copyright 2008-2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
- * Portions copyright 2024 3A Systems,LLC.
+ * Portions copyright 2024-2025 3A Systems,LLC.
  */
 package org.opends.server.workflowelement.localbackend;
 
@@ -29,6 +29,7 @@
 import org.forgerock.opendj.ldap.ByteString;
 import org.forgerock.opendj.ldap.ModificationType;
 import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.controls.TransactionSpecificationRequestControl;
 import org.forgerock.opendj.ldap.schema.AttributeType;
 import org.opends.server.api.AccessControlHandler;
 import org.opends.server.api.LocalBackend;
@@ -43,6 +44,7 @@
 import org.opends.server.core.ModifyDNOperation;
 import org.opends.server.core.ModifyDNOperationWrapper;
 import org.opends.server.core.PersistentSearch;
+import org.opends.server.protocols.ldap.LDAPControl;
 import org.opends.server.types.Attribute;
 import org.opends.server.types.Attributes;
 import org.opends.server.types.CanceledOperationException;
@@ -54,10 +56,7 @@
 import org.opends.server.types.Modification;
 import org.forgerock.opendj.ldap.RDN;
 import org.opends.server.types.SearchFilter;
-import org.opends.server.types.operation.PostOperationModifyDNOperation;
-import org.opends.server.types.operation.PostResponseModifyDNOperation;
-import org.opends.server.types.operation.PostSynchronizationModifyDNOperation;
-import org.opends.server.types.operation.PreOperationModifyDNOperation;
+import org.opends.server.types.operation.*;
 
 import static org.opends.messages.CoreMessages.*;
 import static org.opends.server.core.DirectoryServer.*;
@@ -75,7 +74,7 @@
   implements PreOperationModifyDNOperation,
              PostOperationModifyDNOperation,
              PostResponseModifyDNOperation,
-             PostSynchronizationModifyDNOperation
+             PostSynchronizationModifyDNOperation, RollbackOperation
 {
   private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
@@ -439,6 +438,9 @@
           return;
         }
         currentBackend.renameEntry(entryDN, newEntry, this);
+        if (trx!=null) {
+          trx.success(this);
+        }
       }
 
       // Attach the pre-read and/or post-read controls to the response if
@@ -576,6 +578,10 @@
       {
         continue;
       }
+      else if (TransactionSpecificationRequestControl.OID.equals(oid))
+      {
+        trx=getClientConnection().getTransaction(((LDAPControl)c).getValue().toString());
+      }
       else if (c.isCritical() && !backend.supportsControl(oid))
       {
         throw new DirectoryException(ResultCode.UNAVAILABLE_CRITICAL_EXTENSION,
@@ -583,6 +589,7 @@
       }
     }
   }
+  ClientConnection.Transaction trx=null;
 
   private AccessControlHandler<?> getAccessControlHandler()
   {
@@ -816,4 +823,9 @@
           }
       }
   }
+
+  @Override
+  public void rollback() throws CanceledOperationException, DirectoryException {
+     backend.renameEntry(newEntry.getName(), currentEntry, this);
+  }
 }
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
index 6995427..f2c7f78 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendModifyOperation.java
@@ -13,7 +13,7 @@
  *
  * Copyright 2008-2011 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
- * Portions copyright 2024 3A Systems,LLC.
+ * Portions Copyright 2024-2025 3A Systems,LLC.
  */
 package org.opends.server.workflowelement.localbackend;
 
@@ -35,6 +35,7 @@
 import org.forgerock.opendj.ldap.RDN;
 import org.forgerock.opendj.ldap.ResultCode;
 import org.forgerock.opendj.ldap.controls.RelaxRulesControl;
+import org.forgerock.opendj.ldap.controls.TransactionSpecificationRequestControl;
 import org.forgerock.opendj.ldap.schema.AttributeType;
 import org.forgerock.opendj.ldap.schema.MatchingRule;
 import org.forgerock.opendj.ldap.schema.ObjectClass;
@@ -60,6 +61,7 @@
 import org.opends.server.core.PasswordPolicy;
 import org.opends.server.core.PasswordPolicyState;
 import org.opends.server.core.PersistentSearch;
+import org.opends.server.protocols.ldap.LDAPControl;
 import org.opends.server.schema.AuthPasswordSyntax;
 import org.opends.server.schema.UserPasswordSyntax;
 import org.opends.server.types.AcceptRejectWarn;
@@ -77,10 +79,7 @@
 import org.opends.server.types.Privilege;
 import org.opends.server.types.SearchFilter;
 import org.opends.server.types.SynchronizationProviderResult;
-import org.opends.server.types.operation.PostOperationModifyOperation;
-import org.opends.server.types.operation.PostResponseModifyOperation;
-import org.opends.server.types.operation.PostSynchronizationModifyOperation;
-import org.opends.server.types.operation.PreOperationModifyOperation;
+import org.opends.server.types.operation.*;
 
 import static org.opends.messages.CoreMessages.*;
 import static org.opends.server.config.ConfigConstants.*;
@@ -96,7 +95,7 @@
        extends ModifyOperationWrapper
        implements PreOperationModifyOperation, PostOperationModifyOperation,
                   PostResponseModifyOperation,
-                  PostSynchronizationModifyOperation
+                  PostSynchronizationModifyOperation, RollbackOperation
 {
   private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
@@ -500,6 +499,9 @@
         }
 
         backend.replaceEntry(currentEntry, modifiedEntry, this);
+        if (trx!=null) {
+          trx.success(this);
+        }
 
         if (isAuthnManagedLocally())
         {
@@ -697,6 +699,10 @@
       {
         RelaxRulesControlRequested = true;
       }
+      else if (TransactionSpecificationRequestControl.OID.equals(oid))
+      {
+        trx=getClientConnection().getTransaction(((LDAPControl)c).getValue().toString());
+      }
       else if (c.isCritical() && !backend.supportsControl(oid))
       {
         throw newDirectoryException(currentEntry, ResultCode.UNAVAILABLE_CRITICAL_EXTENSION,
@@ -704,6 +710,7 @@
       }
     }
   }
+  ClientConnection.Transaction trx=null;
 
   private void processNonPasswordModifications() throws DirectoryException
   {
@@ -1658,4 +1665,9 @@
           }
       }
   }
+
+  @Override
+  public void rollback() throws CanceledOperationException, DirectoryException {
+      backend.replaceEntry(modifiedEntry,currentEntry, this);
+  }
 }
diff --git a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/Rfc5808TestCase.java b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/Rfc5808TestCase.java
new file mode 100644
index 0000000..916f253
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/Rfc5808TestCase.java
@@ -0,0 +1,260 @@
+/*
+ * 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 2025 3A Systems, LLC.
+ */
+package org.openidentityplatform.opendj;
+
+
+import com.forgerock.opendj.ldap.extensions.EndTransactionExtendedRequest;
+import com.forgerock.opendj.ldap.extensions.EndTransactionExtendedResult;
+import com.forgerock.opendj.ldap.extensions.StartTransactionExtendedRequest;
+import org.forgerock.opendj.ldap.*;
+import org.forgerock.opendj.ldap.controls.TransactionSpecificationRequestControl;
+import org.forgerock.opendj.ldap.requests.*;
+import com.forgerock.opendj.ldap.extensions.StartTransactionExtendedResult;
+import org.forgerock.opendj.ldap.responses.Result;
+import org.opends.server.DirectoryServerTestCase;
+import org.opends.server.TestCaseUtils;
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.forgerock.opendj.ldap.requests.Requests.newAddRequest;
+import static org.testng.Assert.assertThrows;
+
+@Test(sequential = true)
+public class Rfc5808TestCase extends DirectoryServerTestCase {
+    Connection connection;
+
+    @BeforeClass
+    public void startServer() throws Exception {
+        TestCaseUtils.startServer();
+        TestCaseUtils.initializeTestBackend(true);
+
+        final LDAPConnectionFactory factory =new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+        connection = factory.getConnection();
+        connection.bind("cn=Directory Manager", "password".toCharArray());
+        assertThat(connection.isValid()).isTrue();
+    }
+
+    @Test
+    public void test() throws LdapException {
+        //unknown transaction in TransactionSpecificationRequestControl
+        assertThrows(CancelledResultException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                AddRequest add=Requests
+                        .newAddRequest("ou=People,o=test")
+                        .addAttribute("objectClass", "top", "organizationalUnit")
+                        .addAttribute("ou", "People")
+                        .addControl(new TransactionSpecificationRequestControl("bad"))
+                        ;
+                Result result = connection.add(add);
+            }
+        });
+
+        //unknown transaction in EndTransactionExtendedRequest
+        assertThrows(CancelledResultException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                EndTransactionExtendedResult resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID("unknown").setCommit(true));
+            }
+        });
+        assertThrows(CancelledResultException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                EndTransactionExtendedResult resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID("unknown"));
+            }
+        });
+
+        //commit
+        StartTransactionExtendedResult resStart=connection.extendedRequest(new StartTransactionExtendedRequest());
+        assertThat(resStart.isSuccess()).isTrue();
+        assertThat(resStart.getOID()).isEqualTo("1.3.6.1.1.21.1");
+        String transactionID=resStart.getTransactionID();
+        assertThat(transactionID).isNotEmpty();
+
+        AddRequest add=Requests
+                .newAddRequest("ou=People,o=test")
+                .addAttribute("objectClass", "top", "organizationalUnit")
+                .addAttribute("ou", "People")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+                ;
+        Result result = connection.add(add);
+        assertThat(result.isSuccess()).isTrue();
+
+        add= Requests.newAddRequest("sn=bjensen,ou=People,o=test")
+                .addAttribute("objectClass","top","person")
+                .addAttribute("cn","bjensen")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+        ;
+        result = connection.add(add);
+        assertThat(result.isSuccess()).isTrue();
+
+        ModifyDNRequest mdn=Requests.newModifyDNRequest("sn=bjensen,ou=People,o=test","sn=bjensen2")
+        .addControl(new TransactionSpecificationRequestControl(transactionID))
+        ;
+        result = connection.modifyDN(mdn);
+        assertThat(result.isSuccess()).isTrue();
+
+        ModifyRequest edit= Requests.newModifyRequest("sn=bjensen2,ou=People,o=test")
+                .addModification(ModificationType.REPLACE,"cn","bjensen2")
+        .addControl(new TransactionSpecificationRequestControl(transactionID))
+        ;
+        result = connection.modify(edit);
+        assertThat(result.isSuccess()).isTrue();
+
+        DeleteRequest delete=Requests.newDeleteRequest("sn=bjensen2,ou=People,o=test")
+        .addControl(new TransactionSpecificationRequestControl(transactionID))
+        ;
+        result = connection.delete(delete);
+        assertThat(result.isSuccess()).isTrue();
+
+        assertThrows(EntryNotFoundException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                connection.searchSingleEntry("o=test",SearchScope.SINGLE_LEVEL,"(ou=People)");
+            }
+        });
+
+        EndTransactionExtendedResult resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID(transactionID));
+        assertThat(resEnd.isSuccess()).isTrue();
+        assertThat(resEnd.getOID()).isEqualTo("1.3.6.1.1.21.3");
+
+        //check commit successfully
+        assertThat(connection.searchSingleEntry("o=test",SearchScope.SINGLE_LEVEL,"(ou=People)")).isNotNull();
+
+        //check transaction finished
+        String finalTransactionID = transactionID;
+        assertThrows(CancelledResultException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                EndTransactionExtendedResult resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID(finalTransactionID).setCommit(true));
+            }
+        });
+
+        //rollback by EndTransactionExtendedRequest
+        resStart=connection.extendedRequest(new StartTransactionExtendedRequest());
+        assertThat(resStart.isSuccess()).isTrue();
+        assertThat(resStart.getOID()).isEqualTo("1.3.6.1.1.21.1");
+        transactionID=resStart.getTransactionID();
+        assertThat(transactionID).isNotEmpty();
+
+        add=Requests
+                .newAddRequest("ou=People2,o=test")
+                .addAttribute("objectClass", "top", "organizationalUnit")
+                .addAttribute("ou", "People2")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+                ;
+        result = connection.add(add);
+        assertThat(result.isSuccess()).isTrue();
+
+        resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID(transactionID).setCommit(false));
+        assertThat(resEnd.isSuccess()).isTrue();
+        assertThat(resEnd.getOID()).isEqualTo("1.3.6.1.1.21.3");
+
+        //check transaction finished
+        String finalTransactionID1 = transactionID;
+        assertThrows(CancelledResultException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                EndTransactionExtendedResult resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID(finalTransactionID1).setCommit(false));
+            }
+        });
+
+        //check rollback successfully
+        assertThrows(EntryNotFoundException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                connection.searchSingleEntry("o=test",SearchScope.SINGLE_LEVEL,"(ou=People2)");
+            }
+        });
+
+        //rollback by error
+        resStart=connection.extendedRequest(new StartTransactionExtendedRequest());
+        assertThat(resStart.isSuccess()).isTrue();
+        assertThat(resStart.getOID()).isEqualTo("1.3.6.1.1.21.1");
+        transactionID=resStart.getTransactionID();
+        assertThat(transactionID).isNotEmpty();
+
+        add= Requests.newAddRequest("sn=bjensen0,ou=People,o=test")
+                .addAttribute("objectClass","top","person")
+                .addAttribute("cn","bjensen0")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+        ;
+        result = connection.add(add);
+        assertThat(result.isSuccess()).isTrue();
+
+        add= Requests.newAddRequest("sn=bjensen,ou=People,o=test")
+                .addAttribute("objectClass","top","person")
+                .addAttribute("cn","bjensen")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+        ;
+        result = connection.add(add);
+        assertThat(result.isSuccess()).isTrue();
+
+        mdn=Requests.newModifyDNRequest("sn=bjensen,ou=People,o=test","sn=bjensen2")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+                ;
+        result = connection.modifyDN(mdn);
+        assertThat(result.isSuccess()).isTrue();
+
+        edit= Requests.newModifyRequest("sn=bjensen2,ou=People,o=test")
+                .addModification(ModificationType.REPLACE,"cn","bjensen2")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+                ;
+        result = connection.modify(edit);
+        assertThat(result.isSuccess()).isTrue();
+
+        delete=Requests.newDeleteRequest("sn=bjensen2,ou=People,o=test")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+                ;
+        result = connection.delete(delete);
+        assertThat(result.isSuccess()).isTrue();
+
+        delete=Requests.newDeleteRequest("sn=bjensen3,ou=People,o=test")
+                .addControl(new TransactionSpecificationRequestControl(transactionID))
+        ;
+        result = connection.delete(delete);
+        assertThat(result.isSuccess()).isTrue();
+
+        String finalTransactionID3 = transactionID;
+        assertThrows(EntryNotFoundException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                EndTransactionExtendedResult resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID(finalTransactionID3).setCommit(true));
+            }
+        });
+
+        //check transaction finished
+        String finalTransactionID2 = transactionID;
+        assertThrows(CancelledResultException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                EndTransactionExtendedResult resEnd=connection.extendedRequest(new EndTransactionExtendedRequest().setTransactionID(finalTransactionID2).setCommit(false));
+            }
+        });
+
+        //check rollback successfully
+        assertThrows(EntryNotFoundException.class, new Assert.ThrowingRunnable() {
+            @Override
+            public void run() throws Throwable {
+                connection.searchSingleEntry("ou=People,o=test",SearchScope.SINGLE_LEVEL,"(cn=bjensen0)");
+            }
+        });
+
+    }
+}

--
Gitblit v1.10.0