diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39a875cb..d6956bdb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,39 @@ 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).
+- **Intrinsically isolated mutation preview** (issue #446). `.NET` `PreviewBatch`,
+ Ops/JSON, WASM/npm, stdio/Python, and MCP now run the identical atomic or explicit
+ `best_effort` batch path on a complete shadow package instead of applying to the live
+ session and undoing. The clone carries every OPC part/relationship/media/custom-XML
+ payload plus version, mutable configuration, diff baseline, and id generators, while
+ caches and undo/redo history remain independent; failure, interruption, disposal, and
+ abandonment therefore cannot touch live bytes or history. Rich apply/preview receipts
+ include predicted versions, per-step created/removed/modified anchors and patches,
+ revision/comment/annotation deltas, warnings, a canonical package-content SHA-256, and
+ optional scoped/full shadow-only HTML. Deterministic previews and applies have exact
+ receipt/hash equivalence. Operations that generate anchors/OOXML ids or timestamps are
+ explicitly semantic-equivalence-only (same outcomes and structure/content/relationship
+ effects modulo generated metadata) and emit warnings. This supersedes the undo-depth,
+ redo-destruction, and crash window described in #468.
+
+ The preview HTML profile has a single owner (`HtmlConversionOps.PreviewDocumentOptions`
+ / `PreviewBlockOptions`), reached from the browser through the new
+ `RenderPreviewHtml` / `RenderPreviewBlockHtml` bridge exports, so every surface's
+ preview of the same batch describes the same document (tracked changes, comments,
+ annotations, notes, and headers/footers shown) rather than the editor's authoring
+ render. Receipt change-set membership is compared on each entry's serialized wire
+ projection rather than CLR equality, matching what the browser client compares.
+ `packageHash` is `null`, never `""`, when it could not be computed, so an absent hash
+ cannot satisfy a replay-equality assertion. `MutationPreviewHtmlMode` is exposed to
+ Python as an enum (`docx_scalpel.MutationPreviewHtmlMode`).
+
+ **Cost note.** Receipt enrichment is unconditional on both the apply and the preview
+ path: each batch inspects revisions, comments, and annotations twice (each forcing an
+ anchor index) and computes a package-content hash, which serializes and hashes a full
+ package checkpoint. A preview additionally clones the package and opens a second
+ `WordprocessingDocument`, roughly doubling peak memory for its duration — material for a
+ large document on a browser WASM heap. There is deliberately no opt-out in this release;
+ whether to gate enrichment behind a setting remains an open public-API decision.
- **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
diff --git a/Docxodus.Tests/DocxSessionPreviewBatchTests.cs b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs
new file mode 100644
index 00000000..e5c2e47b
--- /dev/null
+++ b/Docxodus.Tests/DocxSessionPreviewBatchTests.cs
@@ -0,0 +1,864 @@
+#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.IO.Compression;
+using System.Linq;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using DocumentFormat.OpenXml.Packaging;
+using Docxodus.Internal;
+using Xunit;
+
+namespace Docxodus.Tests;
+
+/// Complete-package isolated preview regression coverage (issue #446).
+public class DocxSessionPreviewBatchTests
+{
+ [Fact]
+ public void DS461_PreviewSuccessFailureThrowAndBestEffort_NeverTouchLiveState()
+ {
+ using var session = OpenRich(new DocxSessionSettings
+ {
+ PersistAnchorIds = true,
+ UndoDepth = 1,
+ TrackedChanges = TrackedChangeMode.RenderInline,
+ RevisionAuthor = "Preview Author",
+ });
+ var anchors = BodyParagraphs(session);
+
+ // Seed a redo cursor and a tight history ring: the former apply-and-undo implementation
+ // destroyed redo here and could underflow after more preview steps than UndoDepth.
+ Assert.True(session.ReplaceText(anchors[0], "Redo target.").Success);
+ Assert.True(session.Undo());
+ _ = session.Project();
+ _ = session.AnchorIndex();
+ var before = Fingerprint.Capture(session);
+
+ var success = session.PreviewBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(anchors[0], "Predicted tracked replacement.")),
+ new MutationBatchStep("docx_create", "set_header_text",
+ s => s.SetHeaderText(anchors[0], HeaderFooterKind.Default, "Predicted header.")),
+ new MutationBatchStep("docx_comment", "add",
+ s => s.AddComment(anchors[1], null, "Alice", "Predicted comment.",
+ date: new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc))),
+ new MutationBatchStep("docx_annotate", "add",
+ s => s.AddAnnotation(anchors[1], new CharSpan(0, 3), new DocumentAnnotation
+ {
+ Id = "preview-ann",
+ LabelId = "RISK",
+ Label = "Risk",
+ Color = "#FFCC00",
+ Created = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc),
+ })),
+ }, options: new MutationBatchPreviewOptions
+ {
+ HtmlMode = MutationPreviewHtmlMode.Full,
+ });
+
+ Assert.True(success.Preview);
+ Assert.True(success.Success,
+ success.Failure is null
+ ? "preview failed without a failure envelope"
+ : $"{success.Failure.Index}:{success.Failure.Action}:{success.Failure.Error.Code}:{success.Failure.Error.Message}");
+ Assert.Equal(before.Version, success.BaseVersion);
+ Assert.Equal(before.Version + 1, success.ResultVersion);
+ Assert.NotNull(success.PackageHash);
+ Assert.NotEmpty(success.PackageHash);
+ Assert.Equal(4, success.Steps.Count);
+ Assert.NotEmpty(success.RevisionChanges.Added);
+ Assert.Single(success.CommentChanges.Added);
+ Assert.Single(success.AnnotationChanges.Added);
+ Assert.Contains(success.Warnings,
+ warning => warning.Contains("Comment date attributes", StringComparison.Ordinal));
+ Assert.Contains("Predicted tracked replacement.", success.Html);
+ before.AssertUnchanged(session);
+
+ var failed = session.PreviewBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(anchors[0], "Rolled back in the shadow.")),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText("p:body:missing", "failure")),
+ });
+ Assert.False(failed.Success);
+ Assert.True(failed.RolledBack);
+ Assert.Empty(failed.RevisionChanges.Added);
+ before.AssertUnchanged(session);
+
+ var thrown = session.PreviewBatch(new MutationBatchStep[]
+ {
+ new("docx_edit", "replace_text",
+ s => s.ReplaceText(anchors[0], "Thrown away in shadow.")),
+ new("docx_edit", "throw",
+ (Func)(_ => throw new InvalidOperationException("preview fault"))),
+ });
+ Assert.False(thrown.Success);
+ Assert.Equal(EditErrorCode.InternalError, thrown.Failure?.Error.Code);
+ before.AssertUnchanged(session);
+
+ var partial = session.PreviewBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(anchors[0], "Retained only in best-effort shadow.")),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText("p:body:missing", "failure")),
+ new MutationBatchStep("docx_create", "set_footer_text",
+ s => s.SetFooterText(anchors[1], HeaderFooterKind.Default, "Shadow footer.")),
+ }, MutationBatchMode.BestEffort);
+ Assert.False(partial.Success);
+ Assert.False(partial.RolledBack);
+ Assert.Equal(before.Version + 2, partial.ResultVersion);
+ Assert.Contains(partial.Warnings, value => value.Contains("Best-effort", StringComparison.Ordinal));
+ before.AssertUnchanged(session);
+
+ // The original redo remains usable after every preview, including batches longer than
+ // UndoDepth. This explicitly supersedes the undo-too-many failure mode from #468.
+ Assert.False(session.Undo());
+ Assert.True(session.Redo());
+ Assert.Contains("Redo target.", session.Project().Markdown);
+ }
+
+ [Fact]
+ public void DS462_DisposedOrAbandonedShadow_IsIntrinsicallySafe()
+ {
+ using var live = OpenRich();
+ var anchor = BodyParagraphs(live)[0];
+ var before = Fingerprint.Capture(live);
+
+ var shadow = live.CreateShadowSession();
+ var liveSettings = PrivateField(live, "_settings");
+ var shadowSettings = PrivateField(shadow, "_settings");
+ Assert.NotSame(liveSettings, shadowSettings);
+ Assert.NotSame(liveSettings.ProjectionSettings, shadowSettings.ProjectionSettings);
+ shadowSettings.ProjectionSettings.HeadingLevelOffset++;
+ Assert.True(shadow.ReplaceText(anchor, "Only the abandoned clone changes.").Success);
+ Assert.Contains("Only the abandoned clone changes.", shadow.Project().Markdown);
+ before.AssertUnchanged(live); // live is safe even while the shadow is still in flight
+ shadow.Dispose();
+ before.AssertUnchanged(live);
+
+ // Timeout-style abandonment: work can fault/dispose independently because no rollback of
+ // live state is ever needed.
+ var task = Task.Run(() =>
+ {
+ using var timedOutShadow = live.CreateShadowSession();
+ Assert.True(timedOutShadow.SetHeaderText(
+ anchor, HeaderFooterKind.Default, "Timed-out shadow.").Success);
+ throw new TimeoutException("simulated caller abandonment");
+ });
+ Assert.IsType(Record.Exception(() => task.GetAwaiter().GetResult()));
+ before.AssertUnchanged(live);
+ }
+
+ [Fact]
+ public void DS463_DeterministicPreviewAndApply_HaveIdenticalReceiptsAndPackageHash()
+ {
+ using var session = OpenRich();
+ var anchors = BodyParagraphs(session);
+ Assert.True(session.ReplaceText(anchors[0], "Existing live change from initial baseline.").Success);
+ var expectedDiff = session.GetDiff();
+ var expectedTransactionState = PrivateField(session, "_nextTransactionId");
+ string? previewDiff = null;
+ long previewPreflightTransaction = -1;
+ long previewMutationTransaction = -1;
+ var previewSteps = new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s =>
+ {
+ previewMutationTransaction = PrivateField(s, "_nextTransactionId");
+ return s.ReplaceText(anchors[0], "Deterministic replacement.");
+ },
+ s =>
+ {
+ previewDiff = s.GetDiff();
+ previewPreflightTransaction = PrivateField(s, "_nextTransactionId");
+ return null;
+ }),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(anchors[1], "Deterministic second replacement.")),
+ };
+
+ var preview = session.PreviewBatch(previewSteps);
+ Assert.Equal(expectedDiff, previewDiff);
+ Assert.Equal(expectedTransactionState + 1, previewPreflightTransaction);
+ Assert.Equal(expectedTransactionState + 1, previewMutationTransaction);
+ Assert.Equal(1, session.Version);
+
+ string? applyDiff = null;
+ long applyPreflightTransaction = -1;
+ long applyMutationTransaction = -1;
+ var applySteps = new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s =>
+ {
+ applyMutationTransaction = PrivateField(s, "_nextTransactionId");
+ return s.ReplaceText(anchors[0], "Deterministic replacement.");
+ },
+ s =>
+ {
+ applyDiff = s.GetDiff();
+ applyPreflightTransaction = PrivateField(s, "_nextTransactionId");
+ return null;
+ }),
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(anchors[1], "Deterministic second replacement.")),
+ };
+ var applied = session.ExecuteBatch(applySteps);
+
+ Assert.Equal(previewDiff, applyDiff);
+ Assert.Equal(previewPreflightTransaction, applyPreflightTransaction);
+ Assert.Equal(previewMutationTransaction, applyMutationTransaction);
+
+ Assert.True(preview.Preview);
+ Assert.False(applied.Preview);
+ Assert.Equal(preview.BaseVersion, applied.BaseVersion);
+ Assert.Equal(preview.ResultVersion, applied.ResultVersion);
+ Assert.Equal(preview.PackageHash, applied.PackageHash);
+ Assert.Equal(
+ preview.Steps.Select(Receipt),
+ applied.Steps.Select(Receipt));
+ Assert.Equal(ChangeReceipt(preview.RevisionChanges), ChangeReceipt(applied.RevisionChanges));
+ Assert.Equal(ChangeReceipt(preview.CommentChanges), ChangeReceipt(applied.CommentChanges));
+ Assert.Equal(ChangeReceipt(preview.AnnotationChanges), ChangeReceipt(applied.AnnotationChanges));
+
+ static string Receipt(MutationBatchStepResult step) =>
+ $"{step.Index}|{step.Tool}|{step.Action}|{step.Success}|" +
+ string.Join(";", step.Results.Select(result =>
+ $"{result.Success}:{string.Join(',', result.Created.Select(a => a.Id))}:" +
+ $"{string.Join(',', result.Removed.Select(a => a.Id))}:" +
+ $"{string.Join(',', result.Modified.Select(a => a.Id))}"));
+
+ static string ChangeReceipt(MutationBatchChangeSet changes) =>
+ $"{changes.Added.Count}|{changes.Removed.Count}|{changes.Modified.Count}";
+ }
+
+ [Fact]
+ public void DS464_HandlePreviewFactory_CannotAccidentallyTargetTheLiveHandle()
+ {
+ var handle = DocxSessionOps.OpenSession(RichBytes(), new DocxSessionSettings
+ {
+ PersistAnchorIds = true,
+ UndoDepth = 1,
+ });
+ try
+ {
+ using var projection = System.Text.Json.JsonDocument.Parse(DocxSessionOps.Project(handle));
+ var anchor = projection.RootElement.GetProperty("anchorIndex")
+ .EnumerateObject().First(property => property.Name.StartsWith("p:body:", StringComparison.Ordinal)).Name;
+ _ = DocxSessionOps.Save(handle, persistAnchorIds: false);
+ _ = DocxSessionOps.Save(handle, persistAnchorIds: true);
+ var beforeNormal = DocxSessionOps.Save(handle, persistAnchorIds: false);
+ var beforePersisted = DocxSessionOps.Save(handle, persistAnchorIds: true);
+ var beforeVersion = DocxSessionOps.GetVersion(handle);
+
+ var json = DocxSessionOps.PreviewBatch(
+ handle,
+ MutationBatchMode.Atomic,
+ shadowHandle => new[]
+ {
+ DocxSessionOps.SerializedBatchStep(
+ "docx_scalpel",
+ "replace_text",
+ () => DocxSessionOps.ReplaceText(
+ shadowHandle, anchor, "Handle-only predicted edit.")),
+ });
+
+ using var result = System.Text.Json.JsonDocument.Parse(json);
+ Assert.True(result.RootElement.GetProperty("preview").GetBoolean());
+ Assert.True(result.RootElement.GetProperty("success").GetBoolean());
+ Assert.Equal(beforeVersion, DocxSessionOps.GetVersion(handle));
+ var afterNormal = DocxSessionOps.Save(handle, persistAnchorIds: false);
+ var afterPersisted = DocxSessionOps.Save(handle, persistAnchorIds: true);
+ Assert.Equal(beforeNormal, afterNormal);
+ Assert.Equal(beforePersisted, afterPersisted);
+ }
+ finally
+ {
+ DocxSessionOps.CloseSession(handle);
+ }
+ }
+
+ [Fact]
+ public void DS465_PostCommitInspectionFailure_IsWarningNotApparentMutationFailure()
+ {
+ var bytes = DocxSessionTests.BuildDS001_SimpleTwoParagraphs();
+ using var stream = new MemoryStream();
+ stream.Write(bytes);
+ stream.Position = 0;
+ using (var package = WordprocessingDocument.Open(stream, isEditable: true))
+ {
+ const string paraId = "A1B2C3D4";
+ var main = package.MainDocumentPart!;
+ var comments = main.AddNewPart();
+ comments.PutXDocument(new XDocument(
+ new XElement(W.comments,
+ new XElement(W.comment,
+ new XAttribute(W.id, "1"),
+ new XAttribute(W.author, "Observer"),
+ new XElement(W.p,
+ new XAttribute(W14.paraId, paraId),
+ new XElement(W.r, new XElement(W.t, "comment")))))));
+ package.Save();
+ }
+
+ using var session = new DocxSession(stream.ToArray());
+ Assert.Single(session.ListComments());
+ var anchor = BodyParagraphs(session)[0];
+ var result = session.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s =>
+ {
+ var edit = s.ReplaceText(anchor, "The mutation still commits.");
+ if (!edit.Success) return edit;
+
+ // Simulate a failure in optional receipt enrichment only after the mutation
+ // has committed its ordinary operation state.
+ var document = PrivateField(s, "_doc");
+ var commentsEx = document.MainDocumentPart!
+ .AddNewPart();
+ commentsEx.FeedData(new MemoryStream(Encoding.UTF8.GetBytes(" value.Contains("Comment delta inspection unavailable", StringComparison.Ordinal));
+ Assert.Empty(result.CommentChanges.Added);
+ }
+
+ [Fact]
+ public void DS466_InvalidPreviewHtmlMode_IsRejectedBeforeShadowExecution()
+ {
+ using var session = OpenRich();
+ var invoked = false;
+ Assert.Throws(() => session.PreviewBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "never",
+ s => { invoked = true; return s.ReplaceText(BodyParagraphs(s)[0], "not run"); }),
+ }, options: new MutationBatchPreviewOptions
+ {
+ HtmlMode = (MutationPreviewHtmlMode)12345,
+ }));
+ Assert.False(invoked);
+ Assert.Equal(0, session.Version);
+ }
+
+ [Fact]
+ public void DS467_CreatePreviewApply_AreSemanticallyEquivalentModuloGeneratedIds()
+ {
+ using var session = OpenRich();
+ var anchor = BodyParagraphs(session)[0];
+ var steps = new[]
+ {
+ new MutationBatchStep("docx_create", "insert_paragraph",
+ s => s.InsertParagraph(anchor, Position.After, "Generated-id paragraph.")),
+ };
+
+ var preview = session.PreviewBatch(steps, options: new MutationBatchPreviewOptions
+ {
+ HtmlMode = MutationPreviewHtmlMode.Full,
+ });
+ var applied = session.ExecuteBatch(steps);
+ var appliedHtml = HtmlConversionOps.ConvertToHtml(session, new HtmlConversionOptions
+ {
+ CommentRenderMode = 0,
+ RenderAnnotations = true,
+ RenderFootnotesAndEndnotes = true,
+ RenderHeadersAndFooters = true,
+ RenderTrackedChanges = true,
+ StampAnchors = true,
+ });
+
+ var previewCreated = Assert.Single(Assert.Single(preview.Steps).Results).Created;
+ var appliedCreated = Assert.Single(Assert.Single(applied.Steps).Results).Created;
+ Assert.Equal(previewCreated.Select(anchor => (anchor.Kind, anchor.Scope)),
+ appliedCreated.Select(anchor => (anchor.Kind, anchor.Scope)));
+ Assert.NotEqual(previewCreated.Select(anchor => anchor.Id), appliedCreated.Select(anchor => anchor.Id));
+ Assert.NotEqual(preview.PackageHash, applied.PackageHash);
+ Assert.Contains(preview.Warnings,
+ warning => warning.Contains("equivalence is semantic", StringComparison.Ordinal));
+ Assert.Contains(applied.Warnings,
+ warning => warning.Contains("equivalence is semantic", StringComparison.Ordinal));
+ Assert.Equal(NormalizeGeneratedIds(preview.Html!), NormalizeGeneratedIds(appliedHtml));
+ Assert.Contains("Generated-id paragraph.", appliedHtml);
+ }
+
+ [Fact]
+ public void DS468_ReceiptEnrichment_IsSerializedWithConcurrentMutations()
+ {
+ using var session = OpenRich();
+ var anchors = BodyParagraphs(session);
+ using var receiptInspectionEntered = new ManualResetEventSlim();
+ using var releaseReceiptInspection = new ManualResetEventSlim();
+ using var concurrentMutationStarted = new ManualResetEventSlim();
+ var blockingCreated = new BlockingAnchorList(
+ receiptInspectionEntered, releaseReceiptInspection);
+
+ var batchTask = Task.Run(() => session.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s =>
+ {
+ var edit = s.ReplaceText(anchors[0], "Batch mutation.");
+ return new EditResult
+ {
+ Success = edit.Success,
+ Error = edit.Error,
+ Created = blockingCreated,
+ Removed = edit.Removed,
+ Modified = edit.Modified,
+ Patch = edit.Patch,
+ AnnotationId = edit.AnnotationId,
+ };
+ }),
+ }));
+
+ Task? concurrentTask = null;
+ try
+ {
+ Assert.True(receiptInspectionEntered.Wait(TimeSpan.FromSeconds(10)),
+ "batch did not reach receipt enrichment");
+ concurrentTask = Task.Run(() =>
+ {
+ concurrentMutationStarted.Set();
+ return session.ExecuteMutation(
+ preconditions: null,
+ s => s.ReplaceText(anchors[1], "Concurrent mutation."));
+ });
+ Assert.True(concurrentMutationStarted.Wait(TimeSpan.FromSeconds(10)),
+ "concurrent mutation task did not start");
+ Assert.False(concurrentTask.Wait(TimeSpan.FromSeconds(1)),
+ "concurrent mutation interleaved with batch receipt enrichment");
+ }
+ finally
+ {
+ releaseReceiptInspection.Set();
+ }
+
+ var batch = batchTask.GetAwaiter().GetResult();
+ var concurrent = concurrentTask!.GetAwaiter().GetResult();
+ Assert.True(batch.Success);
+ Assert.True(concurrent.Success);
+ Assert.Equal(0, batch.BaseVersion);
+ Assert.Equal(1, batch.ResultVersion);
+ Assert.Equal(2, session.Version);
+ Assert.Contains("Batch mutation.", session.Project().Markdown);
+ Assert.Contains("Concurrent mutation.", session.Project().Markdown);
+ }
+
+ ///
+ /// Revision classification on an ALREADY-REDLINED document. The receipt's change sets are a
+ /// before∩after comparison, so a comparison that is not value-based reports every surviving
+ /// pre-existing revision as modified — and then cascades into the execution-clock warning
+ /// that tells callers not to trust packageHash. Both the apply and the preview path
+ /// run the same enrichment, so both are asserted.
+ ///
+ [Fact]
+ public void DS469_PreExistingRevisions_AreNeverReclassifiedByAnUnrelatedBatch()
+ {
+ var redlined = RedlinedBytes(out var redlinedAnchors);
+
+ // Untracked batch: nothing about the document's revisions changes, so every change set
+ // must be empty and the revision-date warning must not fire.
+ using (var untracked = new DocxSession(redlined, new DocxSessionSettings
+ {
+ PersistAnchorIds = true,
+ TrackedChanges = TrackedChangeMode.Accept,
+ }))
+ {
+ var existing = untracked.ListRevisions();
+ Assert.NotEmpty(existing);
+
+ var preview = untracked.PreviewBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(redlinedAnchors[1], "Untouched by the redlines.")),
+ });
+
+ Assert.True(preview.Success);
+ Assert.Empty(preview.RevisionChanges.Added);
+ Assert.Empty(preview.RevisionChanges.Removed);
+ Assert.Empty(preview.RevisionChanges.Modified);
+ Assert.DoesNotContain(preview.Warnings,
+ warning => warning.Contains("Tracked-revision date attributes", StringComparison.Ordinal));
+ }
+
+ // Tracked batch: the batch's own revision is added; the pre-existing ones it never
+ // touched stay out of every bucket.
+ using (var tracked = new DocxSession(redlined, new DocxSessionSettings
+ {
+ PersistAnchorIds = true,
+ TrackedChanges = TrackedChangeMode.RenderInline,
+ RevisionAuthor = "Batch Author",
+ }))
+ {
+ var existingIds = tracked.ListRevisions()
+ .Select(revision => revision.Id).ToHashSet(StringComparer.Ordinal);
+ Assert.NotEmpty(existingIds);
+
+ var applied = tracked.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(redlinedAnchors[1], "Tracked batch replacement.")),
+ });
+
+ Assert.True(applied.Success);
+ Assert.NotEmpty(applied.RevisionChanges.Added);
+ Assert.Empty(applied.RevisionChanges.Removed);
+ Assert.Empty(applied.RevisionChanges.Modified);
+ Assert.DoesNotContain(applied.RevisionChanges.Added,
+ revision => existingIds.Contains(revision.Id));
+ }
+ }
+
+ ///
+ /// Cross-surface preview HTML profile. The typed core renders preview HTML directly; the
+ /// callback-shaped npm client cannot, so it renders its shadow through the handle façade.
+ /// Both must resolve to ONE profile, or the same batch previewed from a browser and from
+ /// stdio/MCP describes two different documents. The editor's own render profile is asserted
+ /// to be the wrong answer here on purpose — that is what npm used to call, and it silently
+ /// drops comments, annotations and headers/footers.
+ ///
+ [Fact]
+ public void DS470_PreviewHtmlProfile_IsOwnedByTheFacadeAndNotTheEditorRenderProfile()
+ {
+ var commented = CommentedBytes(out var commentedAnchors);
+ var settings = new DocxSessionSettings { PersistAnchorIds = true };
+
+ using var typed = new DocxSession(commented, settings);
+ var typedPreview = typed.PreviewBatch(
+ new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(commentedAnchors[1], "Predicted body text.")),
+ },
+ options: new MutationBatchPreviewOptions { HtmlMode = MutationPreviewHtmlMode.Full });
+ Assert.True(typedPreview.Success);
+
+ // The façade path the browser client uses: clone, mutate the clone, render the clone.
+ var liveHandle = SessionRegistry.OpenSession(commented, settings);
+ try
+ {
+ var shadowHandle = SessionRegistry.CloneSessionForPreview(liveHandle);
+ string facadeHtml;
+ string editorHtml;
+ try
+ {
+ Assert.True(SessionRegistry.Get(shadowHandle)
+ .ReplaceText(commentedAnchors[1], "Predicted body text.").Success);
+ facadeHtml = DocxSessionOps.RenderPreviewHtml(shadowHandle);
+ editorHtml = DocxSessionOps.RenderHtml(shadowHandle, "docx-", false, false, 1);
+ }
+ finally
+ {
+ SessionRegistry.CloseSession(shadowHandle);
+ }
+
+ Assert.Equal(typedPreview.Html, facadeHtml);
+ Assert.Contains("Predicted body text.", facadeHtml, StringComparison.Ordinal);
+
+ // What the profiles disagree about, stated rather than implied.
+ Assert.Contains("Reviewer comment body.", facadeHtml, StringComparison.Ordinal);
+ Assert.Contains("Preview header.", facadeHtml, StringComparison.Ordinal);
+ Assert.DoesNotContain("Reviewer comment body.", editorHtml, StringComparison.Ordinal);
+ Assert.DoesNotContain("Preview header.", editorHtml, StringComparison.Ordinal);
+ }
+ finally
+ {
+ SessionRegistry.CloseSession(liveHandle);
+ }
+ }
+
+ ///
+ /// An unavailable package hash is null on the wire, never "". Two receipts
+ /// that both failed to hash must NOT satisfy
+ /// preview.packageHash == applied.packageHash — the replay assertion the docs
+ /// describe has to fail loudly when it has nothing to compare.
+ ///
+ [Fact]
+ public void DS471_UnavailablePackageHash_IsNullOnTheWireNotAnEmptySentinel()
+ {
+ var unavailable = DocxSessionJson.SerializeMutationBatchResult(new MutationBatchResult
+ {
+ Mode = MutationBatchMode.Atomic,
+ Success = true,
+ Warnings = new[] { "Package equivalence hash unavailable: simulated." },
+ });
+ Assert.Contains("\"packageHash\":null", unavailable, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"packageHash\":\"\"", unavailable, StringComparison.Ordinal);
+
+ using var session = OpenRich();
+ var applied = session.ExecuteBatch(new[]
+ {
+ new MutationBatchStep("docx_edit", "replace_text",
+ s => s.ReplaceText(BodyParagraphs(s)[0], "Hashed.")),
+ });
+ Assert.NotNull(applied.PackageHash);
+ Assert.Contains($"\"packageHash\":\"{applied.PackageHash}\"",
+ DocxSessionJson.SerializeMutationBatchResult(applied), StringComparison.Ordinal);
+ }
+
+ /// Bytes carrying a comment and a default header — the parts the editor's render
+ /// profile drops and a preview's must keep.
+ private static byte[] CommentedBytes(out string[] anchors)
+ {
+ using var seed = OpenRich(new DocxSessionSettings { PersistAnchorIds = true });
+ var seedAnchors = BodyParagraphs(seed);
+ Assert.True(seed.AddComment(seedAnchors[0], null, "Reviewer", "Reviewer comment body.",
+ date: new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc)).Success);
+ Assert.True(seed.SetHeaderText(
+ seedAnchors[0], HeaderFooterKind.Default, "Preview header.").Success);
+ var bytes = seed.Save(persistAnchorIds: true);
+
+ using var probe = new DocxSession(bytes, new DocxSessionSettings { PersistAnchorIds = true });
+ anchors = BodyParagraphs(probe);
+ return bytes;
+ }
+
+ /// Bytes carrying tracked revisions authored into the FIRST body paragraph only.
+ private static byte[] RedlinedBytes(out string[] anchors)
+ {
+ using var seed = OpenRich(new DocxSessionSettings
+ {
+ PersistAnchorIds = true,
+ TrackedChanges = TrackedChangeMode.RenderInline,
+ RevisionAuthor = "Original Reviewer",
+ });
+ var seedAnchors = BodyParagraphs(seed);
+ Assert.True(seed.ReplaceText(seedAnchors[0], "Redlined first paragraph.").Success);
+ var bytes = seed.Save(persistAnchorIds: true);
+
+ using var probe = new DocxSession(bytes, new DocxSessionSettings { PersistAnchorIds = true });
+ anchors = BodyParagraphs(probe);
+ return bytes;
+ }
+
+ private static string NormalizeGeneratedIds(string value) =>
+ Regex.Replace(value, "[0-9a-fA-F]{32}", "");
+
+ private sealed class BlockingAnchorList : IReadOnlyList
+ {
+ private readonly ManualResetEventSlim _entered;
+ private readonly ManualResetEventSlim _release;
+
+ internal BlockingAnchorList(ManualResetEventSlim entered, ManualResetEventSlim release)
+ {
+ _entered = entered;
+ _release = release;
+ }
+
+ public int Count
+ {
+ get
+ {
+ _entered.Set();
+ _release.Wait();
+ return 0;
+ }
+ }
+
+ public Anchor this[int index] => throw new ArgumentOutOfRangeException(nameof(index));
+
+ public IEnumerator GetEnumerator() =>
+ Enumerable.Empty().GetEnumerator();
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() =>
+ GetEnumerator();
+ }
+
+ private sealed record Fingerprint(
+ IReadOnlyDictionary NormalOpcEntries,
+ IReadOnlyDictionary PersistedOpcEntries,
+ string Markdown,
+ string[] Anchors,
+ long Version,
+ int RevisionCounter,
+ long FormatRevisionTicks,
+ long NextTransactionId,
+ string Revisions,
+ string Comments,
+ string Annotations,
+ TrackedChangeMode TrackedChanges,
+ string? RevisionAuthor,
+ string Settings,
+ int UndoCount,
+ int RedoCount,
+ long UndoMemoryBytes,
+ bool UndoTrimmed,
+ object? CachedProjection,
+ object? InitialProjection,
+ object? CachedAnchorIndex,
+ object? RawOps)
+ {
+ internal static Fingerprint Capture(DocxSession session)
+ {
+ // Observe Save output on complete package clones. Calling Save on the live session
+ // would itself replace its read caches and make the invariant probe perturb state.
+ // Compare entry payloads rather than raw ZIP bytes: DOS entry timestamps are transport
+ // metadata and may advance across successive clone saves. This matches
+ // GetPackageContentHash's policy of excluding ZIP timestamps and compression details.
+ var projection = session.Project();
+ _ = session.AnchorIndex();
+ byte[] normal;
+ byte[] persisted;
+ using (var normalClone = session.CreateShadowSession())
+ normal = normalClone.Save(persistAnchorIds: false);
+ using (var persistedClone = session.CreateShadowSession())
+ persisted = persistedClone.Save(persistAnchorIds: true);
+ return new Fingerprint(
+ HashOpcEntries(normal),
+ HashOpcEntries(persisted),
+ projection.Markdown,
+ projection.AnchorIndex.Select(pair =>
+ $"{pair.Key}|{pair.Value.Anchor.Kind}|{pair.Value.Anchor.Scope}|{pair.Value.Unid}|{pair.Value.PartUri}")
+ .OrderBy(value => value, StringComparer.Ordinal).ToArray(),
+ session.Version,
+ PrivateField(session, "_revisionCounter"),
+ PrivateField(session, "_lastFormatRevisionTicks"),
+ PrivateField(session, "_nextTransactionId"),
+ DocxSessionJson.SerializeRevisionList(session.ListRevisions()),
+ DocxSessionJson.SerializeCommentList(session.ListComments()),
+ DocxSessionJson.SerializeAnnotations(session.ListAnnotations()),
+ session.TrackedChanges,
+ session.RevisionAuthor,
+ SettingsReceipt(PrivateField(session, "_settings")),
+ session.UndoCount,
+ session.RedoCount,
+ session.UndoMemoryBytes,
+ session.UndoHistoryTrimmedForMemory,
+ PrivateField