diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8aa8351c..ef7a7716 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,20 @@ All notable changes to this project will be documented in this file.
preserved; emitting new tracked table revisions remains #455. Coverage:
`DocxSessionTableAddressingTests` DT250–DT257, the existing table/MCP suites, and
`python/tests/test_table_addressing.py`.
+- **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
+ unchanged. `MutationPreconditions` can guard the expected version, target
+ anchor/hash/exact visible text or range/kind/scope, and find/replace occurrence
+ count. A mismatch returns `PreconditionFailed` with structured expected/actual
+ values plus the current version and target metadata, without changing bytes or
+ undo history. The same camel-case shape is exposed by the WASM/npm, stdio/Python,
+ and MCP transports; `AnchorInfo` now includes `contentHash` and `visibleText`.
+- **Exact occurrence-count replacement.** `ReplaceOptions.ExpectedMatchCount`
+ requires the live literal-match count before `ReplaceTextRange` proceeds. Guard
+ evaluation, counting, and the whole multi-match rewrite share one mutation gate
+ and one undo snapshot, so duplicate text cannot turn a stale plan into a partial
+ replacement.
- **`DocxSessionSettings.UndoMemoryBudgetBytes`** (wire `undoMemoryBudgetBytes`,
Python `undo_memory_budget_bytes`) — an approximate ceiling on the memory held
by undo/redo snapshots, default **128 MiB**. `UndoDepth` never bounded memory:
diff --git a/Docxodus.Tests/DocxSessionPreconditionTests.cs b/Docxodus.Tests/DocxSessionPreconditionTests.cs
new file mode 100644
index 00000000..2eb92142
--- /dev/null
+++ b/Docxodus.Tests/DocxSessionPreconditionTests.cs
@@ -0,0 +1,236 @@
+#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.Linq;
+using Docxodus.Internal;
+using Xunit;
+
+namespace Docxodus.Tests;
+
+/// Document-version and optimistic-mutation regression coverage (issue #447).
+public class DocxSessionPreconditionTests
+{
+ [Fact]
+ public void DS447_Version_AdvancesOnlyForCommittedMutationUndoAndRedo()
+ {
+ using var session = Open();
+ var first = BodyParagraphs(session)[0];
+
+ Assert.Equal(0, session.Version);
+ Assert.False(session.ReplaceText("p:body:missing", "x").Success);
+ Assert.Equal(0, session.Version);
+ Assert.False(session.Undo());
+
+ Assert.True(session.ReplaceText(first, "Changed").Success);
+ Assert.Equal(1, session.Version);
+
+ Assert.Equal(0, session.CompactRuns().RunsRemoved);
+ Assert.Equal(1, session.Version);
+
+ Assert.True(session.Undo());
+ Assert.Equal(2, session.Version);
+ Assert.True(session.Redo());
+ Assert.Equal(3, session.Version);
+ Assert.False(session.Redo());
+ Assert.Equal(3, session.Version);
+ }
+
+ [Fact]
+ public void DS448_StaleVersion_FailsWithCurrentMetadataWithoutMutationOrHistoryLoss()
+ {
+ using var session = Open();
+ var paragraphs = BodyParagraphs(session);
+ var first = paragraphs[0];
+ var info = Assert.IsType(session.GetAnchorInfo(first));
+
+ Assert.True(session.ReplaceText(paragraphs[1], "Committed change").Success);
+ Assert.Equal(1, session.Version);
+ var before = session.Save(persistAnchorIds: true);
+
+ var result = session.ExecuteMutation(
+ new MutationPreconditions
+ {
+ ExpectedVersion = 0,
+ AnchorId = first,
+ ExpectedContentHash = info.ContentHash,
+ },
+ s => s.ReplaceText(first, "must not apply"));
+
+ Assert.False(result.Success);
+ Assert.Equal(EditErrorCode.PreconditionFailed, result.Error?.Code);
+ var detail = Assert.IsType(result.Error?.Precondition);
+ Assert.Equal("document_version", detail.Condition);
+ Assert.Equal(0L, detail.Expected);
+ Assert.Equal(1L, detail.Actual);
+ Assert.Equal(1, detail.CurrentVersion);
+ Assert.True(detail.CurrentTarget?.Exists);
+ Assert.Equal(info.ContentHash, detail.CurrentTarget?.ContentHash);
+ Assert.Equal(before, session.Save(persistAnchorIds: true));
+ Assert.Equal(1, session.Version);
+
+ // The failed guarded call did not add or consume a history entry: this undoes
+ // the one earlier committed mutation.
+ Assert.True(session.Undo());
+ Assert.Contains("Second paragraph.", session.Project().Markdown);
+ }
+
+ [Fact]
+ public void DS449_AnchorTextHashAndRangeGuards_ReportExpectedAndActual()
+ {
+ using var session = Open();
+ var first = BodyParagraphs(session)[0];
+ var info = Assert.IsType(session.GetAnchorInfo(first));
+
+ Assert.Equal("First paragraph.", info.VisibleText);
+ Assert.False(string.IsNullOrWhiteSpace(info.ContentHash));
+ Assert.Null(session.EvaluatePreconditions(new MutationPreconditions
+ {
+ AnchorId = first,
+ ExpectedContentHash = info.ContentHash,
+ ExpectedText = info.VisibleText,
+ ExpectedTextRange = new TextRangePrecondition(0, 5, "First"),
+ ExpectedKind = info.Kind,
+ ExpectedScope = info.Scope,
+ }));
+
+ var error = Assert.IsType(session.EvaluatePreconditions(
+ new MutationPreconditions
+ {
+ AnchorId = first,
+ ExpectedTextRange = new TextRangePrecondition(6, 9, "different"),
+ }));
+ Assert.Equal(EditErrorCode.PreconditionFailed, error.Code);
+ Assert.Equal("anchor_text_range", error.Precondition?.Condition);
+ Assert.Equal("paragraph", error.Precondition?.Actual);
+ Assert.Equal(0, session.Version);
+ Assert.False(session.Undo());
+ }
+
+ [Fact]
+ public void DS450_KindChangeAndDeletedAnchor_ReturnCurrentLiveState()
+ {
+ using var session = Open();
+ var paragraphs = BodyParagraphs(session);
+ var oldParagraphAnchor = paragraphs[0];
+ var deletedAnchor = paragraphs[1];
+
+ Assert.True(session.SetParagraphStyle(oldParagraphAnchor, "Heading1").Success);
+ var kindError = Assert.IsType(session.EvaluatePreconditions(
+ new MutationPreconditions { AnchorId = oldParagraphAnchor, ExpectedKind = "p" }));
+ Assert.Equal("anchor_kind", kindError.Precondition?.Condition);
+ Assert.Equal("h", kindError.Precondition?.Actual);
+ Assert.Equal("h", kindError.Precondition?.CurrentTarget?.Kind);
+
+ Assert.True(session.DeleteBlock(deletedAnchor).Success);
+ var deletedError = Assert.IsType(session.EvaluatePreconditions(
+ new MutationPreconditions { AnchorId = deletedAnchor, ExpectedText = "Second paragraph." }));
+ Assert.Equal("anchor_exists", deletedError.Precondition?.Condition);
+ Assert.Equal(false, deletedError.Precondition?.Actual);
+ Assert.False(deletedError.Precondition?.CurrentTarget?.Exists);
+ }
+
+ [Fact]
+ public void DS451_ExactMatchCount_IsAtomicAndOneUndoUnit()
+ {
+ using var session = Open();
+ var first = BodyParagraphs(session)[0];
+ Assert.True(session.ReplaceText(first, "cat cat").Success);
+ Assert.Equal(1, session.Version);
+ var beforeFailure = session.Save(persistAnchorIds: true);
+
+ var failed = Assert.Single(session.ReplaceTextRange(first, "cat", "dog",
+ new ReplaceOptions { ExpectedMatchCount = 1 }));
+ Assert.False(failed.Success);
+ Assert.Equal(EditErrorCode.PreconditionFailed, failed.Error?.Code);
+ Assert.Equal("match_count", failed.Error?.Precondition?.Condition);
+ Assert.Equal(1, failed.Error?.Precondition?.Expected);
+ Assert.Equal(2, failed.Error?.Precondition?.Actual);
+ Assert.Equal(beforeFailure, session.Save(persistAnchorIds: true));
+ Assert.Equal(1, session.Version);
+
+ var replaced = session.ReplaceTextRange(first, "cat", "dog",
+ new ReplaceOptions
+ {
+ Preconditions = new MutationPreconditions
+ {
+ ExpectedVersion = 1,
+ ExpectedMatchCount = 2,
+ },
+ });
+ Assert.Equal(2, replaced.Count);
+ Assert.All(replaced, r => Assert.True(r.Success));
+ Assert.Equal(2, session.Version); // one committed operation, not one per match
+ Assert.Contains("dog dog", session.Project().Markdown);
+
+ Assert.True(session.Undo());
+ Assert.Equal(3, session.Version);
+ Assert.Contains("cat cat", session.Project().Markdown);
+ }
+
+ [Fact]
+ public void DS452_CrossPartAnchor_GuardsUseExactHeaderTextAndHash()
+ {
+ using var session = Open();
+ var body = BodyParagraphs(session)[0];
+ var created = session.SetHeaderText(body, HeaderFooterKind.Default, "Confidential");
+ Assert.True(created.Success);
+ var header = Assert.Single(created.Created, a => a.Scope.StartsWith("hdr")).Id;
+ var info = Assert.IsType(session.GetAnchorInfo(header));
+
+ Assert.Equal("Confidential", info.VisibleText);
+ var success = session.ExecuteMutation(
+ new MutationPreconditions
+ {
+ ExpectedVersion = session.Version,
+ AnchorId = header,
+ ExpectedContentHash = info.ContentHash,
+ ExpectedText = "Confidential",
+ ExpectedScope = info.Scope,
+ },
+ s => s.ReplaceText(header, "Privileged"));
+ Assert.True(success.Success);
+
+ var staleHash = session.ExecuteMutation(
+ new MutationPreconditions { AnchorId = header, ExpectedContentHash = info.ContentHash },
+ s => s.ReplaceText(header, "must not apply"));
+ Assert.False(staleHash.Success);
+ Assert.Equal("anchor_content_hash", staleHash.Error?.Precondition?.Condition);
+ Assert.Equal("Privileged", staleHash.Error?.Precondition?.CurrentTarget?.VisibleText);
+ }
+
+ [Fact]
+ public void DS453_PreconditionFailure_JsonCarriesStructuredCurrentState()
+ {
+ var error = new EditError(EditErrorCode.PreconditionFailed, "stale", "p:body:u")
+ {
+ Precondition = new PreconditionFailure(
+ "document_version", 4L, 5L, 5L,
+ new PreconditionTarget
+ {
+ Exists = true,
+ AnchorId = "p:body:u",
+ Kind = "p",
+ Scope = "body",
+ ContentHash = "abc",
+ VisibleText = "current",
+ }),
+ };
+
+ var json = DocxSessionJson.Serialize(new EditResult { Success = false, Error = error });
+ Assert.Contains("\"code\":\"precondition_failed\"", json);
+ Assert.Contains("\"currentVersion\":5", json);
+ Assert.Contains("\"contentHash\":\"abc\"", json);
+ Assert.Contains("\"visibleText\":\"current\"", json);
+ }
+
+ private static DocxSession Open() =>
+ new(DocxSessionTests.BuildDS001_SimpleTwoParagraphs(),
+ new DocxSessionSettings { PersistAnchorIds = true });
+
+ private static string[] BodyParagraphs(DocxSession session) =>
+ session.Project().AnchorIndex.Keys
+ .Where(id => id.StartsWith("p:body:"))
+ .ToArray();
+}
diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs
index 5ca51a49..f92840d3 100644
--- a/Docxodus.Tests/McpServerDispatcherTests.cs
+++ b/Docxodus.Tests/McpServerDispatcherTests.cs
@@ -886,6 +886,9 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged()
var before = Parse(Dispatcher.Call(_store, "docxodus_get_content", J($$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString()!;
+ var versionBefore = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
+ $$"""{"sessionId":{{sessionArg}},"format":"version"}""")))
+ .GetProperty("version").GetInt64();
var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(
$$"""
@@ -902,6 +905,40 @@ public void MCP091_Mutations_PreviewMode_LeavesDocumentUnchanged()
var after = Parse(Dispatcher.Call(_store, "docxodus_get_content", J($$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
.GetProperty("markdown").GetString()!;
Assert.Equal(before, after);
+ var versionAfter = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
+ $$"""{"sessionId":{{sessionArg}},"format":"version"}""")))
+ .GetProperty("version").GetInt64();
+ Assert.Equal(versionBefore, versionAfter);
+ }
+
+ [Fact]
+ public void MCP093_StalePrecondition_ReturnsStructuredFailureWithoutMutation()
+ {
+ var sessionId = OpenSession();
+ var sessionArg = JsonSerializer.Serialize(sessionId);
+ var anchor = FirstBodyAnchorId(sessionId, _store);
+ Assert.True(ReplaceText(_store, sessionId, anchor, "committed").GetProperty("success").GetBoolean());
+
+ var failed = Parse(Dispatcher.Call(_store, "docxodus_edit", J(
+ $$"""
+ {
+ "sessionId": {{sessionArg}},
+ "action": "replace_text",
+ "anchorId": "{{anchor}}",
+ "markdown": "must not apply",
+ "preconditions": { "expectedVersion": 0 }
+ }
+ """)));
+
+ Assert.False(failed.GetProperty("success").GetBoolean());
+ var error = failed.GetProperty("error");
+ Assert.Equal("precondition_failed", error.GetProperty("code").GetString());
+ Assert.Equal(1, error.GetProperty("precondition").GetProperty("currentVersion").GetInt64());
+ var markdown = Parse(Dispatcher.Call(_store, "docxodus_get_content", J(
+ $$"""{"sessionId":{{sessionArg}},"format":"markdown"}""")))
+ .GetProperty("markdown").GetString()!;
+ Assert.Contains("committed", markdown);
+ Assert.DoesNotContain("must not apply", markdown);
}
[Fact]
diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs
index fe4b48f4..8e8b290c 100644
--- a/Docxodus/DocxSession.cs
+++ b/Docxodus/DocxSession.cs
@@ -523,6 +523,12 @@ public sealed record ReplaceOptions
/// Cap the number of replacements; null = unlimited.
public int? MaxReplacements { get; init; }
+
+ /// Require exactly this many occurrences before applying any replacement.
+ public int? ExpectedMatchCount { get; init; }
+
+ /// Optional optimistic session/anchor guards evaluated before searching.
+ public MutationPreconditions? Preconditions { get; init; }
}
///
@@ -717,6 +723,12 @@ public sealed record AnchorInfo(string Id, string Kind, string Scope, string Tex
///
public string? AutoNumberPrefix { get; init; }
+ /// Hash of the live anchor subtree, excluding projector Unids and note ids.
+ public string ContentHash { get; init; } = string.Empty;
+
+ /// Exact visible text used by optimistic preconditions (never preview-truncated).
+ public string VisibleText { get; init; } = string.Empty;
+
/// What a reader sees: + space +
/// when a prefix is present, otherwise just .
public string FullText =>
@@ -1106,7 +1118,50 @@ public sealed record CompactResult
public int RunsRemoved { get; init; }
}
-public sealed record EditError(EditErrorCode Code, string Message, string? AnchorId = null);
+/// The current state of a precondition target, returned even when it no longer exists.
+public sealed record PreconditionTarget
+{
+ public bool Exists { get; init; }
+ public string? AnchorId { get; init; }
+ public string? Kind { get; init; }
+ public string? Scope { get; init; }
+ public string? ContentHash { get; init; }
+ public string? VisibleText { get; init; }
+}
+
+/// Structured expected/actual detail for .
+public sealed record PreconditionFailure(
+ string Condition,
+ object? Expected,
+ object? Actual,
+ long CurrentVersion,
+ PreconditionTarget? CurrentTarget);
+
+///
+/// Optimistic guards evaluated immediately before a mutation. Anchor-specific fields use
+/// as their target; a stale kind prefix still resolves by Unid, just like
+/// ordinary mutation addressing. is measured against the exact
+/// value.
+///
+public sealed record MutationPreconditions
+{
+ public long? ExpectedVersion { get; init; }
+ public string? AnchorId { get; init; }
+ public string? ExpectedContentHash { get; init; }
+ public string? ExpectedText { get; init; }
+ public TextRangePrecondition? ExpectedTextRange { get; init; }
+ public string? ExpectedKind { get; init; }
+ public string? ExpectedScope { get; init; }
+ public int? ExpectedMatchCount { get; init; }
+}
+
+/// An exact substring assertion within an anchor's visible text.
+public sealed record TextRangePrecondition(int Start, int Length, string Text);
+
+public sealed record EditError(EditErrorCode Code, string Message, string? AnchorId = null)
+{
+ public PreconditionFailure? Precondition { get; init; }
+}
public enum EditErrorCode
{
@@ -1187,6 +1242,9 @@ public enum EditErrorCode
/// revision. Re- for the current set.
RevisionNotFound,
+ /// An optimistic mutation guard did not match the current session or target state.
+ PreconditionFailed,
+
InternalError,
}
@@ -1310,6 +1368,8 @@ public sealed class DocxSession : IDisposable
private MarkdownProjection? _cachedProjection;
private MarkdownProjection? _initialProjection;
private bool _disposed;
+ private long _version;
+ private readonly object _mutationGate = new();
private int _revisionCounter = 1000;
private long _lastFormatRevisionTicks;
private RawDocxOps? _raw;
@@ -1329,7 +1389,9 @@ public DocxSession(byte[] docxBytes, DocxSessionSettings? settings = null)
_history = new Internal.UndoRing(
_settings.UndoDepth,
_settings.UndoMemoryBudgetBytes,
- static snapshot => snapshot.ApproximateBytes);
+ static snapshot => snapshot.ApproximateBytes,
+ onRecordPreOp: _ => AdvanceVersion(),
+ onPopUndo: snapshot => _version = snapshot.Version);
_stream = new MemoryStream();
_stream.Write(docxBytes, 0, docxBytes.Length);
_stream.Position = 0;
@@ -1341,6 +1403,13 @@ public DocxSession(byte[] docxBytes, DocxSessionSettings? settings = null)
public Exception? LastInternalError { get; private set; }
+ ///
+ /// Monotonic in-session document version. Starts at 0 and advances once after each
+ /// committed mutation and each successful undo/redo. Failed calls, failed preconditions,
+ /// and successful no-ops leave it unchanged.
+ ///
+ public long Version => _version;
+
///
/// Set when a mutation threw AND the subsequent rollback to its pre-op snapshot ALSO threw —
/// the one case in which a failed op can leave the document partially mutated. Null on a
@@ -2011,9 +2080,12 @@ public bool Exists(string anchorId)
_ = Project(); // AnchorInfo's product IS the enrichment — never serve the index-only (empty-preview) entries.
var target = FindAnchor(anchorId);
if (target is null) return null;
+ var element = target.Resolve(_doc!);
return new AnchorInfo(target.Anchor.Id, target.Anchor.Kind, target.Anchor.Scope, target.TextPreview)
{
AutoNumberPrefix = target.AutoNumberPrefix,
+ ContentHash = element is null ? string.Empty : UnidHelper.ContentHash(element),
+ VisibleText = element is null ? string.Empty : ExactVisibleText(target, element),
};
}
@@ -2035,16 +2107,148 @@ public bool Exists(string anchorId)
if (id is null) continue;
if (result.ContainsKey(id)) continue;
var target = FindAnchor(id);
- result[id] = target is null
+ var element = target?.Resolve(_doc!);
+ result[id] = target is null || element is null
? null
: new AnchorInfo(target.Anchor.Id, target.Anchor.Kind, target.Anchor.Scope, target.TextPreview)
{
AutoNumberPrefix = target.AutoNumberPrefix,
+ ContentHash = UnidHelper.ContentHash(element),
+ VisibleText = ExactVisibleText(target, element),
};
}
return result;
}
+ private static string FlatElementText(XElement element) =>
+ string.Concat(element.Descendants(W.t).Select(t => (string)t));
+
+ private string ExactVisibleText(AnchorTarget target, XElement element)
+ {
+ var text = FlatElementText(element);
+ var prefix = target.Anchor.Kind is "p" or "h" or "li" && target.Anchor.Scope == "body"
+ ? target.AutoNumberPrefix ?? Internal.ListNumberResolver.Resolve(element, _doc!)
+ : null;
+ return string.IsNullOrEmpty(prefix)
+ ? text
+ : string.IsNullOrEmpty(text) ? prefix : prefix + " " + text;
+ }
+
+ private PreconditionTarget CurrentPreconditionTarget(string? anchorId)
+ {
+ if (string.IsNullOrEmpty(anchorId)) return new PreconditionTarget { Exists = false };
+ var target = FindAnchor(anchorId);
+ var element = target?.Resolve(_doc!);
+ if (target is null || element is null)
+ return new PreconditionTarget { Exists = false, AnchorId = anchorId };
+ return new PreconditionTarget
+ {
+ Exists = true,
+ AnchorId = target.Anchor.Id,
+ Kind = target.Anchor.Kind,
+ Scope = target.Anchor.Scope,
+ ContentHash = UnidHelper.ContentHash(element),
+ VisibleText = ExactVisibleText(target, element),
+ };
+ }
+
+ private EditError PreconditionError(
+ string condition, object? expected, object? actual, string? anchorId,
+ PreconditionTarget? currentTarget = null) =>
+ new(EditErrorCode.PreconditionFailed,
+ $"precondition failed: {condition} expected {expected ?? "null"}, actual {actual ?? "null"}",
+ anchorId)
+ {
+ Precondition = new PreconditionFailure(
+ condition, expected, actual, _version,
+ currentTarget ?? (anchorId is null ? null : CurrentPreconditionTarget(anchorId))),
+ };
+
+ ///
+ /// Evaluate optimistic guards without mutating the document, consuming undo history, or
+ /// advancing . Returns null when every supplied guard matches.
+ /// is supplied by find/replace after it has enumerated
+ /// the live matches; other callers leave it null.
+ ///
+ public EditError? EvaluatePreconditions(
+ MutationPreconditions? preconditions,
+ int? actualMatchCount = null)
+ {
+ if (preconditions is null) return null;
+ if (_disposed) return new EditError(EditErrorCode.SessionDisposed, "session disposed");
+
+ var target = !string.IsNullOrEmpty(preconditions.AnchorId)
+ ? CurrentPreconditionTarget(preconditions.AnchorId)
+ : null;
+ if (preconditions.ExpectedVersion is { } expectedVersion && expectedVersion != _version)
+ return PreconditionError("document_version", expectedVersion, _version,
+ preconditions.AnchorId, target);
+
+ bool hasAnchorGuard = preconditions.ExpectedContentHash is not null
+ || preconditions.ExpectedText is not null
+ || preconditions.ExpectedTextRange is not null
+ || preconditions.ExpectedKind is not null
+ || preconditions.ExpectedScope is not null;
+ if (hasAnchorGuard && string.IsNullOrEmpty(preconditions.AnchorId))
+ return PreconditionError("anchor_id", "present", "missing", null);
+ if (hasAnchorGuard && target is not { Exists: true })
+ return PreconditionError("anchor_exists", true, false, preconditions.AnchorId, target);
+
+ if (preconditions.ExpectedKind is { } expectedKind
+ && !string.Equals(expectedKind, target!.Kind, StringComparison.Ordinal))
+ return PreconditionError("anchor_kind", expectedKind, target.Kind,
+ preconditions.AnchorId, target);
+ if (preconditions.ExpectedScope is { } expectedScope
+ && !string.Equals(expectedScope, target!.Scope, StringComparison.Ordinal))
+ return PreconditionError("anchor_scope", expectedScope, target.Scope,
+ preconditions.AnchorId, target);
+ if (preconditions.ExpectedContentHash is { } expectedHash
+ && !string.Equals(expectedHash, target!.ContentHash, StringComparison.OrdinalIgnoreCase))
+ return PreconditionError("anchor_content_hash", expectedHash, target.ContentHash,
+ preconditions.AnchorId, target);
+ if (preconditions.ExpectedText is { } expectedText
+ && !string.Equals(expectedText, target!.VisibleText, StringComparison.Ordinal))
+ return PreconditionError("anchor_text", expectedText, target.VisibleText,
+ preconditions.AnchorId, target);
+ if (preconditions.ExpectedTextRange is { } range)
+ {
+ var visible = target!.VisibleText ?? string.Empty;
+ object actual;
+ if (range.Start < 0 || range.Length < 0 || range.Start > visible.Length - range.Length)
+ actual = new { start = range.Start, length = range.Length, availableLength = visible.Length };
+ else
+ actual = visible.Substring(range.Start, range.Length);
+ if (actual is not string actualText
+ || !string.Equals(range.Text, actualText, StringComparison.Ordinal))
+ return PreconditionError("anchor_text_range", range.Text, actual,
+ preconditions.AnchorId, target);
+ }
+ if (preconditions.ExpectedMatchCount is { } expectedCount
+ && actualMatchCount is { } count && expectedCount != count)
+ return PreconditionError("match_count", expectedCount, count,
+ preconditions.AnchorId, target);
+ return null;
+ }
+
+ ///
+ /// Atomically evaluate guards and invoke one synchronous mutation. This is the direct .NET
+ /// primitive shared facades use; future atomic batches can evaluate their guards under the
+ /// same gate before taking their aggregate snapshot.
+ ///
+ public EditResult ExecuteMutation(
+ MutationPreconditions? preconditions,
+ Func mutation)
+ {
+ ArgumentNullException.ThrowIfNull(mutation);
+ lock (_mutationGate)
+ {
+ var error = EvaluatePreconditions(preconditions);
+ return error is null
+ ? mutation(this)
+ : new EditResult { Success = false, Error = error };
+ }
+ }
+
///
/// Resolves block-level metadata (style id + name, outline level, list
/// membership, formatting probe) for . Returns
@@ -2719,12 +2923,34 @@ public IReadOnlyList ReplaceTextRange(
string find,
string replace,
ReplaceOptions? options = null)
+ {
+ lock (_mutationGate)
+ return ReplaceTextRangeCore(anchorId, find, replace, options);
+ }
+
+ private IReadOnlyList ReplaceTextRangeCore(
+ string anchorId,
+ string find,
+ string replace,
+ ReplaceOptions? options)
{
if (_disposed)
return new[] { EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed") };
if (string.IsNullOrEmpty(find))
return new[] { EditResult.Fail(EditErrorCode.MalformedMarkdown, "find must be non-empty", anchorId) };
+ var opts = options ?? new ReplaceOptions();
+ var guards = opts.Preconditions;
+ if (guards is not null && guards.AnchorId is null)
+ guards = guards with { AnchorId = anchorId };
+ if (opts.ExpectedMatchCount is { } expectedCount)
+ guards = (guards ?? new MutationPreconditions { AnchorId = anchorId }) with
+ {
+ ExpectedMatchCount = expectedCount,
+ };
+ if (EvaluatePreconditions(guards) is { } initialPreconditionError)
+ return new[] { new EditResult { Success = false, Error = initialPreconditionError } };
+
var target = FindAnchor(anchorId);
if (target is null)
return new[] { EditResult.Fail(EditErrorCode.AnchorNotFound, $"anchor not found: {anchorId}", anchorId) };
@@ -2732,7 +2958,6 @@ public IReadOnlyList ReplaceTextRange(
return new[] { EditResult.Fail(EditErrorCode.AnchorWrongKind,
$"ReplaceTextRange requires a paragraph/heading/list-item anchor; got kind={target.Anchor.Kind}", anchorId) };
- var opts = options ?? new ReplaceOptions();
var regexOpts = opts.IgnoreCase
? System.Text.RegularExpressions.RegexOptions.IgnoreCase
: System.Text.RegularExpressions.RegexOptions.None;
@@ -2742,6 +2967,8 @@ public IReadOnlyList ReplaceTextRange(
var matches = Grep(pattern, regexOpts)
.Where(m => m.EnclosingAnchor.Anchor.Id == target.Anchor.Id)
.ToList();
+ if (EvaluatePreconditions(guards, matches.Count) is { } countPreconditionError)
+ return new[] { new EditResult { Success = false, Error = countPreconditionError } };
if (opts.MaxReplacements is int cap) matches = matches.Take(cap).ToList();
if (matches.Count == 0) return Array.Empty();
@@ -9359,6 +9586,7 @@ public CompactResult CompactRuns(ProjectionScopes scopes = ProjectionScopes.All)
part.PutXDocument();
}
if (removed > 0) InvalidateProjectionCache();
+ else _ = _history.PopForUndo();
return new CompactResult { RunsRemoved = removed };
}
@@ -9434,23 +9662,35 @@ private void RollbackFailedOp()
public bool Undo()
{
if (_disposed) return false;
+ var nextVersion = NextVersion();
var (preOp, ok) = _history.PopForUndo();
if (!ok) return false;
_history.RecordForRedo(TakeSnapshot());
RestoreSnapshot(preOp);
+ _version = nextVersion;
return true;
}
public bool Redo()
{
if (_disposed) return false;
+ var nextVersion = NextVersion();
var (postOp, ok) = _history.PopForRedo();
if (!ok) return false;
_history.PushBackForUndo(TakeSnapshot());
RestoreSnapshot(postOp);
+ _version = nextVersion;
return true;
}
+ private long NextVersion() => checked(_version + 1);
+
+ private void AdvanceVersion() => _version = NextVersion();
+
+ /// Restore the caller-visible version after rolling back speculative preview work.
+ /// Internal by design: committed undo/redo must remain monotonic.
+ internal void RestorePreviewVersion(long version) => _version = version;
+
public void Dispose()
{
if (_disposed) return;
@@ -9489,6 +9729,7 @@ internal void InvalidateProjectionCache()
/// The same, for commentsExtended/commentsIds, which
/// AddCommentReply/SetCommentResolved create when upgrading a flat comment.
internal sealed record DocumentSnapshot(
+ long Version,
System.Collections.Generic.IReadOnlyList<(string PartUri, XDocument Xml)> Parts,
System.Collections.Generic.IReadOnlyList<(string RelId, bool IsHeader, string PartUri)> HeaderFooterParts,
System.Collections.Generic.IReadOnlyList<(string RelId, bool IsFootnote, string PartUri)> NoteParts,
@@ -9534,7 +9775,7 @@ internal DocumentSnapshot TakeSnapshot()
commentThreadingParts.Add((main.GetIdOfPart(main.WordprocessingCommentsIdsPart), false,
main.WordprocessingCommentsIdsPart.Uri.ToString()));
}
- return new DocumentSnapshot(parts, hfParts, noteParts, commentParts, commentThreadingParts);
+ return new DocumentSnapshot(_version, parts, hfParts, noteParts, commentParts, commentThreadingParts);
}
internal void RestoreSnapshot(DocumentSnapshot snapshot)
@@ -9614,6 +9855,7 @@ internal void RestoreSnapshot(DocumentSnapshot snapshot)
}
}
+ _version = snapshot.Version;
InvalidateProjectionCache();
}
diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs
index 6f29ca18..a7ee32b0 100644
--- a/Docxodus/Internal/DocxSessionJson.cs
+++ b/Docxodus/Internal/DocxSessionJson.cs
@@ -184,6 +184,39 @@ public static FormatOp ParseFormatOp(string json)
};
}
+ /// Parse the common optimistic-mutation guard object used by every transport.
+ public static MutationPreconditions? ParseMutationPreconditions(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return null;
+ using var doc = JsonDocument.Parse(json);
+ return ParseMutationPreconditions(doc.RootElement);
+ }
+
+ public static MutationPreconditions? ParseMutationPreconditions(JsonElement root)
+ {
+ if (root.ValueKind != JsonValueKind.Object) return null;
+ TextRangePrecondition? range = null;
+ if (root.TryGetProperty("expectedTextRange", out var r) && r.ValueKind == JsonValueKind.Object
+ && r.TryGetProperty("start", out var start) && start.ValueKind == JsonValueKind.Number
+ && r.TryGetProperty("length", out var length) && length.ValueKind == JsonValueKind.Number
+ && r.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
+ {
+ range = new TextRangePrecondition(start.GetInt32(), length.GetInt32(), text.GetString() ?? string.Empty);
+ }
+ return new MutationPreconditions
+ {
+ ExpectedVersion = root.TryGetProperty("expectedVersion", out var version)
+ && version.ValueKind == JsonValueKind.Number ? version.GetInt64() : null,
+ AnchorId = TryGetString(root, "anchorId", null),
+ ExpectedContentHash = TryGetString(root, "expectedContentHash", null),
+ ExpectedText = TryGetString(root, "expectedText", null),
+ ExpectedTextRange = range,
+ ExpectedKind = TryGetString(root, "expectedKind", null),
+ ExpectedScope = TryGetString(root, "expectedScope", null),
+ ExpectedMatchCount = TryGetIntNullable(root, "expectedMatchCount"),
+ };
+ }
+
///
/// Parse a ParagraphFormatOp wire object: { alignment?: "left"|"center"|"right"|"justify",
/// indentDelta?: int (twips), firstLineIndent?/hangingIndent?: int (twips, mutually exclusive),
@@ -416,6 +449,33 @@ public static string Serialize(EditResult r)
.Append(",\"message\":").Append(JsonString(r.Error.Message));
if (r.Error.AnchorId is not null)
sb.Append(",\"anchorId\":").Append(JsonString(r.Error.AnchorId));
+ if (r.Error.Precondition is { } p)
+ {
+ sb.Append(",\"precondition\":{")
+ .Append("\"condition\":").Append(JsonString(p.Condition))
+ .Append(",\"expected\":");
+ AppendJsonValue(sb, p.Expected);
+ sb.Append(",\"actual\":");
+ AppendJsonValue(sb, p.Actual);
+ sb.Append(",\"currentVersion\":").Append(p.CurrentVersion);
+ if (p.CurrentTarget is { } target)
+ {
+ sb.Append(",\"currentTarget\":{")
+ .Append("\"exists\":").Append(target.Exists ? "true" : "false");
+ if (target.AnchorId is not null)
+ sb.Append(",\"anchorId\":").Append(JsonString(target.AnchorId));
+ if (target.Kind is not null)
+ sb.Append(",\"kind\":").Append(JsonString(target.Kind));
+ if (target.Scope is not null)
+ sb.Append(",\"scope\":").Append(JsonString(target.Scope));
+ if (target.ContentHash is not null)
+ sb.Append(",\"contentHash\":").Append(JsonString(target.ContentHash));
+ if (target.VisibleText is not null)
+ sb.Append(",\"visibleText\":").Append(JsonString(target.VisibleText));
+ sb.Append('}');
+ }
+ sb.Append('}');
+ }
sb.Append('}');
}
sb.Append(",\"created\":"); AppendAnchorArray(sb, r.Created);
@@ -559,6 +619,21 @@ private static void AppendAnchorValue(StringBuilder sb, Anchor anchor) =>
.Append(",\"scope\":").Append(JsonString(anchor.Scope))
.Append(",\"unid\":").Append(JsonString(anchor.Unid))
.Append('}');
+ private static void AppendJsonValue(StringBuilder sb, object? value)
+ {
+ switch (value)
+ {
+ case null: sb.Append("null"); break;
+ case string s: sb.Append(JsonString(s)); break;
+ case bool b: sb.Append(b ? "true" : "false"); break;
+ case int i: sb.Append(i); break;
+ case long l: sb.Append(l); break;
+ default: sb.Append(JsonSerializer.Serialize(value)); break;
+ }
+ }
+
+ public static string SerializeVersion(long version) =>
+ "{\"version\":" + version.ToString(System.Globalization.CultureInfo.InvariantCulture) + "}";
public static string SerializeEditResults(IReadOnlyList results)
{
@@ -1060,7 +1135,9 @@ public static string SerializeAnchorInfoOrNull(AnchorInfo? info)
sb.Append("{\"id\":").Append(JsonString(info.Id))
.Append(",\"kind\":").Append(JsonString(info.Kind))
.Append(",\"scope\":").Append(JsonString(info.Scope))
- .Append(",\"textPreview\":").Append(JsonString(info.TextPreview));
+ .Append(",\"textPreview\":").Append(JsonString(info.TextPreview))
+ .Append(",\"contentHash\":").Append(JsonString(info.ContentHash))
+ .Append(",\"visibleText\":").Append(JsonString(info.VisibleText));
if (info.AutoNumberPrefix is { } prefix)
sb.Append(",\"autoNumberPrefix\":").Append(JsonString(prefix));
sb.Append('}');
diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs
index de09063f..1d2e6cf4 100644
--- a/Docxodus/Internal/DocxSessionOps.cs
+++ b/Docxodus/Internal/DocxSessionOps.cs
@@ -13,6 +13,22 @@ namespace Docxodus.Internal;
///
internal static class DocxSessionOps
{
+ private static MutationPreconditions? ForTarget(MutationPreconditions? preconditions, string? anchorId) =>
+ preconditions is not null && preconditions.AnchorId is null && anchorId is not null
+ ? preconditions with { AnchorId = anchorId }
+ : preconditions;
+
+ private static string Mutate(
+ int handle,
+ MutationPreconditions? preconditions,
+ string? targetAnchorId,
+ System.Func mutation)
+ {
+ var session = SessionRegistry.Get(handle);
+ return DocxSessionJson.Serialize(
+ session.ExecuteMutation(ForTarget(preconditions, targetAnchorId), mutation));
+ }
+
// ─── Lifecycle ──────────────────────────────────────────────────────
public static int OpenSession(byte[] bytes, DocxSessionSettings? settings) =>
@@ -42,6 +58,23 @@ public static byte[] Save(int handle, bool persistAnchorIds) =>
public static byte[] SaveWithAnchorIds(int handle) =>
SessionRegistry.Get(handle).Save(persistAnchorIds: true);
+ public static long GetVersion(int handle) => SessionRegistry.Get(handle).Version;
+
+ public static string GetVersionJson(int handle) =>
+ DocxSessionJson.SerializeVersion(GetVersion(handle));
+
+ /// Read-only optimistic guard evaluation for dry runs and transport diagnostics.
+ public static string CheckPreconditions(int handle, MutationPreconditions? preconditions)
+ {
+ var error = SessionRegistry.Get(handle).EvaluatePreconditions(preconditions);
+ return DocxSessionJson.Serialize(error is null
+ ? new EditResult { Success = true }
+ : new EditResult { Success = false, Error = error });
+ }
+
+ internal static void RestorePreviewVersion(int handle, long version) =>
+ SessionRegistry.Get(handle).RestorePreviewVersion(version);
+
// ─── Projection + discovery ─────────────────────────────────────────
public static string Project(int handle) =>
@@ -208,27 +241,39 @@ public static string GetDiff(int handle, DiffFormat format) =>
// ─── Tier A: text mutations ─────────────────────────────────────────
- public static string ReplaceText(int handle, string anchorId, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ReplaceText(anchorId, markdown));
+ public static string ReplaceText(int handle, string anchorId, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.ReplaceText(anchorId, markdown));
- public static string DeleteBlock(int handle, string anchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteBlock(anchorId));
+ public static string DeleteBlock(int handle, string anchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.DeleteBlock(anchorId));
- public static string DeleteRange(int handle, string fromAnchorId, string toAnchorIdExclusive) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteRange(fromAnchorId, toAnchorIdExclusive));
+ public static string DeleteRange(int handle, string fromAnchorId, string toAnchorIdExclusive,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, fromAnchorId,
+ s => s.DeleteRange(fromAnchorId, toAnchorIdExclusive));
- public static string DeleteSection(int handle, string headingAnchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteSection(headingAnchorId));
+ public static string DeleteSection(int handle, string headingAnchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, headingAnchorId, s => s.DeleteSection(headingAnchorId));
public static string ReplaceTextRange(int handle, string anchorId, string find, string replace,
- ReplaceOptions? options) =>
- DocxSessionJson.SerializeEditResults(
+ ReplaceOptions? options, MutationPreconditions? preconditions = null)
+ {
+ if (preconditions is not null)
+ options = (options ?? new ReplaceOptions()) with
+ {
+ Preconditions = ForTarget(preconditions, anchorId),
+ };
+ return DocxSessionJson.SerializeEditResults(
SessionRegistry.Get(handle).ReplaceTextRange(anchorId, find, replace, options));
+ }
public static string ReplaceTextAtSpan(int handle, string anchorId, int spanStart, int spanLength,
- string replace) =>
- DocxSessionJson.Serialize(
- SessionRegistry.Get(handle).ReplaceTextAtSpan(anchorId, spanStart, spanLength, replace));
+ string replace, MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.ReplaceTextAtSpan(anchorId, spanStart, spanLength, replace));
///
/// Bracket-aware variant of . Parses the brackets out
@@ -241,29 +286,31 @@ public static string ReplaceTextAtSpan(int handle, string anchorId, int spanStar
/// (Fragments, ContextBefore, …) would be wasteful.
///
public static string ReplaceInner(int handle, string matchText, string anchorId,
- int spanStart, int spanLength, string newInner)
+ int spanStart, int spanLength, string newInner, MutationPreconditions? preconditions = null)
{
- int lb = matchText.IndexOf('[');
- int rb = matchText.LastIndexOf(']');
- if (lb < 0 || rb <= lb)
- return DocxSessionJson.Serialize(new EditResult
- {
- Success = false,
- Error = new EditError(EditErrorCode.MalformedMarkdown,
- $"match text has no balanced brackets: '{matchText}'", anchorId),
- });
- var prefix = matchText[..lb];
- var suffix = matchText[(rb + 1)..];
- return DocxSessionJson.Serialize(
- SessionRegistry.Get(handle).ReplaceTextAtSpan(anchorId, spanStart, spanLength, prefix + newInner + suffix));
+ return Mutate(handle, preconditions, anchorId, s =>
+ {
+ int lb = matchText.IndexOf('[');
+ int rb = matchText.LastIndexOf(']');
+ if (lb < 0 || rb <= lb)
+ return new EditResult
+ {
+ Success = false,
+ Error = new EditError(EditErrorCode.MalformedMarkdown,
+ $"match text has no balanced brackets: '{matchText}'", anchorId),
+ };
+ var prefix = matchText[..lb];
+ var suffix = matchText[(rb + 1)..];
+ return s.ReplaceTextAtSpan(anchorId, spanStart, spanLength, prefix + newInner + suffix);
+ });
}
// ─── Tier B: structural ─────────────────────────────────────────────
public static string MoveBlock(int handle, string sourceAnchorId, string targetAnchorId,
- Position position) =>
- DocxSessionJson.Serialize(
- SessionRegistry.Get(handle).MoveBlock(sourceAnchorId, targetAnchorId, position));
+ Position position, MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, sourceAnchorId,
+ s => s.MoveBlock(sourceAnchorId, targetAnchorId, position));
/// The blocks may legally move next to, and on which
/// side — what a drag UI gates its drop targets on, so it never offers a drop the engine
@@ -272,19 +319,25 @@ public static string ValidMoveTargets(int handle, string sourceAnchorId) =>
DocxSessionJson.SerializeMoveTargets(
SessionRegistry.Get(handle).ValidMoveTargets(sourceAnchorId));
- public static string InsertParagraph(int handle, string anchorId, Position position, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertParagraph(anchorId, position, markdown));
+ public static string InsertParagraph(int handle, string anchorId, Position position, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.InsertParagraph(anchorId, position, markdown));
- public static string SplitParagraph(int handle, string anchorId, int characterOffset) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SplitParagraph(anchorId, characterOffset));
+ public static string SplitParagraph(int handle, string anchorId, int characterOffset,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.SplitParagraph(anchorId, characterOffset));
- public static string MergeParagraphs(int handle, string firstAnchorId, string secondAnchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).MergeParagraphs(firstAnchorId, secondAnchorId));
+ public static string MergeParagraphs(int handle, string firstAnchorId, string secondAnchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, firstAnchorId,
+ s => s.MergeParagraphs(firstAnchorId, secondAnchorId));
- public static string InsertHorizontalRule(int handle, string anchorId, Position position, string ruleJson) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertHorizontalRule(
- anchorId, position,
- string.IsNullOrEmpty(ruleJson) ? null : ParseRuleEdge(ruleJson)));
+ public static string InsertHorizontalRule(int handle, string anchorId, Position position, string ruleJson,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.InsertHorizontalRule(
+ anchorId, position, string.IsNullOrEmpty(ruleJson) ? null : ParseRuleEdge(ruleJson)));
private static ParagraphBorderEdge? ParseRuleEdge(string json)
{
@@ -296,93 +349,124 @@ public static string InsertHorizontalRule(int handle, string anchorId, Position
// ─── Headers / footers / page numbers ───────────────────────────────
- public static string SetHeaderText(int handle, string anchorId, HeaderFooterKind kind, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetHeaderText(anchorId, kind, markdown));
+ public static string SetHeaderText(int handle, string anchorId, HeaderFooterKind kind, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.SetHeaderText(anchorId, kind, markdown));
- public static string SetFooterText(int handle, string anchorId, HeaderFooterKind kind, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetFooterText(anchorId, kind, markdown));
+ public static string SetFooterText(int handle, string anchorId, HeaderFooterKind kind, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.SetFooterText(anchorId, kind, markdown));
public static string InsertPageNumberField(
- int handle, string anchorId, PageNumberField field, NumberFormat? format = null) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertPageNumberField(anchorId, field, format));
+ int handle, string anchorId, PageNumberField field, NumberFormat? format = null,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.InsertPageNumberField(anchorId, field, format));
- public static string EnsureHeaderFooterVisible(int handle, string anchorId, HeaderFooterKind kind) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).EnsureHeaderFooterVisible(anchorId, kind));
+ public static string EnsureHeaderFooterVisible(int handle, string anchorId, HeaderFooterKind kind,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.EnsureHeaderFooterVisible(anchorId, kind));
- public static string SetPageNumbering(int handle, string anchorId, PageNumberingOp op) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetPageNumbering(anchorId, op));
+ public static string SetPageNumbering(int handle, string anchorId, PageNumberingOp op,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.SetPageNumbering(anchorId, op));
- public static string ClearPageNumbering(int handle, string anchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ClearPageNumbering(anchorId));
+ public static string ClearPageNumbering(int handle, string anchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.ClearPageNumbering(anchorId));
// ─── Footnotes / endnotes ───────────────────────────────────────────
- public static string InsertFootnote(int handle, string anchorId, int characterOffset, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertFootnote(anchorId, characterOffset, markdown));
+ public static string InsertFootnote(int handle, string anchorId, int characterOffset, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.InsertFootnote(anchorId, characterOffset, markdown));
- public static string InsertEndnote(int handle, string anchorId, int characterOffset, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertEndnote(anchorId, characterOffset, markdown));
+ public static string InsertEndnote(int handle, string anchorId, int characterOffset, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.InsertEndnote(anchorId, characterOffset, markdown));
// ─── Comments (issue #300) ──────────────────────────────────────────
public static string AddComment(int handle, string anchorId, CharSpan? span, string author,
- string? initials, string? dateIso, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddComment(
+ string? initials, string? dateIso, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.AddComment(
anchorId, span, author, markdown, initials, DocxSessionJson.ParseCommentDate(dateIso)));
public static string AddCommentToRevision(int handle, string revisionId, string author,
- string? initials, string? dateIso, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddCommentToRevision(
+ string? initials, string? dateIso, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, null, s => s.AddCommentToRevision(
revisionId, author, markdown, initials, DocxSessionJson.ParseCommentDate(dateIso)));
public static string AddCommentReply(int handle, string parentCommentAnchorId, string author,
- string? initials, string? dateIso, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddCommentReply(
+ string? initials, string? dateIso, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, parentCommentAnchorId, s => s.AddCommentReply(
parentCommentAnchorId, author, markdown, initials, DocxSessionJson.ParseCommentDate(dateIso)));
- public static string UpdateComment(int handle, string commentAnchorId, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).UpdateComment(commentAnchorId, markdown));
+ public static string UpdateComment(int handle, string commentAnchorId, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, commentAnchorId, s => s.UpdateComment(commentAnchorId, markdown));
- public static string SetCommentResolved(int handle, string commentAnchorId, bool resolved) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetCommentResolved(commentAnchorId, resolved));
+ public static string SetCommentResolved(int handle, string commentAnchorId, bool resolved,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, commentAnchorId,
+ s => s.SetCommentResolved(commentAnchorId, resolved));
- public static string RemoveComment(int handle, string commentAnchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveComment(commentAnchorId));
+ public static string RemoveComment(int handle, string commentAnchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, commentAnchorId, s => s.RemoveComment(commentAnchorId));
public static string ListComments(int handle) =>
DocxSessionJson.SerializeCommentList(SessionRegistry.Get(handle).ListComments());
// ─── Tier C: formatting ─────────────────────────────────────────────
- public static string ApplyFormat(int handle, string anchorId, CharSpan? span, FormatOp op) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyFormat(anchorId, span, op));
+ public static string ApplyFormat(int handle, string anchorId, CharSpan? span, FormatOp op,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.ApplyFormat(anchorId, span, op));
- public static string ApplyFormatBySubstring(int handle, string anchorId, string substring, FormatOp op) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyFormatToSubstring(anchorId, substring, op));
+ public static string ApplyFormatBySubstring(int handle, string anchorId, string substring, FormatOp op,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId,
+ s => s.ApplyFormatToSubstring(anchorId, substring, op));
- public static string SetParagraphStyle(int handle, string anchorId, string styleId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetParagraphStyle(anchorId, styleId));
+ public static string SetParagraphStyle(int handle, string anchorId, string styleId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.SetParagraphStyle(anchorId, styleId));
- public static string SetParagraphFormat(int handle, string anchorId, ParagraphFormatOp op) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetParagraphFormat(anchorId, op));
+ public static string SetParagraphFormat(int handle, string anchorId, ParagraphFormatOp op,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.SetParagraphFormat(anchorId, op));
- public static string SetListLevel(int handle, string anchorId, int levelDelta) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetListLevel(anchorId, levelDelta));
+ public static string SetListLevel(int handle, string anchorId, int levelDelta,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.SetListLevel(anchorId, levelDelta));
- public static string RemoveListMembership(int handle, string anchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveListMembership(anchorId));
+ public static string RemoveListMembership(int handle, string anchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.RemoveListMembership(anchorId));
- public static string ApplyListFormat(int handle, string anchorId, ListFormat kind) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyListFormat(anchorId, kind));
+ public static string ApplyListFormat(int handle, string anchorId, ListFormat kind,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.ApplyListFormat(anchorId, kind));
- public static string ApplyListFormatRange(int handle, string firstAnchorId, string lastAnchorId, ListFormat kind) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ApplyListFormatRange(firstAnchorId, lastAnchorId, kind));
+ public static string ApplyListFormatRange(int handle, string firstAnchorId, string lastAnchorId, ListFormat kind,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, firstAnchorId,
+ s => s.ApplyListFormatRange(firstAnchorId, lastAnchorId, kind));
- public static string SetListStartOverride(int handle, string anchorId, int value) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetListStartOverride(anchorId, value));
+ public static string SetListStartOverride(int handle, string anchorId, int value,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.SetListStartOverride(anchorId, value));
- public static string ClearListStartOverride(int handle, string anchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ClearListStartOverride(anchorId));
+ public static string ClearListStartOverride(int handle, string anchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.ClearListStartOverride(anchorId));
// ─── Tier D: tables ─────────────────────────────────────────────────
@@ -399,64 +483,78 @@ public static string ResolveTableCellCoordinate(
DocxSessionJson.SerializeTableCellResolutionResult(
SessionRegistry.Get(handle).ResolveTableCellCoordinate(tableAnchorId, rowIndex, columnIndex));
- public static string ReplaceCellContent(int handle, string cellAnchorId, string markdown) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).ReplaceCellContent(cellAnchorId, markdown));
+ public static string ReplaceCellContent(int handle, string cellAnchorId, string markdown,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId,
+ s => s.ReplaceCellContent(cellAnchorId, markdown));
- public static string InsertTable(int handle, string anchorId, Position position, int rows, int cols, string optionsJson) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertTable(
+ public static string InsertTable(int handle, string anchorId, Position position, int rows, int cols,
+ string optionsJson, MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.InsertTable(
anchorId, position, rows, cols, DocxSessionJson.ParseTableInsertOptions(optionsJson)));
- public static string InsertTableRow(int handle, string cellAnchorId, Position position) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertTableRow(cellAnchorId, position));
+ public static string InsertTableRow(int handle, string cellAnchorId, Position position,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.InsertTableRow(cellAnchorId, position));
- public static string InsertTableColumn(int handle, string cellAnchorId, Position position) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertTableColumn(cellAnchorId, position));
+ public static string InsertTableColumn(int handle, string cellAnchorId, Position position,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.InsertTableColumn(cellAnchorId, position));
- public static string DeleteTableRow(int handle, string cellAnchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteTableRow(cellAnchorId));
+ public static string DeleteTableRow(int handle, string cellAnchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.DeleteTableRow(cellAnchorId));
- public static string DeleteTableColumn(int handle, string cellAnchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).DeleteTableColumn(cellAnchorId));
+ public static string DeleteTableColumn(int handle, string cellAnchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.DeleteTableColumn(cellAnchorId));
// ─── Cell merge / unmerge (issue #340 Stage B) ──────────────────────
/// is "append" (default) | "discard" | "reject" —
/// what happens to the content of the cells the merge absorbs.
public static string MergeCells(int handle, string cellAnchorId, int rowSpan, int colSpan,
- string? content) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).MergeCells(cellAnchorId, rowSpan, colSpan,
+ string? content, MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.MergeCells(cellAnchorId, rowSpan, colSpan,
new TableMergeOptions { Content = DocxSessionJson.ParseTableMergeContent(content) }));
- public static string UnmergeCells(int handle, string cellAnchorId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).UnmergeCells(cellAnchorId));
+ public static string UnmergeCells(int handle, string cellAnchorId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.UnmergeCells(cellAnchorId));
// ─── Table styling (issue #315 Stage A) ─────────────────────────────
/// is a JSON array of per-column twip widths
/// (one positive value per column, left→right).
- public static string SetColumnWidths(int handle, string cellAnchorId, string widthsJson) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetColumnWidths(
- cellAnchorId, DocxSessionJson.ParseIntArray(widthsJson)));
+ public static string SetColumnWidths(int handle, string cellAnchorId, string widthsJson,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId,
+ s => s.SetColumnWidths(cellAnchorId, DocxSessionJson.ParseIntArray(widthsJson)));
/// is a TableBorderSpec object
/// ({ scope?: "all"|"outside"|"inside", style?, size?, color? }); "" uses the defaults.
- public static string SetTableBorders(int handle, string cellAnchorId, string specJson) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetTableBorders(
- cellAnchorId, DocxSessionJson.ParseTableBorderSpec(specJson)));
+ public static string SetTableBorders(int handle, string cellAnchorId, string specJson,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId,
+ s => s.SetTableBorders(cellAnchorId, DocxSessionJson.ParseTableBorderSpec(specJson)));
/// is a hex RRGGBB triplet or "auto"; "" clears the shading.
/// is "cell" | "row".
- public static string SetCellShading(int handle, string cellAnchorId, string fill, string scope) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetCellShading(
+ public static string SetCellShading(int handle, string cellAnchorId, string fill, string scope,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.SetCellShading(
cellAnchorId, string.IsNullOrEmpty(fill) ? null : fill,
DocxSessionJson.ParseTableShadingScope(scope)));
- public static string SetRepeatHeaderRow(int handle, string cellAnchorId, bool repeat) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetRepeatHeaderRow(cellAnchorId, repeat));
+ public static string SetRepeatHeaderRow(int handle, string cellAnchorId, bool repeat,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId,
+ s => s.SetRepeatHeaderRow(cellAnchorId, repeat));
public static string SetTableRowOptions(int handle, string cellAnchorId, bool? repeatHeader,
- bool? allowBreakAcrossPages, int? heightTwips, string? heightRule) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetTableRowOptions(cellAnchorId,
+ bool? allowBreakAcrossPages, int? heightTwips, string? heightRule,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, cellAnchorId, s => s.SetTableRowOptions(cellAnchorId,
new TableRowOptions
{
RepeatHeader = repeatHeader,
@@ -470,30 +568,34 @@ public static string SetTableRowOptions(int handle, string cellAnchorId, bool? r
public static string RawGetXml(int handle, string anchorId) =>
SessionRegistry.Get(handle).Raw.GetXml(anchorId);
- public static string RawInsertXml(int handle, string anchorId, Position position, string xml) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).Raw.InsertXml(anchorId, position, xml));
+ public static string RawInsertXml(int handle, string anchorId, Position position, string xml,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.Raw.InsertXml(anchorId, position, xml));
- public static string RawReplaceXml(int handle, string anchorId, string xml) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).Raw.ReplaceXml(anchorId, xml));
+ public static string RawReplaceXml(int handle, string anchorId, string xml,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.Raw.ReplaceXml(anchorId, xml));
// ─── Tier E: annotations ────────────────────────────────────────────
public static string AddAnnotation(int handle, string anchorId, CharSpan? span,
- string annotationJson) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddAnnotation(
+ string annotationJson, MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, anchorId, s => s.AddAnnotation(
anchorId, span, DocxSessionJson.DeserializeAnnotation(annotationJson)));
- public static string RemoveAnnotation(int handle, string annotationId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveAnnotation(annotationId));
+ public static string RemoveAnnotation(int handle, string annotationId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, null, s => s.RemoveAnnotation(annotationId));
- public static string UpdateAnnotation(int handle, string annotationId, string updateJson) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).UpdateAnnotation(
+ public static string UpdateAnnotation(int handle, string annotationId, string updateJson,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, null, s => s.UpdateAnnotation(
annotationId, DocxSessionJson.DeserializeAnnotationUpdate(updateJson)));
public static string MoveAnnotation(int handle, string annotationId, string newAnchorId,
- CharSpan? newSpan) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).MoveAnnotation(
- annotationId, newAnchorId, newSpan));
+ CharSpan? newSpan, MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, newAnchorId,
+ s => s.MoveAnnotation(annotationId, newAnchorId, newSpan));
// ─── Tracked revisions (issue #318) ─────────────────────────────────
@@ -502,11 +604,13 @@ public static string MoveAnnotation(int handle, string annotationId, string newA
public static string ListRevisions(int handle) =>
DocxSessionJson.SerializeRevisionList(SessionRegistry.Get(handle).ListRevisions());
- public static string AcceptRevision(int handle, string revisionId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).AcceptRevision(revisionId));
+ public static string AcceptRevision(int handle, string revisionId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, null, s => s.AcceptRevision(revisionId));
- public static string RejectRevision(int handle, string revisionId) =>
- DocxSessionJson.Serialize(SessionRegistry.Get(handle).RejectRevision(revisionId));
+ public static string RejectRevision(int handle, string revisionId,
+ MutationPreconditions? preconditions = null) =>
+ Mutate(handle, preconditions, null, s => s.RejectRevision(revisionId));
// ─── Undo / Redo ────────────────────────────────────────────────────
@@ -514,6 +618,16 @@ public static string RejectRevision(int handle, string revisionId) =>
public static bool Redo(int handle) => SessionRegistry.Get(handle).Redo();
+ public static string UndoChecked(int handle, MutationPreconditions? preconditions) =>
+ Mutate(handle, preconditions, null, s => s.Undo()
+ ? new EditResult { Success = true }
+ : EditResult.Fail(EditErrorCode.NothingToUndo, "nothing to undo"));
+
+ public static string RedoChecked(int handle, MutationPreconditions? preconditions) =>
+ Mutate(handle, preconditions, null, s => s.Redo()
+ ? new EditResult { Success = true }
+ : EditResult.Fail(EditErrorCode.NothingToRedo, "nothing to redo"));
+
// ─── Session configuration (issue #304) ─────────────────────────────
public static void SetTrackedChanges(int handle, TrackedChangeMode mode) =>
diff --git a/Docxodus/Internal/UndoRing.cs b/Docxodus/Internal/UndoRing.cs
index c7c56d84..dd4dafb0 100644
--- a/Docxodus/Internal/UndoRing.cs
+++ b/Docxodus/Internal/UndoRing.cs
@@ -36,6 +36,8 @@ internal sealed class UndoRing
private readonly int _capacity;
private readonly long _budgetBytes;
private readonly Func? _costOf;
+ private readonly Action? _onRecordPreOp;
+ private readonly Action? _onPopUndo;
private long _undoBytes;
private long _redoBytes;
@@ -47,11 +49,18 @@ internal sealed class UndoRing
/// undo and redo sides together. Values <= 0 disable the budget bound (depth only).
/// Approximate retained cost of one snapshot. Null (or a zero budget)
/// leaves the ring depth-bounded only, exactly as before.
- public UndoRing(int capacity, long budgetBytes = 0, Func? costOf = null)
+ public UndoRing(
+ int capacity,
+ long budgetBytes = 0,
+ Func? costOf = null,
+ Action? onRecordPreOp = null,
+ Action? onPopUndo = null)
{
_capacity = capacity > 0 ? capacity : 1;
_budgetBytes = budgetBytes > 0 ? budgetBytes : 0;
_costOf = costOf;
+ _onRecordPreOp = onRecordPreOp;
+ _onPopUndo = onPopUndo;
}
private long CostOf(T snapshot) =>
@@ -77,6 +86,7 @@ public void RecordPreOp(T preOpSnapshot)
_undoBytes += costBytes;
ClearRedo();
Trim();
+ _onRecordPreOp?.Invoke(preOpSnapshot);
}
/// Pop the most recent pre-op snapshot (for an undo).
@@ -86,6 +96,7 @@ public void RecordPreOp(T preOpSnapshot)
var entry = _undo.Last!.Value;
_undo.RemoveLast();
_undoBytes -= entry.CostBytes;
+ _onPopUndo?.Invoke(entry.Snapshot);
return (entry.Snapshot, true);
}
diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md
index 95e33b06..e71c0e75 100644
--- a/docs/architecture/docx_agent_server.md
+++ b/docs/architecture/docx_agent_server.md
@@ -408,6 +408,15 @@ is `ok`/`partial`/`failed`). `mode: preview` runs every step exactly the same wa
`DocxSessionOps.Undo` once per step that actually mutated before returning — see Known gaps for
why this is "apply-then-undo" rather than a true no-op dry run.
+The batch itself and each step's `args` may carry `preconditions`, using the same
+camel-case guard object as the core API (`expectedVersion`, `anchorId`,
+`expectedContentHash`, exact text/range/kind/scope, and `expectedMatchCount`). A
+failure is the standard structured `precondition_failed` result and does not
+mutate that step. `docxodus_get_content` with `format: "version"` reads the current
+monotonic document version; `format: "check_preconditions"` evaluates guards
+without mutating. Preview restores its starting version after undoing speculative
+steps, so a dry-run does not make an otherwise-current plan stale.
+
### `docxodus_table` — tables
`insert`, `insert_row`, `insert_column`, `delete_row`, `delete_column`, `replace_cell_content`,
diff --git a/docs/architecture/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md
index fd08bb2e..b9f132c5 100644
--- a/docs/architecture/docx_mutation_api.md
+++ b/docs/architecture/docx_mutation_api.md
@@ -18,6 +18,67 @@ Three design forces, in order of weight:
**Errors must be pattern-matchable, not stringly-typed.** Every mutation returns an `EditResult` envelope; failure carries a typed `EditErrorCode` with a remediation message. The same enum is exposed as a snake-case string union in TypeScript, so JS agents pattern-match the same way C# callers do. No method on the session throws across the boundary (the constructor and `Save()` are the only places that can — and only for fatal conditions like an invalid DOCX or IO failure).
+## Document version and optimistic preconditions
+
+Every session exposes a monotonic `long Version`. It is `0` when the document is
+opened and advances exactly once for each committed document mutation. A
+multi-match `ReplaceTextRange` is one mutation. `Undo()` and `Redo()` also advance
+the version—restoring older content never restores an older caller-visible
+version. Validation failures, precondition failures, exceptions that roll back,
+and successful no-ops do not advance it. Version state is carried in internal
+snapshots so rollback and speculative preview restoration cannot leak a version
+change.
+
+Callers that derived an edit from an earlier projection can guard the mutation:
+
+```csharp
+var info = session.GetAnchorInfo(anchor)!;
+var result = session.ExecuteMutation(
+ new MutationPreconditions
+ {
+ ExpectedVersion = session.Version,
+ AnchorId = anchor,
+ ExpectedContentHash = info.ContentHash,
+ ExpectedText = info.VisibleText,
+ ExpectedKind = info.Kind,
+ ExpectedScope = info.Scope,
+ },
+ s => s.ReplaceText(anchor, replacement));
+```
+
+`MutationPreconditions` fields are optional and ANDed:
+
+| .NET | wire | Meaning |
+|---|---|---|
+| `ExpectedVersion` | `expectedVersion` | The session version used to derive the plan. |
+| `AnchorId` | `anchorId` | Guard target. Mutation façades infer their primary target when omitted. |
+| `ExpectedContentHash` | `expectedContentHash` | Hash of the target's current OOXML subtree, also returned by `GetAnchorInfo`. |
+| `ExpectedText` | `expectedText` | Exact current visible text, also returned as `AnchorInfo.VisibleText`. |
+| `ExpectedTextRange` | `expectedTextRange: {start,length,text}` | Exact ordinal substring guard over visible text. |
+| `ExpectedKind` / `ExpectedScope` | `expectedKind` / `expectedScope` | Current canonical anchor metadata. A stale kind prefix still resolves by Unid and reports the new kind. |
+| `ExpectedMatchCount` | `expectedMatchCount` | Exact live occurrence count for `ReplaceTextRange`. |
+
+`EvaluatePreconditions`/transport `checkPreconditions` are read-only probes.
+`ExecuteMutation` is the direct .NET gated primitive used by the shared façade.
+`ReplaceTextRange` holds the same gate across initial guard evaluation, live match
+enumeration/counting, and every replacement, so another mutation cannot slip
+between count and commit.
+
+On mismatch, no document bytes or history entry change and the result has code
+`PreconditionFailed` (`precondition_failed` on the wire). Its `precondition` member
+contains `condition`, `expected`, `actual`, `currentVersion`, and `currentTarget`
+(`exists`, canonical anchor id/kind/scope, content hash, and exact visible text).
+That is enough for an agent to decide whether to rebase, retarget, or abandon the
+edit without an extra diagnostic round trip.
+
+The common wire object is available throughout the stack: npm exposes
+`getVersion`, `checkPreconditions`, and `runWithPreconditions`; Python exposes
+`get_version`, `check_preconditions`, and a `session.preconditioned(...)` context
+that attaches the guard to each mutation request; stdio accepts top-level
+`preconditions`; MCP mutation tools and individual batch steps accept the same
+property. MCP batches may additionally carry a batch-start guard. Preview mode
+restores the starting version after it undoes its speculative edits.
+
## Architecture
```
@@ -1238,7 +1299,9 @@ session.ReplaceMatch(textMatch, replace) // convenience fo
session.ReplaceTextAtSpan(anchor, spanStart, spanLength, repl) // exact-span variant when several identical needles share a block
```
-`ReplaceOptions`: `IgnoreCase` (case-insensitive find) and `MaxReplacements` (cap on how many to apply).
+`ReplaceOptions`: `IgnoreCase` (case-insensitive find), `MaxReplacements` (cap on
+how many to apply), `ExpectedMatchCount` (require the exact live count before the
+cap), and `Preconditions` (the common optimistic guard object).
### Formatting-preservation contract
@@ -1254,7 +1317,7 @@ Revision wrappers stay inside a match's hyperlink, run-level SDT, `smartTag`, or
### Ordering and atomicity
-Multiple matches in the same paragraph are applied in **reverse document order** so each earlier-offset match's span stays valid after later edits land — the same trick the projector uses for tracked-change accept passes. The whole call records **one** snapshot; `Undo()` rolls every replacement back together.
+Multiple matches in the same paragraph are applied in **reverse document order** so each earlier-offset match's span stays valid after later edits land — the same trick the projector uses for tracked-change accept passes. The whole call records **one** snapshot; `Undo()` rolls every replacement back together. Preconditions, exact occurrence counting, and the rewrite execute under one mutation gate; a count mismatch returns one failed result and leaves bytes, version, and undo history unchanged.
### When to reach for the span-addressed variant
@@ -1521,6 +1584,7 @@ Errors are grouped by what the agent should do in response, not by where in the
| The agent should… | When it sees these codes |
|---|---|
+| Re-read the current version/target metadata in `error.precondition`, rebase or abandon the stale edit, then retry with fresh guards | `PreconditionFailed` |
| Re-project and re-derive the anchor from current text | `AnchorNotFound` |
| Re-list revisions (`ListRevisions`) and reissue with a current id | `RevisionNotFound` |
| Re-read the anchor's kind via `GetAnchorInfo`, reissue with the right op or coordinates | `AnchorWrongKind`, `TableAnchorMigrationRequired`, `AnchorsNotAdjacent`, `InvalidPosition`, `OffsetOutOfRange`, `EmptyCommentSpan` |
diff --git a/npm/src/session.ts b/npm/src/session.ts
index 2ee951ed..66ba672a 100644
--- a/npm/src/session.ts
+++ b/npm/src/session.ts
@@ -38,6 +38,7 @@ import type {
ListFormat,
GrepOptions,
ListMembership,
+ MutationPreconditions,
ReplaceOptions,
RevisionListEntry,
SectionInfo,
@@ -71,6 +72,31 @@ export class DocxSession {
return JSON.parse(this.wasm.Project(this.handle)) as DocxSessionProjection;
}
+ /** Monotonic document version (0 at open; +1 per committed mutation/undo/redo). */
+ getVersion(): number {
+ return (JSON.parse(this.wasm.GetVersion(this.handle)) as { version: number }).version;
+ }
+
+ /** Evaluate optimistic guards without mutating or advancing the version. */
+ checkPreconditions(preconditions: MutationPreconditions): EditResult {
+ return JSON.parse(
+ this.wasm.CheckPreconditions(this.handle, JSON.stringify(preconditions)),
+ ) as EditResult;
+ }
+
+ /**
+ * Guard any synchronous mutation. WASM calls are synchronous and single-threaded, so the
+ * check and callback form one uninterrupted client-side operation. Prefer a method's native
+ * `preconditions` option where it has one (notably replaceTextRange's match-count guard).
+ */
+ runWithPreconditions(
+ preconditions: MutationPreconditions,
+ mutation: () => EditResult,
+ ): EditResult {
+ const checked = this.checkPreconditions(preconditions);
+ return checked.success ? mutation() : checked;
+ }
+
/**
* Project a slice of the document keyed off an anchor — useful for showing
* one section to an LLM at a time without paying the cost of projecting the
@@ -122,12 +148,26 @@ export class DocxSession {
// ─── Tier A: text CRUD ───────────────────────────────────────────────
- replaceText(anchorId: string, markdown: string): EditResult {
- return JSON.parse(this.wasm.ReplaceText(this.handle, anchorId, markdown)) as EditResult;
+ replaceText(
+ anchorId: string,
+ markdown: string,
+ preconditions?: MutationPreconditions,
+ ): EditResult {
+ const apply = () => JSON.parse(
+ this.wasm.ReplaceText(this.handle, anchorId, markdown),
+ ) as EditResult;
+ return preconditions
+ ? this.runWithPreconditions(
+ { ...preconditions, anchorId: preconditions.anchorId ?? anchorId }, apply)
+ : apply();
}
- deleteBlock(anchorId: string): EditResult {
- return JSON.parse(this.wasm.DeleteBlock(this.handle, anchorId)) as EditResult;
+ deleteBlock(anchorId: string, preconditions?: MutationPreconditions): EditResult {
+ const apply = () => JSON.parse(this.wasm.DeleteBlock(this.handle, anchorId)) as EditResult;
+ return preconditions
+ ? this.runWithPreconditions(
+ { ...preconditions, anchorId: preconditions.anchorId ?? anchorId }, apply)
+ : apply();
}
/** Reorder one top-level paragraph/heading/list/table block relative to another. */
@@ -1245,5 +1285,5 @@ export function openDocxSession(
return new DocxSession(handle, bridge);
}
-export type { AnchorInfo, AnchorRef, AnchorTargetRef, BlockSlice, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, DocxSessionProjection, DocxSessionSettings, EditError, EditErrorCode, EditResult, FindOptions, FormatOp, GrepOptions, MarkdownPatch, PlaceholderKind, ReplaceOptions, RunFormatting, RunFragment, TemplatePlaceholder, TextMatch } from "./types.js";
+export type { AnchorInfo, AnchorRef, AnchorTargetRef, BlockSlice, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, DocxSessionProjection, DocxSessionSettings, EditError, EditErrorCode, EditResult, FindOptions, FormatOp, GrepOptions, MarkdownPatch, MutationPreconditions, PlaceholderKind, PreconditionFailure, PreconditionTarget, ReplaceOptions, RunFormatting, RunFragment, TemplatePlaceholder, TextMatch, TextRangePrecondition } from "./types.js";
export { ContextBoundary, PlaceholderKinds } from "./types.js";
diff --git a/npm/src/types.ts b/npm/src/types.ts
index c2d1c2c2..b772fc06 100644
--- a/npm/src/types.ts
+++ b/npm/src/types.ts
@@ -1049,6 +1049,8 @@ export interface DocxodusWasmExports {
CloseSession: (handle: number) => void;
CreateBlankDocx: () => Uint8Array;
Project: (handle: number) => string;
+ GetVersion: (handle: number) => string;
+ CheckPreconditions: (handle: number, preconditionsJson: string) => string;
ProjectAnchor: (handle: number, anchorId: string, depth: number) => string;
/** Ordered top-level render units per scope container (JSON {@link RenderPlan}) —
* what the editor's incremental reconciler diffs its DOM against. Optional:
@@ -1293,6 +1295,7 @@ export type EditErrorCode =
| "empty_annotation_span"
| "empty_comment_span"
| "revision_not_found"
+ | "precondition_failed"
| "internal_error";
export interface AnchorRef {
@@ -1306,6 +1309,43 @@ export interface EditError {
code: EditErrorCode;
message: string;
anchorId?: string;
+ precondition?: PreconditionFailure;
+}
+
+export interface PreconditionTarget {
+ exists: boolean;
+ anchorId?: string;
+ kind?: string;
+ scope?: string;
+ contentHash?: string;
+ visibleText?: string;
+}
+
+export interface PreconditionFailure {
+ condition: string;
+ expected: unknown;
+ actual: unknown;
+ currentVersion: number;
+ currentTarget?: PreconditionTarget;
+}
+
+export interface TextRangePrecondition {
+ start: number;
+ length: number;
+ text: string;
+}
+
+/** Optimistic guards checked immediately before a mutation. */
+export interface MutationPreconditions {
+ expectedVersion?: number;
+ /** Optional explicit target; target-addressed methods infer their own anchor when omitted. */
+ anchorId?: string;
+ expectedContentHash?: string;
+ expectedText?: string;
+ expectedTextRange?: TextRangePrecondition;
+ expectedKind?: string;
+ expectedScope?: string;
+ expectedMatchCount?: number;
}
export interface MarkdownPatch {
@@ -1786,6 +1826,10 @@ export interface ReplaceOptions {
ignoreCase?: boolean;
/** Cap the number of replacements; omitted = unlimited. */
maxReplacements?: number;
+ /** Require exactly this many occurrences before applying any replacement. */
+ expectedMatchCount?: number;
+ /** Optional document/anchor guards evaluated before searching. */
+ preconditions?: MutationPreconditions;
}
/**
@@ -2022,6 +2066,10 @@ export interface AnchorInfo {
kind: string;
scope: string;
textPreview: string;
+ /** Exact live subtree hash suitable for expectedContentHash. */
+ contentHash: string;
+ /** Exact (untruncated) reader-visible text suitable for expectedText. */
+ visibleText: string;
/** Resolved auto-numbering prefix (e.g. "1.", "First") when the element carries
* numbering. Absent for un-numbered paragraphs or non-paragraph kinds. */
autoNumberPrefix?: string;
diff --git a/python/README.md b/python/README.md
index 556fcf5d..e897c90e 100644
--- a/python/README.md
+++ b/python/README.md
@@ -116,7 +116,7 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a
| Tier | Methods |
|---|---|
-| **Lifecycle** | `save`, `close`, `undo`, `redo`, `to_html` |
+| **Lifecycle** | `save`, `close`, `undo`, `redo`, `get_version`, `to_html` |
| **Projection** | `project`, `project_anchor` |
| **Discovery** | `grep`, `grep_cross_block`, `find_placeholders`, `find_by_text`, `find_all_by_text`, `find_by_regex`, `find_by_kind`, `find_by_annotation`, `find_by_label`, `find_by_bookmark`, `list_annotations`, `exists`, `get_anchor_info`, `get_anchor_infos`, `get_edit_summary`, `remaining_placeholders`, `get_diff` |
| **Inspection** | `get_block_metadata`, `get_block_metadatas`, `get_list_membership`, `get_section_info` |
@@ -133,6 +133,14 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a
Every mutation method returns an `EditResult` envelope — transport-level failures raise `DocxodusTransportError`, but a business outcome (`anchor_not_found`, `malformed_markdown`, etc.) returns `EditResult(success=False, error=EditError(...))`. **Never** an exception across the API boundary.
+For optimistic concurrency, build a `MutationPreconditions` object and use
+`session.check_preconditions(...)` for a read-only probe or
+`with session.preconditioned(guards): ...` to attach it to each mutation request in
+the block. The guard can require the document version, anchor hash/exact visible
+text or range/kind/scope, and an exact replacement match count. A mismatch returns
+`EditErrorCode.PRECONDITION_FAILED` with structured expected/actual/current target
+metadata and leaves bytes, version, and undo history unchanged.
+
### Stateless functions
Alongside the session API, the package exposes stateless one-shot functions at the module root — no session handle, they take DOCX bytes in and return bytes / data out:
diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py
index 084a5153..1000e62b 100644
--- a/python/src/docx_scalpel/__init__.py
+++ b/python/src/docx_scalpel/__init__.py
@@ -108,17 +108,21 @@
ListMembership,
MarkdownPatch,
MarkdownProjection,
+ MutationPreconditions,
NumberFormat,
ParagraphBorderEdge,
ParagraphFormatOp,
ReplaceOptions,
RetainedTableAnchor,
+ PreconditionFailure,
+ PreconditionTarget,
RevisionListEntry,
RunFormatting,
RunFragment,
SectionInfo,
TemplatePlaceholder,
TextMatch,
+ TextRangePrecondition,
WmlToMarkdownConverterSettings,
TableAnchorLocation,
TableAnchorMapping,
@@ -183,17 +187,21 @@
"ListMembership",
"MarkdownPatch",
"MarkdownProjection",
+ "MutationPreconditions",
"NumberFormat",
"ParagraphBorderEdge",
"ParagraphFormatOp",
"ReplaceOptions",
"RetainedTableAnchor",
+ "PreconditionFailure",
+ "PreconditionTarget",
"RevisionListEntry",
"RunFormatting",
"RunFragment",
"SectionInfo",
"TemplatePlaceholder",
"TextMatch",
+ "TextRangePrecondition",
"WmlToMarkdownConverterSettings",
"TableAnchorLocation",
"TableAnchorMapping",
diff --git a/python/src/docx_scalpel/enums.py b/python/src/docx_scalpel/enums.py
index c0a2fb76..8785184f 100644
--- a/python/src/docx_scalpel/enums.py
+++ b/python/src/docx_scalpel/enums.py
@@ -168,6 +168,7 @@ class EditErrorCode(str, Enum):
DISALLOWED_NAMESPACE = "disallowed_namespace"
INCOMPATIBLE_ELEMENT_TYPE = "incompatible_element_type"
VALIDATION_FAILED = "validation_failed"
+ PRECONDITION_FAILED = "precondition_failed"
NOTHING_TO_UNDO = "nothing_to_undo"
NOTHING_TO_REDO = "nothing_to_redo"
DUPLICATE_ANNOTATION_ID = "duplicate_annotation_id"
diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py
index 696b6df3..c2ecd735 100644
--- a/python/src/docx_scalpel/session.py
+++ b/python/src/docx_scalpel/session.py
@@ -21,7 +21,8 @@
from __future__ import annotations
import base64
-from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Sequence
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Mapping, Sequence
if TYPE_CHECKING:
from types import TracebackType
@@ -67,6 +68,7 @@
HtmlOptions,
ListMembership,
MarkdownProjection,
+ MutationPreconditions,
NumberFormat,
ParagraphFormatOp,
ReplaceOptions,
@@ -358,11 +360,12 @@ class DocxSession:
Construct via :func:`open_session`; never instantiate directly.
"""
- __slots__ = ("_handle", "_closed")
+ __slots__ = ("_handle", "_closed", "_active_preconditions")
def __init__(self, handle: int) -> None:
self._handle = handle
self._closed = False
+ self._active_preconditions: MutationPreconditions | None = None
# -- lifecycle --------------------------------------------------------
@@ -452,11 +455,43 @@ def set_revision_author(self, author: str | None) -> None:
def undo(self) -> bool:
"""Undo one snapshot. Returns ``True`` if the undo ring had something to pop."""
- return bool(self._call("undo", {}))
+ result = self._call("undo", {})
+ return bool(result.get("success")) if isinstance(result, dict) else bool(result)
def redo(self) -> bool:
"""Redo one snapshot. Returns ``True`` if the redo ring had something to pop."""
- return bool(self._call("redo", {}))
+ result = self._call("redo", {})
+ return bool(result.get("success")) if isinstance(result, dict) else bool(result)
+
+ def get_version(self) -> int:
+ """Return the monotonic version of the live document state."""
+ result = self._call("get_version", {})
+ if not isinstance(result, dict) or not isinstance(result.get("version"), int):
+ raise TypeError(f"get_version: expected {{version: int}}, got {result!r}")
+ return int(result["version"])
+
+ def check_preconditions(self, preconditions: MutationPreconditions) -> EditResult:
+ """Evaluate guards without mutating the document or advancing its version."""
+ return EditResult._from_wire(
+ self._call("check_preconditions", {"preconditions": preconditions.to_wire()})
+ )
+
+ @contextmanager
+ def preconditioned(
+ self, preconditions: MutationPreconditions
+ ) -> Iterator["DocxSession"]:
+ """Attach guards to each mutation request made inside the context.
+
+ The host evaluates them immediately before that request's mutation. Nested
+ contexts restore the previous guard on exit. A session should not be shared
+ across threads while a precondition context is active.
+ """
+ previous = self._active_preconditions
+ self._active_preconditions = preconditions
+ try:
+ yield self
+ finally:
+ self._active_preconditions = previous
# -- projection -------------------------------------------------------
@@ -1507,6 +1542,8 @@ def raw(self) -> "_RawOps":
def _call(self, op: str, args: dict[str, Any]) -> Any:
if self._closed:
raise ValueError(f"session {self._handle} is closed")
+ if self._active_preconditions is not None and "preconditions" not in args:
+ args = {**args, "preconditions": self._active_preconditions.to_wire()}
payload = {"handle": self._handle, **args}
return _call(op, payload)
diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py
index afc64ef8..d14d53ad 100644
--- a/python/src/docx_scalpel/types.py
+++ b/python/src/docx_scalpel/types.py
@@ -53,6 +53,10 @@
"MarkdownPatch",
"AnchorTarget",
"AnchorInfo",
+ "PreconditionTarget",
+ "PreconditionFailure",
+ "TextRangePrecondition",
+ "MutationPreconditions",
"BlockMetadata",
"BulkEditResult",
"FillOptions",
@@ -315,6 +319,8 @@ class AnchorInfo:
kind: str
scope: str
text_preview: str
+ content_hash: str | None = None
+ visible_text: str | None = None
@classmethod
def _from_wire(cls, d: Mapping[str, Any]) -> "AnchorInfo":
@@ -323,9 +329,92 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "AnchorInfo":
kind=d["kind"],
scope=d["scope"],
text_preview=d.get("textPreview", ""),
+ content_hash=d.get("contentHash"),
+ visible_text=d.get("visibleText"),
)
+@dataclass(frozen=True, slots=True)
+class PreconditionTarget:
+ """Current target metadata returned when an optimistic guard fails."""
+
+ exists: bool
+ anchor_id: str | None = None
+ kind: str | None = None
+ scope: str | None = None
+ content_hash: str | None = None
+ visible_text: str | None = None
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "PreconditionTarget":
+ return cls(
+ exists=bool(d.get("exists", False)),
+ anchor_id=d.get("anchorId"),
+ kind=d.get("kind"),
+ scope=d.get("scope"),
+ content_hash=d.get("contentHash"),
+ visible_text=d.get("visibleText"),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class PreconditionFailure:
+ """Expected/actual detail for ``PRECONDITION_FAILED``."""
+
+ condition: str
+ expected: Any
+ actual: Any
+ current_version: int
+ current_target: PreconditionTarget | None = None
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "PreconditionFailure":
+ target = d.get("currentTarget")
+ return cls(
+ condition=str(d.get("condition", "")),
+ expected=d.get("expected"),
+ actual=d.get("actual"),
+ current_version=int(d.get("currentVersion", 0)),
+ current_target=PreconditionTarget._from_wire(target) if target else None,
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class TextRangePrecondition:
+ start: int
+ length: int
+ text: str
+
+ def to_wire(self) -> dict[str, Any]:
+ return {"start": self.start, "length": self.length, "text": self.text}
+
+
+@dataclass(frozen=True, slots=True)
+class MutationPreconditions:
+ """Optional optimistic guards evaluated immediately before a mutation."""
+
+ expected_version: int | None = None
+ anchor_id: str | None = None
+ expected_content_hash: str | None = None
+ expected_text: str | None = None
+ expected_text_range: TextRangePrecondition | None = None
+ expected_kind: str | None = None
+ expected_scope: str | None = None
+ expected_match_count: int | None = None
+
+ def to_wire(self) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ if self.expected_version is not None: out["expectedVersion"] = self.expected_version
+ if self.anchor_id is not None: out["anchorId"] = self.anchor_id
+ if self.expected_content_hash is not None: out["expectedContentHash"] = self.expected_content_hash
+ if self.expected_text is not None: out["expectedText"] = self.expected_text
+ if self.expected_text_range is not None: out["expectedTextRange"] = self.expected_text_range.to_wire()
+ if self.expected_kind is not None: out["expectedKind"] = self.expected_kind
+ if self.expected_scope is not None: out["expectedScope"] = self.expected_scope
+ if self.expected_match_count is not None: out["expectedMatchCount"] = self.expected_match_count
+ return out
+
+
class NumberFormat(str, Enum):
"""Six list formats supported by the list write surface and surfaced
on ``ListMembership.format``. String-valued so the wire JSON round-trips
@@ -756,6 +845,7 @@ class EditError:
code: EditErrorCode
message: str
anchor_id: str | None = None
+ precondition: PreconditionFailure | None = None
@classmethod
def _from_wire(cls, d: Mapping[str, Any]) -> "EditError":
@@ -763,6 +853,8 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "EditError":
code=EditErrorCode(d["code"]),
message=d.get("message", ""),
anchor_id=d.get("anchorId"),
+ precondition=PreconditionFailure._from_wire(d["precondition"])
+ if d.get("precondition") else None,
)
@@ -1105,11 +1197,15 @@ class ReplaceOptions:
ignore_case: bool = False
max_replacements: int | None = None
+ expected_match_count: int | None = None
+ preconditions: MutationPreconditions | None = None
def to_wire(self) -> dict[str, Any]:
out: dict[str, Any] = {}
if self.ignore_case: out["ignoreCase"] = True
if self.max_replacements is not None: out["maxReplacements"] = self.max_replacements
+ if self.expected_match_count is not None: out["expectedMatchCount"] = self.expected_match_count
+ if self.preconditions is not None: out["preconditions"] = self.preconditions.to_wire()
return out
diff --git a/python/tests/test_preconditions.py b/python/tests/test_preconditions.py
new file mode 100644
index 00000000..fa44c2ee
--- /dev/null
+++ b/python/tests/test_preconditions.py
@@ -0,0 +1,79 @@
+"""Version and optimistic-mutation transport coverage (issue #447)."""
+
+from __future__ import annotations
+
+from docx_scalpel import (
+ EditErrorCode,
+ MutationPreconditions,
+ ReplaceOptions,
+ TextRangePrecondition,
+ open_session,
+)
+
+
+def test_version_and_structured_preconditions(tour_plan_bytes: bytes) -> None:
+ with open_session(tour_plan_bytes) as session:
+ target = next(
+ a for a in session.project().anchor_index.values()
+ if a.scope == "body" and a.kind in ("p", "h", "li")
+ )
+ info = session.get_anchor_info(target.id)
+ assert info is not None
+ assert info.content_hash
+ assert info.visible_text is not None
+ assert session.get_version() == 0
+
+ guards = MutationPreconditions(
+ expected_version=0,
+ anchor_id=target.id,
+ expected_content_hash=info.content_hash,
+ expected_text=info.visible_text,
+ expected_text_range=TextRangePrecondition(0, 0, ""),
+ expected_kind=info.kind,
+ expected_scope=info.scope,
+ )
+ assert session.check_preconditions(guards).success
+ with session.preconditioned(guards):
+ assert session.replace_text(target.id, "cat cat").success
+ assert session.get_version() == 1
+
+ with session.preconditioned(MutationPreconditions(expected_version=0)):
+ stale = session.delete_block(target.id)
+ assert not stale.success
+ assert stale.error is not None
+ assert stale.error.code is EditErrorCode.PRECONDITION_FAILED
+ assert stale.error.precondition is not None
+ assert stale.error.precondition.condition == "document_version"
+ assert stale.error.precondition.expected == 0
+ assert stale.error.precondition.actual == 1
+ assert stale.error.precondition.current_version == 1
+ assert stale.error.precondition.current_target is not None
+ assert stale.error.precondition.current_target.visible_text == "cat cat"
+ assert session.get_version() == 1
+
+ count_failure = session.replace_text_range(
+ target.id,
+ "cat",
+ "dog",
+ ReplaceOptions(expected_match_count=1),
+ )
+ assert len(count_failure) == 1
+ assert not count_failure[0].success
+ assert count_failure[0].error is not None
+ assert count_failure[0].error.code is EditErrorCode.PRECONDITION_FAILED
+ assert count_failure[0].error.precondition is not None
+ assert count_failure[0].error.precondition.condition == "match_count"
+ assert session.get_version() == 1
+
+ replaced = session.replace_text_range(
+ target.id,
+ "cat",
+ "dog",
+ ReplaceOptions(
+ expected_match_count=2,
+ preconditions=MutationPreconditions(expected_version=1),
+ ),
+ )
+ assert len(replaced) == 2
+ assert all(r.success for r in replaced)
+ assert session.get_version() == 2
diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs
index 0b980199..69e03613 100644
--- a/tools/mcp-server/Dispatcher.cs
+++ b/tools/mcp-server/Dispatcher.cs
@@ -163,9 +163,16 @@ anchorId is null
break;
}
}
- return $"{{\"editSummary\":{editSummary},\"sectionInfo\":{sectionInfo}}}";
+ return $"{{\"version\":{DocxSessionOps.GetVersion(session.Handle)},\"editSummary\":{editSummary},\"sectionInfo\":{sectionInfo}}}";
}
+ case "version":
+ return DocxSessionOps.GetVersionJson(session.Handle);
+
+ case "check_preconditions":
+ return DocxSessionOps.CheckPreconditions(
+ session.Handle, ParsePreconditions(args, OptStr(args, "anchorId")));
+
default:
throw new McpToolException($"unknown format: {format}");
}
@@ -270,14 +277,25 @@ private static string Edit(SessionStore store, JsonElement args)
/// Shared by and (batched steps route
/// through the same per-tool functions so there's exactly one place each action's argument
/// parsing lives).
- private static string RunEditAction(DocSession session, string action, JsonElement args) => action switch
- {
+ private static string RunEditAction(DocSession session, string action, JsonElement args)
+ {
+ var preconditions = ParsePreconditions(args, MutationTarget(args));
+ if (action == "undo")
+ return preconditions is null
+ ? BoolResult(DocxSessionOps.Undo(session.Handle))
+ : DocxSessionOps.UndoChecked(session.Handle, preconditions);
+ if (action == "redo")
+ return preconditions is null
+ ? BoolResult(DocxSessionOps.Redo(session.Handle))
+ : DocxSessionOps.RedoChecked(session.Handle, preconditions);
+ return Guarded(session, preconditions, () => action switch
+ {
"insert_paragraph" => DocxSessionOps.InsertParagraph(
session.Handle, Str(args, "anchorId"), ParsePos(args), Str(args, "markdown")),
"replace_text" => DocxSessionOps.ReplaceText(session.Handle, Str(args, "anchorId"), Str(args, "markdown")),
"replace_text_range" => DocxSessionOps.ReplaceTextRange(
session.Handle, Str(args, "anchorId"), Str(args, "find"), Str(args, "replace"),
- new ReplaceOptions { IgnoreCase = !BoolOpt(args, "caseSensitive", false) }),
+ new ReplaceOptions { IgnoreCase = !BoolOpt(args, "caseSensitive", false) }, preconditions),
"delete_block" => DocxSessionOps.DeleteBlock(session.Handle, Str(args, "anchorId")),
"move_block" => DocxSessionOps.MoveBlock(
session.Handle, Str(args, "sourceAnchorId"), Str(args, "targetAnchorId"), ParsePos(args)),
@@ -288,10 +306,9 @@ private static string Edit(SessionStore store, JsonElement args)
session.Handle, Str(args, "anchorId"), Int(args, "characterOffset")),
"merge_paragraphs" => DocxSessionOps.MergeParagraphs(
session.Handle, Str(args, "anchorId"), Str(args, "secondAnchorId")),
- "undo" => BoolResult(DocxSessionOps.Undo(session.Handle)),
- "redo" => BoolResult(DocxSessionOps.Redo(session.Handle)),
_ => throw new McpToolException($"unknown docxodus_edit action: {action}"),
- };
+ });
+ }
private static bool IsMutatingEditAction(string action) => action is not ("undo" or "redo");
@@ -305,8 +322,9 @@ private static string Format(SessionStore store, JsonElement args)
return RunFormatAction(session, Str(args, "action"), args);
}
- private static string RunFormatAction(DocSession session, string action, JsonElement args) => action switch
- {
+ private static string RunFormatAction(DocSession session, string action, JsonElement args) =>
+ Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch
+ {
"apply_format" => DocxSessionOps.ApplyFormat(
session.Handle, Str(args, "anchorId"), ParseSpan(args, "span"), ParseFormatOp(args)),
"apply_format_by_substring" => DocxSessionOps.ApplyFormatBySubstring(
@@ -321,7 +339,7 @@ private static string Format(SessionStore store, JsonElement args)
"apply_list_format" => DocxSessionOps.ApplyListFormat(
session.Handle, Str(args, "anchorId"), DocxSessionJson.ParseListFormat(OptStr(args, "listFormat"))),
_ => throw new McpToolException($"unknown docxodus_format action: {action}"),
- };
+ });
private static FormatOp ParseFormatOp(JsonElement args) =>
args.ValueKind == JsonValueKind.Object && args.TryGetProperty("format", out var f) && f.ValueKind == JsonValueKind.Object
@@ -341,8 +359,9 @@ private static string Create(SessionStore store, JsonElement args)
return RunCreateAction(session, Str(args, "action"), args);
}
- private static string RunCreateAction(DocSession session, string action, JsonElement args) => action switch
- {
+ private static string RunCreateAction(DocSession session, string action, JsonElement args) =>
+ Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch
+ {
"insert_paragraph" => DocxSessionOps.InsertParagraph(
session.Handle, Str(args, "anchorId"), ParsePos(args), Str(args, "markdown")),
"insert_heading" => DocxSessionOps.InsertParagraph(
@@ -371,7 +390,7 @@ private static string Create(SessionStore store, JsonElement args)
session.Handle, Str(args, "bodyAnchorId"),
DocxSessionJson.ParseHeaderFooterKind(Str(args, "kind"))),
_ => throw new McpToolException($"unknown docxodus_create action: {action}"),
- };
+ });
private static string BuildTableInsertOptionsJson(JsonElement args)
{
@@ -401,8 +420,12 @@ private static string ListTool(SessionStore store, JsonElement args)
return RunListAction(session, Str(args, "action"), args);
}
- private static string RunListAction(DocSession session, string action, JsonElement args) => action switch
+ private static string RunListAction(DocSession session, string action, JsonElement args)
{
+ if (action == "get_membership")
+ return DocxSessionOps.GetListMembership(session.Handle, Str(args, "anchorId"));
+ return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch
+ {
"apply_format" => DocxSessionOps.ApplyListFormat(
session.Handle, Str(args, "anchorId"), DocxSessionJson.ParseListFormat(OptStr(args, "listFormat"))),
"apply_format_range" => DocxSessionOps.ApplyListFormatRange(
@@ -413,9 +436,9 @@ private static string ListTool(SessionStore store, JsonElement args)
session.Handle, Str(args, "anchorId"), Int(args, "startValue")),
"clear_start" => DocxSessionOps.ClearListStartOverride(session.Handle, Str(args, "anchorId")),
"remove" => DocxSessionOps.RemoveListMembership(session.Handle, Str(args, "anchorId")),
- "get_membership" => DocxSessionOps.GetListMembership(session.Handle, Str(args, "anchorId")),
_ => throw new McpToolException($"unknown docxodus_list action: {action}"),
- };
+ });
+ }
private static bool IsMutatingListAction(string action) => action != "get_membership";
@@ -427,8 +450,12 @@ private static string Comment(SessionStore store, JsonElement args)
return RunCommentAction(session, Str(args, "action"), args);
}
- private static string RunCommentAction(DocSession session, string action, JsonElement args) => action switch
+ private static string RunCommentAction(DocSession session, string action, JsonElement args)
{
+ if (action == "list")
+ return $"{{\"comments\":{DocxSessionOps.ListComments(session.Handle)}}}";
+ return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch
+ {
"add" => AddComment(session, args),
"reply" => DocxSessionOps.AddCommentReply(
session.Handle, Str(args, "commentAnchorId"), Str(args, "author"),
@@ -438,9 +465,9 @@ private static string Comment(SessionStore store, JsonElement args)
"resolve" => DocxSessionOps.SetCommentResolved(
session.Handle, Str(args, "commentAnchorId"), BoolOpt(args, "resolved", true)),
"remove" => DocxSessionOps.RemoveComment(session.Handle, Str(args, "commentAnchorId")),
- "list" => $"{{\"comments\":{DocxSessionOps.ListComments(session.Handle)}}}",
_ => throw new McpToolException($"unknown docxodus_comment action: {action}"),
- };
+ });
+ }
private static bool IsMutatingCommentAction(string action) => action != "list";
@@ -468,39 +495,36 @@ private static string Annotate(SessionStore store, JsonElement args)
{
var session = Session(store, args);
var action = Str(args, "action");
- switch (action)
+ if (action == "list")
+ return $"{{\"annotations\":{DocxSessionOps.ListAnnotations(session.Handle)}}}";
+ if (action == "find")
+ return $"{{\"anchors\":{DocxSessionOps.FindByAnnotation(session.Handle, Str(args, "query"))}}}";
+ return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch
{
- case "add":
- {
- var annotationJson = JsonSerializer.Serialize(new
- {
- id = OptStr(args, "annotationId") ?? "",
- labelId = OptStr(args, "labelId") ?? "",
- label = OptStr(args, "label") ?? "",
- color = OptStr(args, "color") ?? "#FFEB3B",
- author = OptStr(args, "author") ?? "",
- });
- return DocxSessionOps.AddAnnotation(
- session.Handle, Str(args, "anchorId"), ParseSpan(args, "span"), annotationJson);
- }
- case "update":
- {
- var updateEl = args.ValueKind == JsonValueKind.Object && args.TryGetProperty("update", out var u) && u.ValueKind == JsonValueKind.Object
- ? u.GetRawText() : "{}";
- return DocxSessionOps.UpdateAnnotation(session.Handle, Str(args, "annotationId"), updateEl);
- }
- case "remove":
- return DocxSessionOps.RemoveAnnotation(session.Handle, Str(args, "annotationId"));
- case "move":
- return DocxSessionOps.MoveAnnotation(
- session.Handle, Str(args, "annotationId"), Str(args, "newAnchorId"), ParseSpan(args, "newSpan"));
- case "list":
- return $"{{\"annotations\":{DocxSessionOps.ListAnnotations(session.Handle)}}}";
- case "find":
- return $"{{\"anchors\":{DocxSessionOps.FindByAnnotation(session.Handle, Str(args, "query"))}}}";
- default:
- throw new McpToolException($"unknown docxodus_annotate action: {action}");
- }
+ "add" => AddAnnotation(session, args),
+ "update" => DocxSessionOps.UpdateAnnotation(
+ session.Handle, Str(args, "annotationId"),
+ args.TryGetProperty("update", out var u) && u.ValueKind == JsonValueKind.Object
+ ? u.GetRawText() : "{}"),
+ "remove" => DocxSessionOps.RemoveAnnotation(session.Handle, Str(args, "annotationId")),
+ "move" => DocxSessionOps.MoveAnnotation(
+ session.Handle, Str(args, "annotationId"), Str(args, "newAnchorId"), ParseSpan(args, "newSpan")),
+ _ => throw new McpToolException($"unknown docxodus_annotate action: {action}"),
+ });
+ }
+
+ private static string AddAnnotation(DocSession session, JsonElement args)
+ {
+ var annotationJson = JsonSerializer.Serialize(new
+ {
+ id = OptStr(args, "annotationId") ?? "",
+ labelId = OptStr(args, "labelId") ?? "",
+ label = OptStr(args, "label") ?? "",
+ color = OptStr(args, "color") ?? "#FFEB3B",
+ author = OptStr(args, "author") ?? "",
+ });
+ return DocxSessionOps.AddAnnotation(
+ session.Handle, Str(args, "anchorId"), ParseSpan(args, "span"), annotationJson);
}
// ─── Track changes ──────────────────────────────────────────────────
@@ -521,23 +545,35 @@ private static string TrackChanges(SessionStore store, JsonElement args)
return FilterRevisions(revisionsJson, OptStr(args, "author"), OptStr(args, "changeType"));
}
case "accept":
- return DocxSessionOps.AcceptRevision(session.Handle, Str(args, "revisionId"));
+ return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () =>
+ DocxSessionOps.AcceptRevision(session.Handle, Str(args, "revisionId")));
case "reject":
- return DocxSessionOps.RejectRevision(session.Handle, Str(args, "revisionId"));
+ return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () =>
+ DocxSessionOps.RejectRevision(session.Handle, Str(args, "revisionId")));
case "accept_all":
{
+ var preconditions = ParsePreconditions(args, MutationTarget(args));
+ var check = Check(session, preconditions);
+ if (check is not null) return check;
+ var nextVersion = checked(DocxSessionOps.GetVersion(session.Handle) + 1);
// SaveWithAnchorIds (not Save) so the transformed bytes still carry the
// PtOpenXml:Unid attributes Rebind's reopen needs to keep anchor ids stable.
var bytes = DocxSessionOps.SaveWithAnchorIds(session.Handle);
var accepted = RevisionProcessor.AcceptRevisions(new WmlDocument("session.docx", bytes));
store.Rebind(session, accepted.DocumentByteArray);
+ DocxSessionOps.RestorePreviewVersion(session.Handle, nextVersion);
return "{\"success\":true}";
}
case "reject_all":
{
+ var preconditions = ParsePreconditions(args, MutationTarget(args));
+ var check = Check(session, preconditions);
+ if (check is not null) return check;
+ var nextVersion = checked(DocxSessionOps.GetVersion(session.Handle) + 1);
var bytes = DocxSessionOps.SaveWithAnchorIds(session.Handle);
var rejected = RevisionProcessor.RejectRevisions(new WmlDocument("session.docx", bytes));
store.Rebind(session, rejected.DocumentByteArray);
+ DocxSessionOps.RestorePreviewVersion(session.Handle, nextVersion);
return "{\"success\":true}";
}
case "set_mode":
@@ -599,10 +635,14 @@ private static string Mutations(SessionStore store, JsonElement args)
if (!args.TryGetProperty("steps", out var stepsEl) || stepsEl.ValueKind != JsonValueKind.Array)
throw new McpToolException("docxodus_mutations requires an array \"steps\"");
+ var batchCheck = Check(session, ParsePreconditions(args, MutationTarget(args)));
+ if (batchCheck is not null) return batchCheck;
+
var results = new List();
var errors = new List();
+ var startingVersion = DocxSessionOps.GetVersion(session.Handle);
int applied = 0;
- int mutatingSteps = 0;
+ int committed = 0;
foreach (var step in stepsEl.EnumerateArray())
{
@@ -626,6 +666,7 @@ private static string Mutations(SessionStore store, JsonElement args)
throw new McpToolException($"docxodus_mutations does not accept the read-only action \"{stepAction}\" on {stepTool}");
string resultJson;
+ var stepStartingVersion = DocxSessionOps.GetVersion(session.Handle);
try
{
resultJson = stepTool switch
@@ -657,8 +698,12 @@ private static string Mutations(SessionStore store, JsonElement args)
}
catch (JsonException) { /* non-EditResult shape (shouldn't happen for batchable tools); assume success */ }
- mutatingSteps++;
- if (succeeded) applied++;
+ if (succeeded)
+ {
+ applied++;
+ var versionDelta = DocxSessionOps.GetVersion(session.Handle) - stepStartingVersion;
+ committed = checked(committed + checked((int)versionDelta));
+ }
else
{
using var rdoc = JsonDocument.Parse(resultJson);
@@ -668,8 +713,9 @@ private static string Mutations(SessionStore store, JsonElement args)
if (mode == "preview")
{
- for (int i = 0; i < mutatingSteps; i++)
+ for (int i = 0; i < committed; i++)
DocxSessionOps.Undo(session.Handle);
+ DocxSessionOps.RestorePreviewVersion(session.Handle, startingVersion);
}
var status = errors.Count == 0 ? "ok" : applied == 0 ? "failed" : "partial";
@@ -694,6 +740,8 @@ private static string Table(SessionStore store, JsonElement args)
session.Handle, Str(args, "cellAnchorId")),
"resolve_cell_coordinate" => DocxSessionOps.ResolveTableCellCoordinate(
session.Handle, Str(args, "tableAnchorId"), Int(args, "rowIndex"), Int(args, "columnIndex")),
+ _ => Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => action switch
+ {
"insert" => DocxSessionOps.InsertTable(
session.Handle, Str(args, "anchorId"), ParsePos(args),
Int(args, "rows"), Int(args, "columns"), BuildTableInsertOptionsJson(args)),
@@ -721,6 +769,7 @@ private static string Table(SessionStore store, JsonElement args)
OptBool(args, "allowBreakAcrossPages"), OptInt(args, "heightTwips"),
OptStr(args, "heightRule")),
_ => throw new McpToolException($"unknown docxodus_table action: {action}"),
+ }),
};
/// The raw JSON text of a required array argument (passed through to the Ops-layer
@@ -747,6 +796,49 @@ private static string BuildTableBorderSpecJson(JsonElement args)
// ─── Arg helpers ────────────────────────────────────────────────────
+ private static MutationPreconditions? ParsePreconditions(JsonElement args, string? inferredAnchorId)
+ {
+ if (args.ValueKind != JsonValueKind.Object
+ || !args.TryGetProperty("preconditions", out var p)
+ || p.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ return null;
+ var parsed = DocxSessionJson.ParseMutationPreconditions(p);
+ return parsed is not null && parsed.AnchorId is null && inferredAnchorId is not null
+ ? parsed with { AnchorId = inferredAnchorId }
+ : parsed;
+ }
+
+ private static string? MutationTarget(JsonElement args)
+ {
+ foreach (var name in new[]
+ {
+ "anchorId", "cellAnchorId", "sourceAnchorId", "fromAnchorId", "firstAnchorId",
+ "headingAnchorId", "bodyAnchorId", "commentAnchorId", "newAnchorId",
+ })
+ {
+ if (args.ValueKind == JsonValueKind.Object
+ && args.TryGetProperty(name, out var target)
+ && target.ValueKind == JsonValueKind.String)
+ return target.GetString();
+ }
+ return null;
+ }
+
+ private static string? Check(DocSession session, MutationPreconditions? preconditions)
+ {
+ if (preconditions is null) return null;
+ var result = DocxSessionOps.CheckPreconditions(session.Handle, preconditions);
+ using var doc = JsonDocument.Parse(result);
+ return doc.RootElement.GetProperty("success").GetBoolean() ? null : result;
+ }
+
+ private static string Guarded(
+ DocSession session, MutationPreconditions? preconditions, Func mutation)
+ {
+ var failure = Check(session, preconditions);
+ return failure ?? mutation();
+ }
+
private static DocSession Session(SessionStore store, JsonElement args) => store.Get(Str(args, "sessionId"));
private static string Str(JsonElement args, string name)
diff --git a/tools/mcp-server/README.md b/tools/mcp-server/README.md
index ba716306..2925991b 100644
--- a/tools/mcp-server/README.md
+++ b/tools/mcp-server/README.md
@@ -112,7 +112,12 @@ them):
with `docxodus_list`'s `apply_format` action (which does write real `w:numPr`).
- **`docxodus_mutations`'s `preview` mode is apply-then-undo**, not a true no-op dry run;
it uses the session's bounded undo ring, so it composes with everything else but is not
- free of history-depth pressure.
+ free of history-depth pressure. Its caller-visible document version is restored after
+ rollback, so merely previewing does not stale a guarded edit plan.
+- **Optimistic guards are common to every mutation tool.** Pass `preconditions` with
+ `expectedVersion` and/or an anchor hash/exact text/range/kind/scope; replacement may
+ also require `expectedMatchCount`. `docxodus_get_content` formats `version` and
+ `check_preconditions` expose the read side. Batch-level and per-step guards use the same shape.
## License
diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs
index 6039f452..915c542c 100644
--- a/tools/mcp-server/ToolCatalog.cs
+++ b/tools/mcp-server/ToolCatalog.cs
@@ -64,8 +64,9 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
- "format": { "type": "string", "enum": ["markdown", "html", "text", "blocks", "info"], "description": "markdown/text: anchor-addressed markdown projection (text strips the markdown syntax). html: fully rendered HTML. blocks: structural metadata for every addressable block. info: section/page-setup facts plus a document edit summary." },
- "anchorId": { "type": "string", "description": "Optional. Scope markdown/html/text output to one block and its descendants instead of the whole document. Anchors in body, header (hdr*), footer (ftr*), note, and comment scopes are accepted." }
+ "format": { "type": "string", "enum": ["markdown", "html", "text", "blocks", "info", "version", "check_preconditions"], "description": "markdown/text: projection; html: rendered HTML; blocks: metadata; info: version plus page/edit facts; version: monotonic document version; check_preconditions: read-only guard evaluation." },
+ "anchorId": { "type": "string", "description": "Optional scope/target anchor." },
+ "preconditions": { "type": "object", "description": "check_preconditions: expectedVersion and/or anchorId plus expectedContentHash, expectedText/expectedTextRange, expectedKind, expectedScope, or expectedMatchCount." }
},
"required": ["sessionId", "format"]
}
@@ -109,6 +110,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic guards evaluated immediately before the mutation: expectedVersion, anchorId, expectedContentHash, expectedText/expectedTextRange, expectedKind, expectedScope, expectedMatchCount." },
"action": { "type": "string", "enum": ["insert_paragraph", "replace_text", "replace_text_range", "delete_block", "move_block", "delete_range", "delete_section", "split_paragraph", "merge_paragraphs", "undo", "redo"] },
"anchorId": { "type": "string", "description": "Target block. Required for every action except delete_range, delete_section, undo, redo." },
"position": { "type": "string", "enum": ["before", "after"], "description": "insert_paragraph/move_block only." },
@@ -135,6 +137,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." },
"action": { "type": "string", "enum": ["apply_format", "apply_format_by_substring", "set_paragraph_style", "set_paragraph_format", "set_list_level", "remove_list_membership", "apply_list_format"] },
"anchorId": { "type": "string" },
"span": { "type": "object", "properties": { "start": { "type": "integer" }, "length": { "type": "integer" } }, "description": "apply_format only. Omit to format the whole block." },
@@ -201,6 +204,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." },
"action": { "type": "string", "enum": ["insert_paragraph", "insert_heading", "insert_table", "insert_horizontal_rule", "insert_footnote", "insert_endnote", "insert_page_number_field", "set_header_text", "set_footer_text", "ensure_header_footer_visible"] },
"anchorId": { "type": "string", "description": "Reference block for insert_paragraph/insert_heading/insert_table/insert_horizontal_rule (paired with position), or the citing paragraph for insert_footnote/insert_endnote, or the target paragraph for insert_page_number_field." },
"bodyAnchorId": { "type": "string", "description": "set_header_text/set_footer_text/ensure_header_footer_visible: a body block identifying the section whose running story or visibility flags should change." },
@@ -230,6 +234,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." },
"action": { "type": "string", "enum": ["apply_format", "apply_format_range", "set_level", "set_start", "clear_start", "remove", "get_membership"] },
"anchorId": { "type": "string", "description": "Target paragraph for every action except apply_format_range. remove accepts paragraph, heading, or list-item anchors and overrides style-inherited numbering when necessary." },
"startValue": { "type": "integer", "description": "set_start: the number the item restarts at (>= 0), e.g. 5 to make this item render as '5.'" },
@@ -249,6 +254,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." },
"action": { "type": "string", "enum": ["add", "reply", "resolve", "update", "remove", "list"] },
"anchorId": { "type": "string", "description": "add: the body paragraph to comment on. Mutually exclusive with revisionId." },
"span": { "type": "object", "properties": { "start": { "type": "integer" }, "length": { "type": "integer" } }, "description": "add: character range within the paragraph. Omit to comment on the whole block." },
@@ -271,6 +277,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." },
"action": { "type": "string", "enum": ["add", "update", "remove", "move", "list", "find"] },
"anchorId": { "type": "string", "description": "add/move: block to attach the annotation to." },
"span": { "type": "object", "properties": { "start": { "type": "integer" }, "length": { "type": "integer" } }, "description": "add/move: character range within the block. Omit to annotate the whole block." },
@@ -302,6 +309,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic guards for accept/reject/accept_all/reject_all." },
"action": { "type": "string", "enum": ["list", "accept", "reject", "accept_all", "reject_all", "set_mode"], "description": "'list' reads revisions directly off the live markup — each entry carries a stable id, the markup's true author/date, its text, and the containing block's anchorId. 'accept'/'reject' resolve ONE revision by revisionId (undoable via docxodus_undo; other revisions keep their ids). 'accept_all'/'reject_all' resolve the whole document (not undoable — the session is rebound to the transformed bytes). 'set_mode' switches the session's own recording mode mid-workflow (issue #304)." },
"revisionId": { "type": "string", "description": "accept/reject: the id from 'list' (e.g. 'rev101'). Unknown or already-resolved ids fail with revision_not_found — re-list for the current set." },
"author": { "type": "string", "description": "list: only return revisions by this author." },
@@ -320,6 +328,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional batch-start guards. Each step args object may also carry its own preconditions." },
"mode": { "type": "string", "enum": ["apply", "preview"], "description": "preview applies every step, records the result, then undoes them all before returning — nothing is left changed." },
"steps": {
"type": "array",
@@ -344,6 +353,7 @@ internal static class ToolCatalog
"type": "object",
"properties": {
"sessionId": { "type": "string" },
+ "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." },
"action": { "type": "string", "enum": ["get_metadata", "resolve_cell_anchor", "resolve_cell_coordinate", "insert", "insert_row", "insert_column", "delete_row", "delete_column", "replace_cell_content", "merge_cells", "unmerge_cells", "set_column_widths", "set_borders", "set_shading", "set_repeat_header_row", "set_row_options"] },
"anchorId": { "type": "string", "description": "insert: reference block (paired with position)." },
"tableAnchorId": { "type": "string", "description": "get_metadata/resolve_cell_coordinate: the table's canonical tbl anchor." },
diff --git a/tools/python-host/Dispatcher.cs b/tools/python-host/Dispatcher.cs
index f3907d5c..2079e9a4 100644
--- a/tools/python-host/Dispatcher.cs
+++ b/tools/python-host/Dispatcher.cs
@@ -22,7 +22,25 @@ namespace Docxodus.PyHost;
///
internal static class Dispatcher
{
- public static string Dispatch(string op, JsonElement args) => op switch
+ public static string Dispatch(string op, JsonElement args)
+ {
+ var preconditions = ParsePreconditions(args);
+ if (preconditions is not null && IsMutation(op) && op != "replace_text_range")
+ {
+ if (op == "undo") return DocxSessionOps.UndoChecked(Handle(args), preconditions);
+ if (op == "redo") return DocxSessionOps.RedoChecked(Handle(args), preconditions);
+
+ // The stdio host dispatches one complete request at a time, so the check and
+ // mutation below cannot be interleaved by another protocol request.
+ var check = DocxSessionOps.CheckPreconditions(Handle(args), preconditions);
+ using var parsed = JsonDocument.Parse(check);
+ if (!parsed.RootElement.GetProperty("success").GetBoolean()) return check;
+ }
+
+ return DispatchCore(op, args);
+ }
+
+ private static string DispatchCore(string op, JsonElement args) => op switch
{
"ping" => Ping(),
"open_session" => OpenSession(args),
@@ -44,6 +62,8 @@ internal static class Dispatcher
"project_anchor" => DocxSessionOps.ProjectAnchor(
Handle(args), Str(args, "anchorId"),
(ProjectionDepth)IntOptional(args, "depth", 2)),
+ "get_version" => DocxSessionOps.GetVersionJson(Handle(args)),
+ "check_preconditions" => DocxSessionOps.CheckPreconditions(Handle(args), ParsePreconditions(args)),
"replace_text" => DocxSessionOps.ReplaceText(Handle(args), Str(args, "anchorId"), Str(args, "markdown")),
"delete_block" => DocxSessionOps.DeleteBlock(Handle(args), Str(args, "anchorId")),
@@ -559,16 +579,62 @@ private static string[] ParseAnchorIdArray(JsonElement args)
private static ReplaceOptions? ParseReplaceOptions(JsonElement args)
{
- if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("options", out var o) || o.ValueKind != JsonValueKind.Object)
- return null;
+ if (args.ValueKind != JsonValueKind.Object) return null;
+ var hasOptions = args.TryGetProperty("options", out var o) && o.ValueKind == JsonValueKind.Object;
+ var preconditions = ParsePreconditions(args);
+ if (preconditions is null && hasOptions
+ && o.TryGetProperty("preconditions", out var nestedPreconditions))
+ preconditions = DocxSessionJson.ParseMutationPreconditions(nestedPreconditions);
+ if (!hasOptions && preconditions is null) return null;
return new ReplaceOptions
{
- IgnoreCase = DocxSessionJson.TryGetBool(o, "ignoreCase", false),
- MaxReplacements = o.TryGetProperty("maxReplacements", out var mr) && mr.ValueKind == JsonValueKind.Number
+ IgnoreCase = hasOptions && DocxSessionJson.TryGetBool(o, "ignoreCase", false),
+ MaxReplacements = hasOptions && o.TryGetProperty("maxReplacements", out var mr) && mr.ValueKind == JsonValueKind.Number
? mr.GetInt32() : (int?)null,
+ ExpectedMatchCount = hasOptions && o.TryGetProperty("expectedMatchCount", out var emc) && emc.ValueKind == JsonValueKind.Number
+ ? emc.GetInt32() : (int?)null,
+ Preconditions = preconditions,
};
}
+ private static MutationPreconditions? ParsePreconditions(JsonElement args)
+ {
+ if (args.ValueKind != JsonValueKind.Object
+ || !args.TryGetProperty("preconditions", out var p)
+ || p.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ return null;
+ var parsed = DocxSessionJson.ParseMutationPreconditions(p);
+ if (parsed is null || parsed.AnchorId is not null) return parsed;
+ foreach (var targetName in new[]
+ {
+ "anchorId", "cellAnchorId", "sourceAnchorId", "fromAnchorId",
+ "firstAnchorId", "headingAnchorId", "parentAnchorId", "newAnchorId",
+ })
+ {
+ if (args.TryGetProperty(targetName, out var target) && target.ValueKind == JsonValueKind.String)
+ return parsed with { AnchorId = target.GetString() };
+ }
+ return parsed;
+ }
+
+ private static bool IsMutation(string op) => op is
+ "replace_text" or "delete_block" or "move_block" or "delete_range" or "delete_section"
+ or "replace_text_range" or "replace_text_at_span" or "replace_inner"
+ or "insert_paragraph" or "split_paragraph" or "merge_paragraphs"
+ or "set_header_text" or "set_footer_text" or "insert_page_number_field"
+ or "ensure_header_footer_visible" or "set_page_numbering" or "clear_page_numbering"
+ or "insert_footnote" or "insert_endnote"
+ or "add_comment" or "add_comment_reply" or "update_comment"
+ or "set_comment_resolved" or "remove_comment"
+ or "accept_revision" or "reject_revision"
+ or "apply_format" or "apply_format_by_substring" or "set_paragraph_style"
+ or "set_paragraph_format" or "set_list_level" or "remove_list_membership"
+ or "apply_list_format" or "apply_list_format_range" or "set_list_start_override"
+ or "clear_list_start_override" or "replace_cell_content"
+ or "raw_insert_xml" or "raw_replace_xml"
+ or "add_annotation" or "remove_annotation" or "update_annotation" or "move_annotation"
+ or "undo" or "redo";
+
private static string JsonString(string s) => DocxSessionJson.JsonString(s);
private static string JsonObject(JsonElement args, string name)
diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs
index 1ab06953..0cd37a14 100644
--- a/wasm/DocxodusWasm/DocxSessionBridge.cs
+++ b/wasm/DocxodusWasm/DocxSessionBridge.cs
@@ -37,6 +37,16 @@ public static int OpenSession(byte[] bytes, string settingsJson) =>
[JSExport]
public static string Project(int handle) => DocxSessionOps.Project(handle);
+ /// Current monotonic session document version as {"version":N}.
+ [JSExport]
+ public static string GetVersion(int handle) => DocxSessionOps.GetVersionJson(handle);
+
+ /// Read-only optimistic guard evaluation. A successful result applies no mutation.
+ [JSExport]
+ public static string CheckPreconditions(int handle, string preconditionsJson) =>
+ DocxSessionOps.CheckPreconditions(
+ handle, DocxSessionJson.ParseMutationPreconditions(preconditionsJson));
+
///
/// Ordered top-level render units per scope container (body / footnotes /
/// endnotes), as JSON: {"body":[{"id","kind"},…],"footnotes":[…],"endnotes":[…]}.
@@ -596,6 +606,9 @@ public static string ReplaceTextRange(int h, string anchor, string find, string
IgnoreCase = DocxSessionJson.TryGetBool(root, "ignoreCase", false),
MaxReplacements = root.TryGetProperty("maxReplacements", out var mr) && mr.ValueKind == JsonValueKind.Number
? mr.GetInt32() : (int?)null,
+ ExpectedMatchCount = DocxSessionJson.TryGetIntNullable(root, "expectedMatchCount"),
+ Preconditions = root.TryGetProperty("preconditions", out var p)
+ ? DocxSessionJson.ParseMutationPreconditions(p) : null,
};
}
return DocxSessionOps.ReplaceTextRange(h, anchor, find, replace, opts);