Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
## [Unreleased]

### Added
- **Idempotent mutation transaction identities for the MCP server** (issue #449).
An applying `docxodus_mutations` batch may carry a caller-chosen root
`transactionId` (non-blank, at most 256 Unicode scalar values). The first
terminal response — success, partial, structured failure, precondition failure,
or safely-caught exception — is retained for the lifetime of that open session,
and an identical retry returns it byte-for-byte without executing anything or
re-evaluating preconditions, so generated anchors, timestamps, versions and
`packageHash` all survive a lost response. Results gain a top-level
`transaction: { schemaVersion, transactionId, requestFingerprint }`; the
fingerprint is a SHA-256 over a canonical rendering that excludes only the root
`sessionId`/`transactionId`. Reusing an id for a different request returns
`transaction_conflict`. Preview/dry-run batches, nested step args, and the other
tools reject transaction ids rather than ignoring them. Retention is bounded per
session by both a count and a byte budget (128 responses, 32 MiB) followed by
1,024 response-less tombstones; there is no TTL and the number of open sessions
is not bounded, and once a tombstone expires a late retry applies again — both
documented as hazards in
[`docs/architecture/docx_agent_server.md`](docs/architecture/docx_agent_server.md).
Idempotency is MCP-only: `execute_batch` through WASM/npm and the stdio host has
no equivalent. Adds `EditErrorCode.InvalidTransaction`, `TransactionConflict`,
`TransactionResultEvicted` and `TransactionIncomplete`, rippled to npm
`EditErrorCode` and Python `EditErrorCode`. MCP session dispatch is now
serialized per session so a retry cannot race another action on the same
document. Coverage: `McpMutationTransactionTests` MCP449,
`python/tests/test_transaction_error_codes.py`, and
`npm/tests/transaction-error-codes.spec.ts`.
- **Structural tracked revisions and one live revision registry (#455).** *(Breaking — see
the Breaking changes section below.)* `DocxSession` now enumerates and resolves the
structural revision families it previously ignored —
Expand Down
1,104 changes: 1,104 additions & 0 deletions Docxodus.Tests/McpMutationTransactionTests.cs

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions Docxodus/DocxSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1702,6 +1702,18 @@
/// <summary>A mutation batch step names an unsupported operation or a read-only action.</summary>
InvalidBatchStep,

/// <summary>A transaction identity was supplied where it cannot safely identify an applying batch.</summary>
InvalidTransaction,

/// <summary>A transaction id was already reserved for a different canonical request.</summary>
TransactionConflict,

/// <summary>The exact response for a known transaction was evicted from bounded retention.</summary>
TransactionResultEvicted,

/// <summary>A known transaction never recorded a terminal response, so its outcome is unknown.</summary>
TransactionIncomplete,

/// <summary>The revision family is visible but has no safe selective resolver.</summary>
RevisionUnsupported,

Expand Down Expand Up @@ -7693,7 +7705,7 @@
// structured wrappers; validate the whole cell subtree rather than only direct paragraphs.
if (ValidateBookmarkRemoval(new[] { cell! }, cellAnchorId) is { } bookmarkError)
return bookmarkError;
var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, cell);

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (macos-latest)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.10)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (ubuntu-latest)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.13)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (windows-latest)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-npm

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 7708 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.
var oldHyperlinkIds = cell.Descendants(W.hyperlink)
.Select(h => (string?)h.Attribute(R.id)).Where(id => !string.IsNullOrEmpty(id)).Cast<string>().ToList();

Expand Down Expand Up @@ -7811,7 +7823,7 @@
{
// Inline code references a "Code" character style by id; ensure it actually
// exists so the run renders monospace instead of pointing at a phantom style.
if (op.Code is true) Internal.StyleFactory.EnsureCodeCharacterStyle(_doc);

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (macos-latest)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.10)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (ubuntu-latest)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.13)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (windows-latest)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-npm

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 7826 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

SplitRunsAtOffset(element, actualSpan.Start);
SplitRunsAtOffset(element, actualSpan.Start + actualSpan.Length);
Expand Down Expand Up @@ -7883,7 +7895,7 @@
if (target.Anchor.Kind is not ("p" or "h" or "li"))
return EditResult.Fail(EditErrorCode.AnchorWrongKind, "SetParagraphStyle requires a paragraph anchor", anchorId);

var element = target.Resolve(_doc);

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (macos-latest)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.10)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (ubuntu-latest)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.13)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (windows-latest)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-npm

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 7898 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.
if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", anchorId);
if (RefuseNestedTrackedParagraphPropertyChange(element, anchorId) is { } pending) return pending;

Expand Down
70 changes: 70 additions & 0 deletions docs/architecture/docx_agent_server.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,76 @@ modulo those generated ids/timestamps. Such receipts carry warnings; clients mus
id or `packageHash` equality unless the operation supplies stable ids/timestamps or is otherwise
known deterministic.

Applying batches may carry a caller-chosen root `transactionId` (a non-blank string up to 256
Unicode scalar values). Its first terminal success, partial result, structured failure,
precondition failure, or safely-caught exception is recorded for the lifetime of that open session.
An identical retry returns the original serialized `MutationBatchResult` byte-for-byte before
evaluating current preconditions or running a step; it therefore preserves generated anchors,
timestamps, versions, outcome, semantic deltas, and `packageHash` from the original call. The
result has one additional top-level identity — not a parallel receipt model:

```json
{
"transaction": {
"schemaVersion": 1,
"transactionId": "caller-operation-42",
"requestFingerprint": "sha256:..."
}
}
```

The request fingerprint excludes only root `sessionId` and `transactionId`; it sorts object keys,
normalizes JSON whitespace and equivalent string escapes, preserves array order, string spelling,
numeric token spelling, unknown properties, and every omitted/explicit distinction except the root
default `mode` (`mode` omitted is canonicalized as `"atomic"`). Deprecated `apply` remains distinct
from `best_effort`. Duplicate keys are rejected at any depth. Reusing an id for a different
fingerprint returns `transaction_conflict`. Transaction ids are for mutating batches only: direct
tools, step args, `mode: preview`, and `preview: true` reject them.
Replay after an ordinary undo or redo still returns the historical response: it never reapplies,
undoes, or redoes the mutation, changes the current document, or moves either history cursor. A
caller that wants an undone mutation present again must use ordinary `redo` while it remains
available. Saving preserves the in-session journal. Closing clears it, and reopening the document
starts a new transaction-identity namespace even when it opens the same saved file.

**Retention is bounded by count *and* bytes.** The per-session journal retains at most 128 full
responses *and* at most 32 MiB of retained response text (`DefaultFullRecordCapacity` /
`DefaultResponseByteBudget`), evicting oldest-first until both hold, then keeps 1,024 response-less
FIFO tombstones. A count alone is not a memory bound: a retained entry is the complete serialized
`MutationBatchResult`, which emits every step's `results` twice (`steps[].results` plus the
duplicate top-level `results`, `DocxSessionJson.cs`) alongside `patch.markdown` and the
revision/comment/annotation deltas. A one-step `insert_paragraph` batch on a blank document already
measures ~3.2 KB retained; scoped batches over real documents are far larger, so the worst case is
~32 MiB per open session. A single response exceeding the whole budget evicts itself rather than
raising the ceiling — the identity stays bound and answers `transaction_result_evicted`, which is a
safe answer, where an unbounded retained response would not be a bound at all. **Not bounded:**
the number of open sessions, and elapsed time — there is no TTL and no idle-session eviction, so
sessions that are never closed accumulate. A TTL / idle-session reaper is deliberately left as a
separate design question rather than smuggled in here.

An identical retry whose response has been evicted returns `transaction_result_evicted`, while its
tombstone still prevents conflicting reuse. An id whose reservation never recorded a terminal
response returns `transaction_incomplete`: the fingerprints match, so reporting a conflict would
state the opposite of the truth, and the caller's real situation is that the outcome is unknown.
The Dispatcher cannot strand a reservation — it completes or abandons every one — so this is
reachable through direct component use; an abandoned reservation becomes an outcome-unknown
tombstone, and uncompleted reservations are FIFO-bounded like completed ones.

Three lifecycle facts a client must design around:

- **A validation failure burns the id.** `mode: "sideways"` is a terminal structured response and is
cached as such, so correcting the typo and resending under the same id yields
`transaction_conflict`. Use a fresh id after any failure you intend to correct; reuse an id only
to resend a byte-identical request.
- **HAZARD — tombstone expiry lets a stale retry silently re-apply.** Once ~128 further
transactions plus 1,024 further tombstones have passed, the identity is genuinely forgotten and a
late retry executes as a *fresh mutation* (`MCP449_DispatcherSerializesEvictedResultAndConflictAndReusesOnlyAfterTombstone`
pins exactly this: version 2 → 4). Idempotency here is a bounded-window guarantee, not a
permanent one.
- **The guarantee is MCP-only.** `execute_batch` via WASM/npm and via the stdio host /
`docx-scalpel` carries no transaction identity and no replay; a retry on those transports
re-applies. This asymmetry is within issue #449's scope, but callers must not generalize the
guarantee across transports.

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
Expand Down
4 changes: 4 additions & 0 deletions npm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,10 @@ export type EditErrorCode =
| "revision_not_found"
| "precondition_failed"
| "invalid_batch_step"
| "invalid_transaction"
| "transaction_conflict"
| "transaction_result_evicted"
| "transaction_incomplete"
| "hyperlink_not_found"
| "bookmark_not_found"
| "duplicate_bookmark_name"
Expand Down
27 changes: 27 additions & 0 deletions npm/tests/transaction-error-codes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { EditErrorCode } from '../src/types.js';

// The MCP server produces these codes; the browser package has no transaction surface of its
// own (idempotent retries are MCP-only). It does type every EditError it decodes, so the union
// has to name them. Declaring the list as EditErrorCode[] makes `npm run typecheck` fail if a
// member is dropped or misspelled, and the source assertion below catches it at runtime too.
const TRANSACTION_ERROR_CODES: readonly EditErrorCode[] = [
'invalid_transaction',
'transaction_conflict',
'transaction_result_evicted',
'transaction_incomplete',
];

const typesSource = readFileSync(
fileURLToPath(new URL('../src/types.ts', import.meta.url)),
'utf8',
);

test('EditErrorCode names every MCP mutation-transaction wire string', () => {
for (const code of TRANSACTION_ERROR_CODES) {
expect(typesSource).toContain(`| "${code}"`);
}
expect(new Set(TRANSACTION_ERROR_CODES).size).toBe(TRANSACTION_ERROR_CODES.length);
});
4 changes: 4 additions & 0 deletions python/src/docx_scalpel/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ class EditErrorCode(str, Enum):
EMPTY_COMMENT_SPAN = "empty_comment_span"
REVISION_NOT_FOUND = "revision_not_found"
INVALID_BATCH_STEP = "invalid_batch_step"
INVALID_TRANSACTION = "invalid_transaction"
TRANSACTION_CONFLICT = "transaction_conflict"
TRANSACTION_RESULT_EVICTED = "transaction_result_evicted"
TRANSACTION_INCOMPLETE = "transaction_incomplete"
HYPERLINK_NOT_FOUND = "hyperlink_not_found"
BOOKMARK_NOT_FOUND = "bookmark_not_found"
DUPLICATE_BOOKMARK_NAME = "duplicate_bookmark_name"
Expand Down
40 changes: 40 additions & 0 deletions python/tests/test_transaction_error_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Wire-string parity for the MCP mutation-transaction ``EditErrorCode`` members.

These codes are produced by ``tools/mcp-server`` and generated from the C# enum by
``EnumToSnake``. ``docx-scalpel`` itself has no transaction surface (idempotent retries
are MCP-only), but it decodes every ``EditError`` on the wire, so a client that talks to
the MCP server through another path must still be able to name them.
"""

from __future__ import annotations

from pathlib import Path

from docx_scalpel.enums import EditErrorCode

TRANSACTION_CODES = {
"invalid_transaction": "INVALID_TRANSACTION",
"transaction_conflict": "TRANSACTION_CONFLICT",
"transaction_result_evicted": "TRANSACTION_RESULT_EVICTED",
"transaction_incomplete": "TRANSACTION_INCOMPLETE",
}


def test_transaction_error_codes_round_trip_from_their_wire_strings() -> None:
for wire, member_name in TRANSACTION_CODES.items():
member = getattr(EditErrorCode, member_name)
assert member.value == wire
# ``_missing_`` degrades an unknown code to INTERNAL_ERROR, so a member that was
# never added would decode silently. Decoding the wire string and demanding the
# exact member is what actually catches an absent or drifted code.
assert EditErrorCode(wire) is member


def test_transaction_error_codes_are_declared_in_this_checkout() -> None:
# Guards against an unrelated installed copy of docx_scalpel satisfying the import
# above: the source of record for this repository must declare them too.
source = (
Path(__file__).resolve().parents[1] / "src" / "docx_scalpel" / "enums.py"
).read_text(encoding="utf-8")
for wire, member_name in TRANSACTION_CODES.items():
assert f'{member_name} = "{wire}"' in source
Loading
Loading