@ansonlai/docx-redline-js 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/TESTING.md CHANGED
@@ -29,6 +29,7 @@ fixtures rarely contain.
29
29
  | Target hot-path parity | `node tests/performance_phase3_target_hot_paths_tests.mjs` | Cached and uncached resolution metadata parity, duplicate safety, snapshots, session invalidation, and pointer-based revision-ID seeding | Machine-independent latency thresholds |
30
30
  | List and text-walker parity | `node tests/performance_phase4_list_and_text_parity_tests.mjs` | One shared marker vocabulary and canonical/specialized visible-text agreement across structural characters and revision views | That all specialized mappings are interchangeable |
31
31
  | Route compatibility | `node tests/performance_phase5_route_consolidation_tests.mjs` | Direct/legacy list accepted-rejected parity, numbering artifacts, route selection, capability records, and stable public exports | Permission to migrate the retained multi-paragraph compatibility route |
32
+ | Cross-author revision slicing | `node tests/cross_author_slicing_fixtures_tests.mjs`, `node tests/cross_author_carrier_splitting_tests.mjs`, `node tests/cross_author_slicing_synthetic_tests.mjs`, `node tests/cross_author_slicing_real_tests.mjs`, `node tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs`, `node tests/cross_author_slicing_insertion_stress_tests.mjs` | Word-native `<w:ins>` splitting, `<w:del>` nesting, multi-author stacking, straddle boundaries, synthetic matrix (SYN-01..12d), strict package differential replay (PKG-01..06), exact hyperlink/NBSP round trips, and 76 insertion stress scenarios | Non-Word consumers beyond OpenXML XSD validation |
32
33
 
33
34
  The package-facade regression opens a real ZIP buffer, adds a comment beside an
34
35
  existing high ID, validates OPC wiring, and checks an unrelated binary part is
@@ -78,6 +79,23 @@ not correctness gates. Per-operation DOM savepoints are retained because
78
79
  redline accuracy, no-op isolation, and rollback fidelity take precedence over
79
80
  the aspirational speed target.
80
81
 
82
+ ## Cross-author revision slicing test suite
83
+
84
+ The cross-author revision slicing subsystem introduces six complementary test lanes:
85
+
86
+ 1. **Native Word Desktop Fixtures (`tests/cross_author_slicing_fixtures_tests.mjs`)**:
87
+ Verifies 36 reference `.docx` and `.xml` files generated via Microsoft Word Desktop 365 COM automation across 6 golden scenarios (`insert-interior`, `delete-interior`, `delete-boundary-start`, `delete-boundary-end`, `delete-straddle-baseline-insertion`, and `multi-author-stacked`) in pending, accepted, and rejected states. Proves Word-native OOXML patterns (carrier `<w:ins>` splitting for insertions, direct `<w:del>` nesting inside `<w:ins>` for deletions) and lifecycle parity under `acceptTrackedChangesInOoxml` and `rejectTrackedChangesInOoxml`.
88
+ 2. **Carrier Splitting Unit Suite (`tests/cross_author_carrier_splitting_tests.mjs`)**:
89
+ Tests `splitTrackChangeCarrier` across interior and boundary cuts, multi-run formatting preservation, stable metadata cloning, independent revision ID allocation via `RevisionIdAllocator`, and immutable input handling.
90
+ 3. **Synthetic Boundary Matrix (`tests/cross_author_slicing_synthetic_tests.mjs`)**:
91
+ Executes the full SYN-01 through SYN-12d test matrix covering pure interior inserts, pure interior deletes, boundary start/end cuts, full-content annihilation, baseline/insertion straddles, multi-carrier straddles, formatting preservation, paired replacements, 3-author stacked revisions, and selective author accept/reject lifecycle oracles.
92
+ 4. **Checked-In Word Package Differential Suite (`tests/cross_author_slicing_real_tests.mjs`)**:
93
+ Reconstructs the pre-mutation document packages, applies `slice-cross-author` via the strict `openDocx` package facade, enforces atomic package validation, and compares engine `AcceptAll` and `RejectAll` output against Word Desktop-generated golden packages (PKG-01 through PKG-06).
94
+ 5. **Hyperlink and Exact-Text Regression (`tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs`)**:
95
+ Reproduces the repeated-phrase, hyperlink-boundary, and NBSP-to-space failure through both the low-level engine and document operation runner. It requires exact accepted-view equality, preserves both hyperlink relationship containers, and proves that an unreconstructable slicing mutation returns `PATCH_ROUNDTRIP_MISMATCH` with the original OOXML unchanged.
96
+ 6. **Insertion Stress Matrix (`tests/cross_author_slicing_insertion_stress_tests.mjs`)**:
97
+ Runs 76 deterministic scenarios across carrier start/end/interior positions, repeated phrases, multi-run and formatted carriers, spaces/tabs/NBSP, XML-sensitive characters, emoji/combining Unicode, hyperlink interiors and boundaries, bookmarks, comments, nested deletions, adjacent authors, mixed same/foreign-author carriers, multiple structural containers, consecutive reviewer rounds, and the atomic document runner. Every supported case requires exact current-view, Accept-All, Reject-Current, schema, metadata, and non-empty-wrapper invariants; unsupported nested structures must fail without throwing or returning changed output.
98
+
81
99
  ## Coverage matrix and test selection
82
100
 
83
101
  Run the deterministic task-by-structure report before choosing the next Word
@@ -439,6 +439,357 @@ type ExistingRevisionsPolicy =
439
439
  - The repository does not contain the private `agreement.docx` referenced by REAL-01/REAL-02 or the exact `c5bb43ede5...` corpus package referenced by REAL-03. Those named cases remain external acceptance scenarios rather than silently skipped automated tests.
440
440
  - REAL-04/REAL-05 require Microsoft Word Desktop COM and visual review. The checked-in fixtures were produced by Word COM, while the normal automated suite deliberately remains deterministic and non-interactive.
441
441
 
442
+ ### Bug Follow-Up: Hyperlink Boundary Round-Trip Mismatch [FIXED 2026-09-08]
443
+ * **Report Reproduced**: A slicing edit with repeated text, hyperlink runs, and NBSP-to-space substitutions could return `status: "ok"` even though its accepted-view text differed from `modified`.
444
+ * **Root Cause**: `applySurgicalMode` discarded whitespace-only insertion diff segments by checking `textWithoutNewlines.trim().length`. The corresponding NBSP deletion still committed, changing `located at\u00a0example.com` to `located atexample.com`. Structural replacement paths could also proceed after `PAIRING_SKIPPED_STRUCTURAL_BOUNDARY` without a final exact-text oracle.
445
+ * **Fix**:
446
+ - Whitespace-only insertions are now applied rather than silently skipped.
447
+ - Every `slice-cross-author` surgical result reconstructs canonical accepted-view text and compares it exactly with the requested clean modified text.
448
+ - A mismatch returns `PATCH_ROUNDTRIP_MISMATCH`, `hasChanges: false`, diagnostic excerpts and offset, and the exact original OOXML. The document runner/facade therefore treats the operation as unapplied and preserves transactional rollback.
449
+ * **Files Touched**:
450
+ - `engine/surgical-mode.js`
451
+ - `engine/oxml-engine.js`
452
+ - `tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs` (NEW)
453
+ - `CHANGELOG.md`
454
+ - `README.md`
455
+ - `AGENTS.md`
456
+ - `docs/TESTING.md`
457
+ - `docs/plans/2026-09-08-cross-author-revision-slicing.md`
458
+ * **Functions Touched / Created**:
459
+ - `applySurgicalMode` (MODIFIED): retains whitespace-only insertions and enforces the slicing accepted-view postcondition.
460
+ - `firstMismatchOffset` (NEW): locates the first exact-text divergence.
461
+ - `excerptAt` (NEW): provides bounded expected/actual diagnostics without returning entire contract paragraphs.
462
+ - `applyRedlineToOxml` surgical result handling (MODIFIED): restores the exact input OOXML on `PATCH_ROUNDTRIP_MISMATCH`.
463
+ - Test helpers `run`, `insertion`, `hyperlink`, and `acceptedParagraphText` (NEW).
464
+ * **Regression Coverage**:
465
+ - Low-level reproduction with two hyperlink relationship containers, repeated `Widget Policy`, and three NBSP-to-space edits.
466
+ - Full `applyOperationsToDocumentXml` atomic runner reproduction matching the CLI execution path.
467
+ - Exact Accept-All equality with the submitted modified string.
468
+ - Explicit fail-closed test proving mismatch status, error code, mismatch offset, and byte-exact original OOXML rollback.
469
+ * **Verification**:
470
+ - `node tests/cross_author_slicing_replacement_anchor_tests.mjs` — PASS, 12 scenarios.
471
+ - `node tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs` — PASS.
472
+ - `npm test` — PASS, 93 test files passed and 0 failed.
473
+ - `npm run lint` — PASS.
474
+ - `npm run check:types` — PASS; all 123 runtime exports have declarations.
475
+ - `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
476
+
477
+ ### Insertion Stress Follow-Up [COMPLETED 2026-09-08]
478
+ * **Motivation**: Real usage reported failures across a wider variety of insertions after the first hyperlink/NBSP bug. A generated matrix was added to exercise location, payload, structure, lifecycle, and repeated-review dimensions rather than relying on a few fixed examples.
479
+ * **Defects Exposed and Fixed**:
480
+ 1. Leading/trailing spaces, tabs, and NBSP-only additions were classified as no-ops because slicing inherited trim-based text-change detection. `applyRedlineToOxml` now uses exact comparison for `slice-cross-author`.
481
+ 2. Word-token semantic diff cleanup could relocate a pure insertion between repeated phrases, especially inside a hyperlink. `computeInsertionOnlyDiffs` now selects a character-local, no-deletion diff whenever the original is an exact subsequence of the modified text; replacements retain the established word diff and exact round-trip guard.
482
+ 3. In a paragraph containing both current-author and foreign insertion carriers, inserting into the current-author carrier produced illegal nested `w:ins`. `processInsert` now adds a normal run to that existing carrier while continuing to split foreign carriers into siblings.
483
+ * **Coverage Added**:
484
+ - 76 deterministic scenarios spanning carrier start/end/interior positions; single-, multi-, and formatted runs; repeated tokens; double spaces, tabs, NBSP, XML-sensitive characters, emoji, ZWJ emoji, combining characters, citations, and punctuation.
485
+ - Hyperlink interiors and both hyperlink boundaries; bookmarks; comment anchors; nested prior deletions; adjacent foreign authors; mixed current/foreign authors; three-container edits; and consecutive second-/third-reviewer rounds.
486
+ - Exact accepted-view, Accept-All, Reject-Current, validation, unique metadata, hyperlink preservation, and zero-empty-insertion assertions.
487
+ - Twenty scenarios also execute through `applyOperationsToDocumentXml` with atomic and strict-target settings, matching the CLI runner path.
488
+ - Nested hyperlink/field structures are required either to produce exact valid output or fail closed without throwing.
489
+ * **Files Touched**:
490
+ - `pipeline/diff-engine.js`
491
+ - `engine/oxml-engine.js`
492
+ - `engine/surgical-mode.js`
493
+ - `engine/surgical-diff-application.js`
494
+ - `tests/cross_author_slicing_insertion_stress_tests.mjs` (NEW)
495
+ - `CHANGELOG.md`
496
+ - `README.md`
497
+ - `docs/TESTING.md`
498
+ - `docs/plans/2026-09-08-cross-author-revision-slicing.md`
499
+ * **Functions Touched / Created**:
500
+ - `computeInsertionOnlyDiffs` (NEW): detects insertion-only transforms and returns a character-local diff only when it contains no deletion.
501
+ - `applyRedlineToOxml` (MODIFIED): uses exact slicing change detection, including boundary whitespace.
502
+ - `applySurgicalMode` (MODIFIED): selects insertion-only versus word diff without changing replacement semantics.
503
+ - `processInsert` (MODIFIED): inserts directly into an existing same-author carrier in mixed-author paragraphs.
504
+ - `isSameAuthorInsertion` (NEW): namespace-safe author comparison for carrier coalescing.
505
+ - Stress helpers `escapeXml`, `run`, `insertion`, `paragraph`, `parsed`, `acceptedText`, `authorOf`, `assertValid`, and `assertInsertionRoundTrip` (NEW).
506
+ * **Verification**:
507
+ - `node tests/cross_author_slicing_insertion_stress_tests.mjs` — PASS, 76 scenarios.
508
+ - `npm test` — PASS, 95 test files passed and 0 failed.
509
+ - `npm run lint` — PASS.
510
+ - `npm run check:types` — PASS; all 123 runtime exports have declarations.
511
+ - `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
512
+
513
+ ### Hyperlink-Adjacent Replacement Follow-Up [COMPLETED 2026-09-08]
514
+ * **Bug Report**: Replacing the space immediately after a Privacy Policy hyperlink with a comma and execution-date qualifier failed with `PATCH_ROUNDTRIP_MISMATCH`. The generated intermediate OOXML moved the qualifier and URL relative to the following definition text.
515
+ * **Root Cause**: `processDelete` split and removed the run containing the replaced boundary space, but `processInsert` subsequently resolved the paired insertion through the pre-mutation span index. That span still referenced the detached source run, so insertion placement fell back to the wrong paragraph location.
516
+ * **Fix**: `processDelete` now records a stable parent/reference-node anchor at a non-carrier deletion boundary. `processInsert` consumes that anchor for the immediately paired insertion when no explicit insertion affinity was requested. Foreign `w:ins` carriers continue to use their existing carrier-splitting anchor and explicit affinity remains authoritative.
517
+ * **Additional Defect Found by the Matrix**: Two pure insertions in the same source run could detach the shared pre-mutation span after the first insertion and relocate the second insertion to the paragraph end. Multi-insertion-only slicing now applies insertions from right to left and rebuilds the live span index between mutations.
518
+ * **Files Touched**:
519
+ - `engine/surgical-diff-application.js`
520
+ - `engine/surgical-mode.js`
521
+ - `tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs`
522
+ - `tests/cross_author_slicing_replacement_anchor_tests.mjs` (NEW)
523
+ - `CHANGELOG.md`
524
+ - `docs/plans/2026-09-08-cross-author-revision-slicing.md`
525
+ * **Functions Touched**:
526
+ - `processDelete` (MODIFIED): records the live DOM insertion boundary while splitting a deleted run.
527
+ - `processInsert` (MODIFIED): consumes the stable replacement anchor before consulting stale pre-mutation spans.
528
+ - `applySurgicalMode` (MODIFIED): uses live right-to-left application for multiple insertion-only diffs.
529
+ - `collectInsertionOperations` (NEW): records original/new offsets for insertion-only mutations.
530
+ - Hyperlink round-trip test helpers and assertions (MODIFIED): cover low-level apply, Accept All, hyperlink relationship preservation, and the atomic strict-target document runner.
531
+ - Replacement-anchor matrix helpers `escapeXml`, `run`, `hyperlink`, `insertion`, `paragraph`, `parse`, `acceptedText`, and `hyperlinkIds` (NEW).
532
+ * **Additional Future-Regression Coverage**:
533
+ - 12 deterministic replacements at run starts, interiors, and ends; before and after hyperlinks; across an entire spacer run; beside bold/underlined runs, bookmarks, and comment markers; across multiple replacements; and beside/inside a foreign insertion carrier.
534
+ - Every case asserts exact current view, Accept All, Reject Reviewer, structural validation, unique revision IDs, and hyperlink relationship preservation.
535
+ - Four representative hyperlink and multi-replacement cases also execute through the atomic strict-target document runner used by the CLI.
536
+ * **Verification**:
537
+ - `node tests/cross_author_slicing_replacement_anchor_tests.mjs` — PASS, 12 scenarios.
538
+ - `node tests/cross_author_slicing_hyperlink_roundtrip_tests.mjs` — PASS.
539
+ - `node tests/cross_author_slicing_insertion_stress_tests.mjs` — PASS, 76 scenarios.
540
+ - `node tests/insertion_affinity_tests.mjs` — PASS.
541
+ - `npm test` — PASS, 95 test files passed and 0 failed.
542
+ - `npm run lint` — PASS.
543
+ - `npm run check:types` — PASS; all 123 runtime exports have declarations.
544
+ - `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
545
+
546
+ ### WP08a — Fail-Closed Gate for Foreign Paragraph-Mark Deletions [COMPLETED 2026-09-08]
547
+
548
+ WP08a is separable from, and a prerequisite of, WP08b. It ships on its own as a patch release: it adds no new capability, only refuses an operation that currently returns `status: 'ok'` while producing a lifecycle-unsafe document. Every prior follow-up in this document shipped the fail-closed guard before the feature; WP08 follows the same order.
549
+
550
+ #### Scope
551
+
552
+ 1. At the mutation gate, refuse any operation that would add visible runs or `w:ins` content to a paragraph in the **resurrection state** defined in WP08b's trigger taxonomy (foreign paragraph-mark deletion + every pre-existing content node already deleted + new non-empty insertion). Return `FOREIGN_PARAGRAPH_MARK_DELETION` with the owning author, and the original document unchanged under atomic mode.
553
+ 2. Add the same predicate to `core/redline-validation.js` as a **warning**, not an error. Validation runs over documents this engine did not author; see the Validation Predicate section below for why the broader rule is unsafe.
554
+ 3. Regression fixture reproducing the reported restoration shape (foreign `w:pPr/w:rPr/w:del` plus an attempted same-paragraph `w:ins`), asserting the refusal, the error code, and byte-exact rollback.
555
+ 4. Lifecycle assertions in the fixture proving *why* the shape is refused: Accept All loses Reviewer B's text, and Reject Reviewer A yields duplicate visible text.
556
+
557
+ Package validation alone is not a sufficient oracle for this case — the unsafe shape is structurally valid. Accept/Reject lifecycle checks are mandatory.
558
+
559
+ #### Implementation Record
560
+
561
+ * **Result**: Added a shared, narrowly scoped resurrection-state predicate. Both the low-level paragraph engine and the document mutation runner now refuse a non-empty edit when a different author owns the paragraph-mark deletion and every pre-existing content child is deleted. The refusal returns `FOREIGN_PARAGRAPH_MARK_DELETION`, includes `ownerAuthor`, and leaves the original input unchanged. Same-author deleted paragraphs, foreign deleted marks with surviving content, and content-only deletions without a paragraph-mark deletion remain on their existing paths.
562
+ * **Validation**: `validateRedlineOoxml` uses the same structural model to emit a warning for already-authored unsafe shapes. The warning does not make otherwise valid OOXML invalid.
563
+ * **Files Touched**:
564
+ - `core/paragraph-revision-safety.js` (NEW)
565
+ - `core/redline-validation.js`
566
+ - `engine/oxml-engine.js`
567
+ - `services/document-operation-mutations.js`
568
+ - `index.d.ts`
569
+ - `tests/foreign_paragraph_mark_deletion_gate_tests.mjs` (NEW)
570
+ - `CHANGELOG.md`
571
+ - `docs/plans/2026-09-08-cross-author-revision-slicing.md`
572
+ * **Functions and Types Touched**:
573
+ - `inspectForeignDeletedParagraphTarget` (NEW): identifies the pre-mutation WP08 resurrection state and excludes same-author ownership.
574
+ - `findForeignDeletedParagraphResurrections` (NEW): identifies already-authored unsafe foreign-insertion shapes for warning-only validation.
575
+ - `paragraphMarkDeletion`, `hasVisibleInsertionContent`, `isAnchorOnlyRun`, and DOM/name/author helpers (NEW): implement the shared structural inspection without mutating the source DOM, while excluding non-visible comment/bookmark anchors from the content-state decision.
576
+ - `applyRedlineToOxml` (MODIFIED): fails closed before existing-revision normalization or diff application for paragraph-level calls.
577
+ - `applyToParagraphByExactText` (MODIFIED): fails closed immediately after strict target resolution and before preprocessing/mutation for document-runner calls.
578
+ - `validateRedlineOoxml` (MODIFIED): reports `FOREIGN_PARAGRAPH_MARK_DELETION` as a warning for structurally valid but lifecycle-unsafe authored output.
579
+ - `RedlineError` (MODIFIED): documents the new error code and optional `ownerAuthor` metadata.
580
+ - WP08a fixture helpers and assertions (NEW): cover the low-level tracked and direct-edit paths, strict atomic runner rollback, validator severity, Accept/Reject lifecycle evidence, and every non-triggering taxonomy row.
581
+ * **Verification**:
582
+ - `node tests/foreign_paragraph_mark_deletion_gate_tests.mjs` — PASS.
583
+ - Focused validation, revision-policy, replacement-anchor, and paragraph-boundary suites — PASS.
584
+ - `npm test` — PASS, 96 test files passed and 0 failed.
585
+ - `npm run lint` — PASS.
586
+ - `npm run check:types` — PASS; all 123 runtime exports have declarations.
587
+ - `npm run build` — PASS.
588
+ - `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
589
+
590
+ ---
591
+
592
+ ### WP08b — Paragraph-Level Cross-Author Slicing for Deleted Paragraph Restoration [COMPLETED 2026-09-08]
593
+
594
+ #### Motivation
595
+
596
+ Restoring text from another reviewer's pending whole-paragraph deletion is the paragraph-level counterpart of run-level cross-author slicing. The accepted/current view of such a paragraph is empty, while its text exists only in the rejected view. Writing replacement text into that same paragraph can look correct before revisions are resolved, but it is lifecycle-unsafe because the foreign paragraph-mark deletion still owns the paragraph.
597
+
598
+ The observed unsafe shape is conceptually:
599
+
600
+ ```xml
601
+ <w:p>
602
+ <w:pPr><w:rPr><w:del w:author="Reviewer A"/></w:rPr></w:pPr>
603
+ <w:del w:author="Reviewer A">...</w:del>
604
+ <w:ins w:author="Reviewer B">restored text</w:ins>
605
+ </w:p>
606
+ ```
607
+
608
+ This passes structural package validation and looks correct in the current view, but Accept All removes the entire paragraph because Reviewer A's paragraph-mark deletion remains active. Rejecting Reviewer A can also expose both the original deleted text and Reviewer B's inserted copy.
609
+
610
+ Nesting Reviewer B's `<w:ins>` inside Reviewer A's `<w:del>` is not a solution: `w:del/w:ins` nesting is invalid for this use, and accepting the outer deletion would remove the nested text.
611
+
612
+ #### Required Paragraph-Level Slicing Model
613
+
614
+ Preserve the foreign deleted paragraph and materialize the restoring reviewer's counterproposal as a new adjacent tracked paragraph:
615
+
616
+ ```text
617
+ [restored/adjusted paragraph inserted by Reviewer B]
618
+ [paragraph deleted by Reviewer A]
619
+ ```
620
+
621
+ #### Paragraph-Mark Semantics (Normative)
622
+
623
+ This is the part the run-level slicing model has no analogue for, and it governs every lifecycle row below.
624
+
625
+ **Accepting a paragraph-mark deletion does not remove the paragraph — it merges the paragraph into the next paragraph.** `mergeParagraphIntoNextAndRemove` in `services/revision-comment-management.js` moves the deleted paragraph's surviving children into the following `w:p` and removes the emptied paragraph; the **following** paragraph's `pPr` is the one that survives. Any design statement phrased as "Reviewer A's paragraph is removed" is imprecise and must be read as "merged forward".
626
+
627
+ Three consequences are binding on the implementation:
628
+
629
+ 1. **Sibling order is a design decision, not cosmetic.** Placing Reviewer B's paragraph *after* Reviewer A's makes B the merge target when Reviewer A is accepted: A's surviving children land inside B. This is harmless only while A's content is 100% deleted, and stops being harmless the moment A retains content (a partially resolved deletion, or a third author's `w:ins` still pending inside A). Placing B *before* A leaves A's merge target exactly as it was before the restoration existed, so accepting A behaves identically with or without B.
630
+ * **Decision: place Reviewer B's paragraph immediately BEFORE Reviewer A's**, for merge-target neutrality. The current view is unaffected (A is invisible), and All-Markup view order is a rendering preference, not a correctness property. Fixtures must assert the merge target explicitly rather than inferring it from the resulting text.
631
+ 2. **Reviewer B's paragraph MUST carry its own inserted paragraph mark** (`w:pPr/w:rPr/w:ins` attributed to Reviewer B, with an allocator-issued ID). Adding a paragraph adds a paragraph mark. Without it, Reject Reviewer B removes B's content but leaves an empty stub paragraph permanently, silently violating the Reject-B lifecycle row and drifting the document's paragraph count.
632
+ 3. **The existing inserted-paragraph builders do not do this today.** `wrapParagraphContentInInsertion` and `buildFallbackInsertedPlainParagraph` in `services/document-operation-mutations.js` emit no paragraph-mark revision and clone `pPr` verbatim. Cloning `pPr` verbatim from the deleted source paragraph would copy Reviewer A's `w:rPr/w:del` onto Reviewer B's paragraph, reproducing the exact unsafe shape WP08 exists to prevent. Emitting the inserted mark and sanitizing `pPr` is new work in those builders, not reuse of them.
633
+
634
+ #### Paragraph Property Sanitization (Normative Allowlist)
635
+
636
+ When deriving Reviewer B's paragraph from the deleted source, copy only:
637
+
638
+ * `w:pStyle`, `w:numPr`, `w:ind`, `w:jc`, `w:spacing`, `w:tabs`, `w:keepNext`/`w:keepLines`, `w:outlineLvl`, `w:contextualSpacing`.
639
+
640
+ Strip unconditionally:
641
+
642
+ * `w:rPr/w:del` and `w:rPr/w:ins` (foreign mark revisions — replaced by Reviewer B's own inserted mark),
643
+ * `w:sectPr` (section identity must never be duplicated; see refusals),
644
+ * `w:pPrChange`, `w:rPrChange`, and every other `*Change` element (they describe a revision of the *source* paragraph and are meaningless on the clone).
645
+
646
+ #### Paragraph Identity (Normative)
647
+
648
+ Reviewer B's paragraph MUST receive a **fresh `w14:paraId`**, and MUST drop `w14:textId` and all `w:rsid*` attributes. This is unconditional, not best-effort: `extractParagraphIdFromOoxml` in `core/ooxml-identifiers.js` resolves strict targets by `w14:paraId`, so a duplicated paraId makes Reviewer A's and Reviewer B's paragraphs indistinguishable to paragraph-ID targeting — including to this work package's own strict-targeting test case.
649
+
650
+ #### Trigger Taxonomy (Normative)
651
+
652
+ "Whole-paragraph deletion" is ambiguous across the four combinations of paragraph-mark state and content state. Only one routes to WP08b:
653
+
654
+ | Paragraph mark | Pre-existing content | Route |
655
+ |:--|:--|:--|
656
+ | Deleted by foreign author | All deleted | **WP08b sibling restoration** (the resurrection state) |
657
+ | Deleted by foreign author | Intact or partially deleted | Ordinary run-level cross-author slicing — the accepted view is non-empty; a pending forward merge is legal and Word-native |
658
+ | Not deleted | All deleted | Ordinary cross-author insertion — no foreign mark owns the paragraph; MUST NOT route to WP08b |
659
+ | Deleted by current author | All deleted | `merge-same-author`; MUST NOT route to WP08b |
660
+
661
+ #### Validation Predicate (Normative)
662
+
663
+ The reconciliation rule must match the resurrection state exactly. A broader rule of the form "foreign paragraph-mark deletion plus visible insertion in the same paragraph" is **wrong** — inserting text into a paragraph whose mark is deleted by another author is legal, Word-native, and common (it is an ordinary pending merge). Flagging it would reject valid third-party documents.
664
+
665
+ The predicate is: foreign `w:pPr/w:rPr/w:del` **AND** every pre-existing content node deleted **AND** a new non-empty foreign `w:ins`. It is a **warning** in `core/redline-validation.js` and an **error** only at the mutation gate (WP08a).
666
+
667
+ #### Required Behavior
668
+
669
+ 1. Detect the resurrection state per the trigger taxonomy when an operation attempts to restore non-empty text into the paragraph's empty accepted view.
670
+ 2. Never append visible runs or `w:ins` content to the paragraph still owned by the foreign paragraph deletion.
671
+ 3. Derive Reviewer B's paragraph as a sanitized sibling of the source paragraph per the allowlist and identity rules above, without mutating Reviewer A's original deleted paragraph.
672
+ 4. Track the new paragraph's content and its paragraph mark as Reviewer B insertions with document-scoped revision IDs.
673
+ 5. Preserve document order and ensure `w:sectPr`, tables, list boundaries, comments, bookmarks, and other structural anchors are neither displaced nor duplicated.
674
+ 6. **Restoring a multi-paragraph range emits one inserted sibling paragraph per restored source paragraph**, as a contiguous block preserving source order, with N paragraph-mark insertions — never one merged paragraph.
675
+ 7. **Idempotency**: re-running the same restoration must not emit a second Reviewer B paragraph. If an adjacent same-author inserted paragraph already carries the restoration, route the edit through ordinary run-level slicing of that paragraph.
676
+ 8. Rejected-view descriptors remain read-only targeting aids until rejected-view mutation is deliberately supported. Do not silently treat `revisionView: 'rejected'` as accepted-view mutation.
677
+ 9. **Contract decision (resolved, not deferred):** restoration requires **explicit caller intent** — a dedicated restore operation or an explicit restoration option. A `redline` operation with an empty accepted-view target and non-empty modified text remains fail-closed under WP08a. Automatic conversion is rejected because the same request shape is indistinguishable from an ordinary "insert text into an empty paragraph", and silently choosing restoration would move the caller's content into a different paragraph than the one they targeted.
678
+
679
+ #### Structural Anchors (Normative)
680
+
681
+ Reviewer B's paragraph MUST NOT clone `w:bookmarkStart`/`w:bookmarkEnd` or `w:commentRangeStart`/`w:commentRangeEnd`/`w:commentReference`. Bookmark names are document-unique, and duplicating a comment range attaches one comment to two disjoint locations. Anchors stay on Reviewer A's paragraph, where they remain valid until that deletion is resolved. Every anchor not carried over is reported in the receipt as a structured warning naming the bookmark or comment ID, so the caller can re-anchor deliberately.
682
+
683
+ #### Fail-Closed Refusals (Distinct Codes)
684
+
685
+ A single `UNSAFE_PARAGRAPH_BOUNDARY` code conflates unrelated conditions and reads as a near-collision with the existing `PAIRING_SKIPPED_STRUCTURAL_BOUNDARY`. Enumerate:
686
+
687
+ | Condition | Code |
688
+ |:--|:--|
689
+ | Resurrection attempted without explicit restore intent (WP08a gate) | `FOREIGN_PARAGRAPH_MARK_DELETION` |
690
+ | Source paragraph inside a row deleted via `w:trPr/w:del` — a sibling paragraph cannot survive the row | `UNSAFE_DELETED_TABLE_ROW` |
691
+ | Source paragraph is part of a move (`w:moveFrom` / `w:moveFromRangeStart`) | `UNSUPPORTED_MOVE_REVISION` |
692
+ | Source paragraph's `pPr` carries `w:sectPr` — mirrors the existing deletion refusal in `core/paragraph-targeting.js` | `SECTION_BREAK_PARAGRAPH` |
693
+ | Source paragraph is the final paragraph of the body, so no safe sibling placement exists | `UNSAFE_PARAGRAPH_PLACEMENT` |
694
+
695
+ All refusals return the original document unchanged under atomic mode.
696
+
697
+ #### Lifecycle Invariants
698
+
699
+ For the paragraph pair `[ins(B), del(A)]` in document order:
700
+
701
+ | Resolution | Expected Result |
702
+ |:--|:--|
703
+ | Current view | Reviewer B's restored/adjusted paragraph appears exactly once; Reviewer A's paragraph is invisible. |
704
+ | Accept All | Reviewer B's paragraph and mark become baseline; Reviewer A's content deletion resolves and A's mark merges A forward into its **original** successor (not into B). Net: Reviewer B's paragraph remains exactly once. |
705
+ | Reject Reviewer B | Reviewer B's content is removed and B's inserted mark is rejected, merging the now-empty B forward into A. Net: the document returns to its pre-restoration text with Reviewer A's deletion still pending. |
706
+ | Reject Reviewer A | Reviewer A's paragraph and its text return; Reviewer B's insertion remains independently pending. Both marks remain attributable and structurally valid. |
707
+ | Accept Reviewer A only | Reviewer A's content deletion resolves and A merges forward into its original successor; Reviewer B's inserted paragraph remains pending and visible. |
708
+ | Accept Reviewer B only | Reviewer B's paragraph and mark become baseline; Reviewer A's deleted paragraph remains pending and invisible in the current view. |
709
+
710
+ No lifecycle path may silently discard Reviewer B's restoration, produce invalid nested revisions, orphan comments/bookmarks, or leave duplicate visible text after all revisions are resolved.
711
+
712
+ #### Verification Oracle (Normative)
713
+
714
+ Package validation is not an oracle for this feature; neither is a paragraph-local text comparison. Accepting a paragraph-mark deletion **crosses the paragraph boundary**, so a paragraph-scoped round-trip check cannot observe the merge.
715
+
716
+ Every WP08b fixture must therefore:
717
+
718
+ 1. Reconstruct **body-scoped** (or at minimum a window of source paragraph ± 2) canonical text for **both the accepted view and the rejected view**, and compare each exactly against expectation. The rejected view is where duplicate-text regressions surface; the accepted view alone would pass the reported bug.
719
+ 2. Assert the merge target of each paragraph-mark resolution explicitly, not inferred from resulting text.
720
+ 3. Fail closed with a structured mismatch code and byte-exact rollback on divergence, matching the established `PATCH_ROUNDTRIP_MISMATCH` pattern.
721
+
722
+ #### Planned Implementation Areas
723
+
724
+ - `services/document-operation-applier.js`: route explicit restoration intent; retain the rejected-view mutation guard for unsupported generic mutations.
725
+ - `services/document-operation-mutations.js`: add the paragraph-level restoration mutation and sibling placement; extend the inserted-paragraph builders to emit inserted paragraph marks and sanitized `pPr` (see Paragraph-Mark Semantics item 3).
726
+ - `core/paragraph-targeting.js`: resolve the deleted paragraph identity consistently across accepted and rejected metadata without allowing stale descriptors; reuse the existing `w:sectPr` refusal.
727
+ - Revision allocator and receipt collector: report every paragraph-mark and content revision ID allocated by the restoration, plus dropped-anchor warnings.
728
+ - `core/redline-validation.js`: add the narrowed resurrection-state warning (see Validation Predicate).
729
+
730
+ #### Required Test Matrix
731
+
732
+ 1. Plain whole-paragraph deletion restored verbatim.
733
+ 2. Restored paragraph adjusted while being restored.
734
+ 3. Bold, italic, underline, and mixed-run formatting preservation.
735
+ 4. Numbered and bulleted paragraph restoration without list-label drift.
736
+ 5. Paragraph immediately before `w:sectPr`, **and** a paragraph whose own `pPr` carries `w:sectPr` (refusal).
737
+ 6. Paragraph inside a table cell; paragraph inside a row deleted via `w:trPr/w:del` (refusal).
738
+ 7. Deleted paragraph containing bookmarks or comment anchors: assert anchor counts are **unchanged**, that no name or comment ID appears twice, and that each dropped anchor is reported in the receipt.
739
+ 8. Multiple adjacent deleted paragraphs restored independently and as a range, asserting one inserted sibling per source paragraph and preserved order.
740
+ 9. Same-author deletion behavior remains governed by `merge-same-author` and is not routed through cross-author restoration.
741
+ 10. Each non-triggering row of the trigger taxonomy routes to its stated path and not to WP08b.
742
+ 11. Third-author follow-up edits to Reviewer B's restored paragraph continue to use ordinary cross-author slicing.
743
+ 12. Repeat application of the same restoration is idempotent — no duplicate Reviewer B paragraph.
744
+ 13. Source paragraph retains unresolved content (partial deletion, or a third author's pending `w:ins`) — proves the merge target is A's original successor and that surviving content does not land inside Reviewer B's paragraph.
745
+ 14. `w:moveFrom` source paragraph (refusal); final-paragraph-of-body source (refusal).
746
+ 15. Atomic runner rollback and progressive batch receipts.
747
+ 16. Strict targeting by paragraph ID, index, fingerprint, and `revisionView: 'rejected'` diagnostics — including an assertion that Reviewer A's and Reviewer B's paragraphs carry distinct `w14:paraId` values.
748
+
749
+ Every successful fixture must assert exact current text, both-view body-scoped round-trip equality, structural validation, unique revision IDs, receipt reconciliation, paragraph ordering, and the six lifecycle outcomes above.
750
+
751
+ #### Implementation Record
752
+
753
+ * **Public Contract**: Added a dedicated `restore` document operation. A single restoration accepts a non-empty `modified` string; a contiguous range accepts one string per source paragraph. `generateRedlines: false` is rejected because restoration necessarily creates both a tracked content insertion and an inserted paragraph mark. Generic `redline` operations remain protected by WP08a.
754
+ * **Paragraph Model**: Each counterproposal is inserted immediately before the foreign-deleted source paragraph (or, for a range, as one contiguous inserted block before the source block). The source paragraph is not modified. The new paragraph receives two allocator-issued revisions, a fresh `w14:paraId`, no copied `w14:textId`/`w:rsid*`, and only allowlisted paragraph properties.
755
+ * **Lifecycle Oracle**: Before commit, restoration validates the authored OOXML and compares body-scoped paragraph text vectors for current view, Accept All, and Reject All against independently constructed expected documents. Any mismatch returns `PATCH_ROUNDTRIP_MISMATCH`; the operation savepoint supplies byte-exact rollback.
756
+ * **Idempotency**: An identical adjacent same-author restoration is a no-op. A changed same-author reapplication replaces the prior counterproposal rather than adding a duplicate paragraph.
757
+ * **Files Touched**:
758
+ - `core/paragraph-revision-safety.js`
759
+ - `core/paragraph-targeting.js`
760
+ - `services/document-operation-contract.js`
761
+ - `services/document-operation-applier.js`
762
+ - `services/document-operation-mutations.js`
763
+ - `services/operation-preflight.js`
764
+ - `services/standalone-operation-runner.d.ts`
765
+ - `docs/schemas/document-operations.schema.json`
766
+ - `index.d.ts`
767
+ - `tests/paragraph_level_cross_author_restoration_tests.mjs` (NEW)
768
+ - `tests/types/usage.ts`
769
+ - `README.md`
770
+ - `AGENTS.md`
771
+ - `CHANGELOG.md`
772
+ - `docs/plans/2026-09-08-cross-author-revision-slicing.md`
773
+ * **Functions and Types Touched**:
774
+ - `getParagraphRestorationRefusal` and move-range/content-state helpers (NEW): distinguish deleted-row, move-from, section-break, and unsafe-placement refusals while preserving the narrow trigger taxonomy.
775
+ - `resolveTargetParagraph` (MODIFIED): supports strict fingerprint-only descriptors, bringing runtime targeting into alignment with the published schema.
776
+ - `getCanonicalOperationType`, `normalizeDocumentOperation`, and `validateDocumentOperation` (MODIFIED): normalize and validate explicit single/range `restore` operations and retain full `targetEnd` descriptors.
777
+ - `applyOperationToDocumentXml` (MODIFIED): routes restoration separately and keeps rejected-view mutation read-only for both range endpoints.
778
+ - `restoreDeletedParagraphByExactText` (NEW): resolves the source block, enforces trigger/safety rules, reconstructs rejected-view content, inserts tracked siblings, handles idempotency, and runs the lifecycle oracle.
779
+ - `createSanitizedRestorationPPr`, `buildRejectedRestorationTemplate`, `editRestorationTemplate`, `allocateFreshParagraphId`, `trackRestoredParagraph`, and lifecycle/anchor helpers (NEW): implement property sanitization, formatting preservation, fresh identity, dropped-anchor diagnostics, and exact round-trip checks.
780
+ - `wrapParagraphContentInInsertion`, `buildFallbackInsertedPlainParagraph`, and `buildInsertedPlainParagraph` (MODIFIED): optionally emit inserted paragraph marks, use typed insertion receipt metadata, sanitize restoration properties, and accept fresh paragraph identity.
781
+ - `preflightOperations` (MODIFIED): recognizes restoration state, range cardinality, and structural refusals without mutating the document.
782
+ - `RestoreDocumentOperation` and `RedlineError` (MODIFIED/NEW): publish the operation shape and structured refusal/oracle metadata.
783
+ - WP08b fixture helpers and assertions (NEW): exercise six lifecycle outcomes, strict descriptors, independent and range restoration, formatting/list preservation, anchor warnings, table cells/deleted rows, section/move/placement refusals, taxonomy exclusions, progressive and atomic batches, idempotency, and third-author follow-up slicing.
784
+ * **Verification**:
785
+ - `node tests/paragraph_level_cross_author_restoration_tests.mjs` — PASS.
786
+ - Focused list and insertion-affinity regressions — PASS.
787
+ - `$env:DOCX_TEST_CONCURRENCY='1'; npm test` — PASS, 97 test files passed and 0 failed. The serial final run was used after concurrent attempts hit unrelated per-file timeouts under host contention; each timed-out suite also passed directly.
788
+ - `npm run lint` — PASS.
789
+ - `npm run check:types` — PASS; all 123 runtime exports have declarations.
790
+ - `npm run build` — PASS.
791
+ - `git diff --check` — PASS (line-ending conversion notices only; no whitespace errors).
792
+
442
793
  ---
443
794
 
444
795
  ## 6. Comprehensive Verification Plan (Synthetic & Real Test Series)
@@ -88,6 +88,9 @@
88
88
  {
89
89
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
90
90
  },
91
+ {
92
+ "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "const": "restore" }, "modified": { "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } ] }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
93
+ },
91
94
  {
92
95
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "delete" }, "modified": { "const": "" } } } ]
93
96
  },
@@ -40,6 +40,7 @@ import { getDocumentParagraphs } from './format-extraction.js';
40
40
  import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
41
41
  import { NumberingService } from '../services/numbering-service.js';
42
42
  import { recordRouteSelection } from './route-selection.js';
43
+ import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
43
44
 
44
45
  function getCommentIdsInOoxml(node) {
45
46
  const ids = new Set();
@@ -138,9 +139,29 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
138
139
  const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
139
140
  ? options._revisionIdAllocator
140
141
  : new RevisionIdAllocator();
141
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
142
-
143
- if (containsTrackedChanges(xmlDoc)) {
142
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
143
+
144
+ const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
145
+ ? [xmlDoc.documentElement]
146
+ : getDocumentParagraphs(xmlDoc);
147
+ if (inputParagraphs.length === 1 && modifiedText.length > 0) {
148
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
149
+ if (resurrectionTarget.matches) {
150
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
151
+ return finalize({
152
+ oxml: inputOoxml,
153
+ hasChanges: false,
154
+ status: 'error',
155
+ error: {
156
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
157
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
158
+ ownerAuthor
159
+ }
160
+ });
161
+ }
162
+ }
163
+
164
+ if (containsTrackedChanges(xmlDoc)) {
144
165
  if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
145
166
  const authors = getTrackedChangeAuthors(xmlDoc);
146
167
  const currentAuthor = String(author || '').trim().toLowerCase();
@@ -307,7 +328,9 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
307
328
  }
308
329
  const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
309
330
 
310
- const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
331
+ const hasTextChanges = existingRevisionsPolicy === 'slice-cross-author'
332
+ ? cleanModifiedText !== originalText
333
+ : cleanModifiedText.trim() !== originalText.trim();
311
334
  const hasFormatHints = formatHints.length > 0;
312
335
 
313
336
  const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
@@ -492,7 +515,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
492
515
  log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
493
516
  }
494
517
 
495
- const result = applySurgicalMode(
518
+ const result = applySurgicalMode(
496
519
  xmlDoc,
497
520
  originalText,
498
521
  cleanModifiedText,
@@ -502,10 +525,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
502
525
  generateRedlines,
503
526
  surgicalTarget,
504
527
  {},
505
- options
506
- );
507
-
508
- if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
528
+ options
529
+ );
530
+
531
+ if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
532
+ return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
533
+ }
534
+
535
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
509
536
  log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
510
537
  return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
511
538
  }
@@ -161,7 +161,18 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
161
161
  if (delWrapper && record.deletedPieces.length > 0) {
162
162
  delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
163
163
  }
164
- insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
164
+ const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
165
+ if (
166
+ record.globalEnd === endPos
167
+ && !isWordElement(parent, 'ins')
168
+ ) {
169
+ if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = new Map();
170
+ spanIndex.replacementInsertionAnchors.set(endPos, {
171
+ parent,
172
+ referenceNode: afterRun || runElement.nextSibling,
173
+ rPr: record.rPr
174
+ });
175
+ }
165
176
  parent.removeChild(runElement);
166
177
  changed = true;
167
178
  }
@@ -209,6 +220,29 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
209
220
  );
210
221
  }
211
222
 
223
+ const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
224
+ if (
225
+ replacementAnchor
226
+ && !affinity
227
+ && isConnected(replacementAnchor.parent)
228
+ && (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)
229
+ ) {
230
+ spanIndex.replacementInsertionAnchors.delete(pos);
231
+ insertTextRuns(
232
+ xmlDoc,
233
+ replacementAnchor.parent,
234
+ replacementAnchor.referenceNode,
235
+ text,
236
+ replacementAnchor.rPr,
237
+ author,
238
+ formatHints,
239
+ insertOffset,
240
+ generateRedlines,
241
+ revisionMetadata
242
+ );
243
+ return true;
244
+ }
245
+
212
246
  if (!affinity) {
213
247
  let targetSpan = findContainingSpan(spanIndex, pos);
214
248
 
@@ -237,6 +271,12 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
237
271
  return true;
238
272
  }
239
273
 
274
+ const generateNestedRevision = !(
275
+ generateRedlines
276
+ && existingRevisions === 'slice-cross-author'
277
+ && isSameAuthorInsertion(parent, author)
278
+ );
279
+
240
280
  if (
241
281
  generateRedlines
242
282
  && existingRevisions === 'slice-cross-author'
@@ -267,14 +307,14 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
267
307
  const afterPieces = sliceRunPieces(xmlDoc, pieces, localInsertPos, getRunTextLength(pieces), false);
268
308
 
269
309
  insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, beforePieces, targetSpan.rPr);
270
- insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
310
+ insertTextRuns(xmlDoc, parent, targetSpan.runElement, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
271
311
  insertRunPiecesBefore(xmlDoc, parent, targetSpan.runElement, afterPieces, targetSpan.rPr);
272
312
  parent.removeChild(targetSpan.runElement);
273
313
  return true;
274
314
  }
275
315
 
276
316
  const referenceNode = pos <= targetSpan.charStart ? targetSpan.runElement : targetSpan.runElement.nextSibling;
277
- insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
317
+ insertTextRuns(xmlDoc, parent, referenceNode, text, targetSpan.rPr, author, formatHints, insertOffset, generateNestedRevision, revisionMetadata);
278
318
  return true;
279
319
  }
280
320
 
@@ -523,6 +563,12 @@ function isForeignInsertion(node, author) {
523
563
  return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
524
564
  }
525
565
 
566
+ function isSameAuthorInsertion(node, author) {
567
+ if (!isWordElement(node, 'ins')) return false;
568
+ const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
569
+ return carrierAuthor.trim().toLowerCase() === String(author || '').trim().toLowerCase();
570
+ }
571
+
526
572
  function nextElementSibling(node) {
527
573
  let sibling = node?.nextSibling || null;
528
574
  while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;