diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e6db8a0..49933556 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,16 @@ All notable changes to this project will be documented in this file.
`navigateToPageCitation` expose materialization and preview navigation. The MCP inline
preview remains explicitly continuous pending #434. See
[`docs/architecture/page_map.md`](docs/architecture/page_map.md).
+- **Atomic multi-step mutation batches** (issue #445). `DocxSession.ExecuteBatch`
+ and the reusable nested-safe `BeginTransaction` primitive checkpoint the complete
+ OPC package, relationship topology, anchor/revision generators, mutable session
+ configuration, version, and both undo/redo cursors. Atomic mode is the default:
+ all available preflights run before step zero; success advances the version once
+ and creates one undo unit; any failed or thrown step restores the exact package
+ and history state and returns its index/tool/action/error with `rolledBack: true`.
+ Explicit `best_effort` retains sequential partial-success behavior. The contract
+ is available through .NET/Ops/JSON, WASM/npm, stdio/Python, and MCP;
+ MCP's legacy `apply` spelling is now a deprecated alias for `best_effort`.
- **Optimistic mutation preconditions and a monotonic document version** (issue
#447). Every `DocxSession` starts at version `0` and advances exactly once for
each committed mutation, undo, or redo; failures and successful no-ops leave it
diff --git a/Docxodus.Tests/DocxSessionAtomicBatchTests.cs b/Docxodus.Tests/DocxSessionAtomicBatchTests.cs
new file mode 100644
index 00000000..eb7b8889
--- /dev/null
+++ b/Docxodus.Tests/DocxSessionAtomicBatchTests.cs
@@ -0,0 +1,608 @@
+#nullable enable
+
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using DocumentFormat.OpenXml.Packaging;
+using Xunit;
+
+namespace Docxodus.Tests;
+
+/// Complete-package atomic mutation batch regression coverage (issue #445).
+public class DocxSessionAtomicBatchTests
+{
+ [Fact]
+ public void DS454_AtomicSuccess_IsOneVersionAndOneUndoRedoUnit()
+ {
+ using var session = Open();
+ var paragraphs = BodyParagraphs(session);
+ var before = session.Save(persistAnchorIds: false);
+
+ var result = session.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ExecuteMutation(
+ new MutationPreconditions { ExpectedVersion = 0 },
+ x => x.ReplaceText(paragraphs[0], "Atomic first."))),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ExecuteMutation(
+ new MutationPreconditions { ExpectedVersion = 0 },
+ x => x.ReplaceText(paragraphs[1], "Atomic second."))),
+ });
+
+ Assert.True(result.Success);
+ Assert.False(result.RolledBack);
+ Assert.Equal(1, session.Version);
+ Assert.Equal(1, session.UndoCount);
+ Assert.Equal(0, session.RedoCount);
+ var edited = session.Save(persistAnchorIds: false);
+ Assert.Contains("Atomic first.", session.Project().Markdown);
+ Assert.Contains("Atomic second.", session.Project().Markdown);
+
+ Assert.True(session.Undo());
+ Assert.Equal(2, session.Version);
+ AssertSamePackage(before, session.Save(persistAnchorIds: false));
+ Assert.True(session.Redo());
+ Assert.Equal(3, session.Version);
+ AssertSamePackage(edited, session.Save(persistAnchorIds: false));
+ }
+
+ [Theory]
+ [InlineData("body")]
+ [InlineData("header")]
+ [InlineData("footer")]
+ [InlineData("note")]
+ [InlineData("comment")]
+ [InlineData("table")]
+ [InlineData("relationship")]
+ [InlineData("annotation")]
+ public void DS455_AtomicFailure_RestoresEveryStoryAndRelationship(string mutationKind)
+ {
+ using var session = Open();
+ var body = BodyParagraphs(session)[0];
+ var normalBefore = session.Save(persistAnchorIds: false);
+ var persistedBefore = session.Save(persistAnchorIds: true);
+ var anchorsBefore = session.Project().AnchorIndex.Keys.OrderBy(x => x).ToArray();
+ var hyperlinkCountBefore = session.LiveDocument.MainDocumentPart!.HyperlinkRelationships.Count();
+ var hyperlinkCountDuring = hyperlinkCountBefore;
+
+ EditResult Mutate(DocxSession s) => mutationKind switch
+ {
+ "body" => s.ReplaceText(body, "Changed body."),
+ "header" => s.SetHeaderText(body, HeaderFooterKind.Default, "Atomic header."),
+ "footer" => s.SetFooterText(body, HeaderFooterKind.Default, "Atomic footer."),
+ "note" => s.InsertFootnote(body, 3, "Atomic note."),
+ "comment" => s.AddComment(body, null, "Alice", "Atomic comment."),
+ "table" => s.InsertTable(body, Position.After, 2, 2),
+ "relationship" => AddHyperlink(s),
+ "annotation" => s.AddAnnotation(body, new CharSpan(0, 5), new DocumentAnnotation
+ {
+ Id = "atomic-annotation",
+ LabelId = "RISK",
+ Label = "Risk",
+ Color = "#FFCC00",
+ }),
+ _ => throw new ArgumentOutOfRangeException(nameof(mutationKind)),
+ };
+
+ EditResult AddHyperlink(DocxSession s)
+ {
+ var edit = s.ReplaceText(body, "Changed [link](https://example.test/atomic-batch)");
+ hyperlinkCountDuring = s.LiveDocument.MainDocumentPart!.HyperlinkRelationships.Count();
+ return edit;
+ }
+
+ var result = session.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", mutationKind, Mutate),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText("p:body:missing", "must fail")),
+ });
+
+ Assert.False(result.Success);
+ Assert.True(result.RolledBack);
+ Assert.Equal(1, result.Failure?.Index);
+ Assert.Equal("docx_edit", result.Failure?.Tool);
+ Assert.Equal("replace_text", result.Failure?.Action);
+ Assert.Equal(EditErrorCode.AnchorNotFound, result.Failure?.Error.Code);
+ Assert.True(result.Failure?.RolledBack);
+ Assert.All(result.Steps, step => Assert.True(step.RolledBack));
+ Assert.Equal(0, session.Version);
+ Assert.Equal(0, session.UndoCount);
+ Assert.Equal(0, session.RedoCount);
+ AssertSamePackage(normalBefore, session.Save(persistAnchorIds: false));
+ AssertSamePackage(persistedBefore, session.Save(persistAnchorIds: true));
+ Assert.Equal(anchorsBefore, session.Project().AnchorIndex.Keys.OrderBy(x => x).ToArray());
+ Assert.Equal(hyperlinkCountBefore, session.LiveDocument.MainDocumentPart!.HyperlinkRelationships.Count());
+ Assert.Empty(session.ListAnnotations());
+ if (mutationKind == "relationship")
+ Assert.True(hyperlinkCountDuring > hyperlinkCountBefore);
+ }
+
+ [Fact]
+ public void DS455B_ThrowingStep_AfterCrossPartMutationIsStructuredAndRolledBack()
+ {
+ using var session = Open();
+ var body = BodyParagraphs(session)[0];
+ var before = session.Save(persistAnchorIds: true);
+
+ var result = session.ExecuteBatch(new MutationBatchStep[]
+ {
+ new("docx_create", "set_header_text",
+ s => s.SetHeaderText(body, HeaderFooterKind.Default, "Speculative header.")),
+ new("docx_edit", "throw",
+ (Func)(_ => throw new InvalidOperationException("deliberate batch fault"))),
+ });
+
+ Assert.False(result.Success);
+ Assert.True(result.RolledBack);
+ Assert.Equal(1, result.Failure?.Index);
+ Assert.Equal("docx_edit", result.Failure?.Tool);
+ Assert.Equal("throw", result.Failure?.Action);
+ Assert.Equal(EditErrorCode.InternalError, result.Failure?.Error.Code);
+ Assert.Contains("deliberate batch fault", result.Failure?.Error.Message);
+ Assert.Equal(0, session.Version);
+ Assert.Equal(0, session.UndoCount);
+ AssertSamePackage(before, session.Save(persistAnchorIds: true));
+ Assert.DoesNotContain("Speculative header.", session.Project().Markdown);
+ }
+
+ [Fact]
+ public void DS455C_AtomicFailure_PreservesOpaqueCustomXmlPayloadAndRelationship()
+ {
+ var payload = Encoding.UTF8.GetBytes(
+ "\n untouched \n");
+ var seeded = DocxSessionTests.BuildDS001_SimpleTwoParagraphs();
+ using var source = new MemoryStream();
+ source.Write(seeded);
+ source.Position = 0;
+ string partUri;
+ string relationshipId;
+ using (var package = WordprocessingDocument.Open(source, isEditable: true))
+ {
+ var custom = package.MainDocumentPart!.AddCustomXmlPart(CustomXmlPartType.CustomXml);
+ custom.FeedData(new MemoryStream(payload));
+ partUri = custom.Uri.ToString();
+ relationshipId = package.MainDocumentPart.GetIdOfPart(custom);
+ package.Save();
+ }
+
+ using var session = new DocxSession(source.ToArray(), new DocxSessionSettings
+ {
+ CaptureInitialProjection = false,
+ PersistAnchorIds = false,
+ });
+ var anchor = BodyParagraphs(session)[0];
+ var result = session.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(anchor, "Speculative body edit.")),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText("p:body:missing", "failure")),
+ });
+
+ Assert.False(result.Success);
+ Assert.True(result.RolledBack);
+ var restored = session.LiveDocument.MainDocumentPart!.CustomXmlParts
+ .Single(part => part.Uri.ToString() == partUri);
+ Assert.Equal(relationshipId, session.LiveDocument.MainDocumentPart.GetIdOfPart(restored));
+ Assert.Equal(payload, ReadPartBytes(restored));
+
+ // A normal output save may inspect custom XML while finding Docxodus annotations, but
+ // opaque application payloads must remain byte-for-byte untouched.
+ var saved = session.Save(persistAnchorIds: false);
+ using var reopened = WordprocessingDocument.Open(new MemoryStream(saved), isEditable: false);
+ var savedPart = reopened.MainDocumentPart!.CustomXmlParts
+ .Single(part => part.Uri.ToString() == partUri);
+ Assert.Equal(relationshipId, reopened.MainDocumentPart.GetIdOfPart(savedPart));
+ Assert.Equal(payload, ReadPartBytes(savedPart));
+ }
+
+ [Fact]
+ public void DS456_AtomicFailure_RestoresRedoCursorAndHistoryDiagnostics()
+ {
+ using var session = Open(new DocxSessionSettings
+ {
+ PersistAnchorIds = true,
+ UndoDepth = 1,
+ });
+ var body = BodyParagraphs(session)[0];
+ Assert.True(session.ReplaceText(body, "History edit.").Success);
+ Assert.True(session.Undo());
+ Assert.Equal(0, session.UndoCount);
+ Assert.Equal(1, session.RedoCount);
+ var trimmedBefore = session.UndoHistoryTrimmedForMemory;
+ var memoryBefore = session.UndoMemoryBytes;
+ var versionBefore = session.Version;
+
+ var result = session.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text", s => s.ReplaceText(body, "Speculative.")),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText("p:body:missing", "failure")),
+ });
+
+ Assert.False(result.Success);
+ Assert.Equal(versionBefore, session.Version);
+ Assert.Equal(0, session.UndoCount);
+ Assert.Equal(1, session.RedoCount);
+ Assert.Equal(trimmedBefore, session.UndoHistoryTrimmedForMemory);
+ Assert.Equal(memoryBefore, session.UndoMemoryBytes);
+ Assert.False(session.Undo());
+ Assert.True(session.Redo());
+ Assert.Contains("History edit.", session.Project().Markdown);
+ }
+
+ [Fact]
+ public void DS457_Transactions_AreNestedLifoAndOuterCommitSquashesInnerWork()
+ {
+ using var session = Open();
+ var paragraphs = BodyParagraphs(session);
+ var before = session.Save(persistAnchorIds: false);
+
+ using (var outer = session.BeginTransaction())
+ {
+ Assert.True(session.ReplaceText(paragraphs[0], "Outer edit.").Success);
+ using (var inner = session.BeginTransaction())
+ {
+ Assert.True(session.SetHeaderText(
+ paragraphs[0], HeaderFooterKind.Default, "Rolled-back header.").Success);
+ inner.Rollback();
+ }
+
+ Assert.DoesNotContain("Rolled-back header.", session.Project().Markdown);
+ using (var inner = session.BeginTransaction())
+ {
+ Assert.True(session.ReplaceText(paragraphs[1], "Inner committed edit.").Success);
+ inner.Commit();
+ }
+
+ Assert.Equal(0, session.Version);
+ Assert.False(session.Undo());
+ outer.Commit();
+ }
+
+ Assert.Equal(1, session.Version);
+ Assert.Equal(1, session.UndoCount);
+ Assert.Contains("Outer edit.", session.Project().Markdown);
+ Assert.Contains("Inner committed edit.", session.Project().Markdown);
+ Assert.True(session.Undo());
+ AssertSamePackage(before, session.Save(persistAnchorIds: false));
+ }
+
+ [Fact]
+ public void DS457B_OutOfOrderCompletionLeavesBothScopesRecoverable()
+ {
+ using var session = Open();
+ var anchor = BodyParagraphs(session)[0];
+ var before = session.Save(persistAnchorIds: true);
+ var outer = session.BeginTransaction();
+ Assert.True(session.ReplaceText(anchor, "Outer speculative edit.").Success);
+ var inner = session.BeginTransaction();
+ Assert.True(session.SetHeaderText(
+ anchor, HeaderFooterKind.Default, "Inner speculative header.").Success);
+
+ Assert.Throws(() => outer.Commit());
+ Assert.False(outer.IsCompleted);
+ Assert.False(inner.IsCompleted);
+
+ inner.Commit();
+ Assert.True(inner.IsCompleted);
+ Assert.False(outer.IsCompleted);
+ outer.Rollback();
+
+ Assert.True(outer.IsCompleted);
+ Assert.Equal(0, session.Version);
+ Assert.Equal(0, session.UndoCount);
+ AssertSamePackage(before, session.Save(persistAnchorIds: true));
+ }
+
+ [Fact]
+ public void DS457C_WrongThreadCompletionLeavesScopeRecoverableByOwner()
+ {
+ using var session = Open();
+ var before = session.Save(persistAnchorIds: false);
+ var transaction = session.BeginTransaction();
+ Assert.True(session.ReplaceText(
+ BodyParagraphs(session)[0], "Wrong-thread speculative edit.").Success);
+
+ Exception? failure = null;
+ var wrongThread = new Thread(() => failure = Record.Exception(transaction.Rollback));
+ wrongThread.Start();
+ wrongThread.Join();
+ Assert.IsType(failure);
+ Assert.False(transaction.IsCompleted);
+
+ transaction.Rollback();
+ Assert.True(transaction.IsCompleted);
+ AssertSamePackage(before, session.Save(persistAnchorIds: false));
+ Assert.Equal(0, session.Version);
+ Assert.Equal(0, session.UndoCount);
+ }
+
+ [Fact]
+ public void DS457D_DisposingSessionAbandonsScopesWithoutHoldingGateOrResurrectingPackage()
+ {
+ var session = Open();
+ var mutationGate = PrivateField