Skip to content

fix: accepting a redline takes a wholly-deleted note's definition with it - #634

Merged
JSv4 merged 3 commits into
mainfrom
fix/631-accept-takes-wholly-deleted-notes
Aug 31, 2026
Merged

fix: accepting a redline takes a wholly-deleted note's definition with it#634
JSv4 merged 3 commits into
mainfrom
fix/631-accept-takes-wholly-deleted-notes

Conversation

@JSv4

@JSv4 JSv4 commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Closes #631

The problem

Accepting a redline through the stateless path (DocxDiffOps.AcceptRevisions, which is what npm,
python, MCP and WASM all reach) did not reproduce the counterpart's note store. On the
WC035-Footnote pair — a real Word-authored document pair where the change deletes the only
footnote — accepting the comparison's redline left 1 footnote definition where the counterpart
has 0, and DocxSession.AcceptAllRevisions on the very same redline produced 0. Two
transports, same redline, different deliverables.

#625 had deliberately shipped the note-lifecycle rule on the reject side only, because a symmetric
implementation broke the other husk case: a counterpart that keeps a reference-less definition
(the shape a naive SDK edit leaves behind) is entitled to keep it through
Accept(Compare(l, r)) ≡ r. The issue's open question was whether the redline can even tell the
two cases apart.

The finding: the redline already tells them apart

It can, and it already does — which makes the issue's option 1 ("represent the note-store
difference in the redline") mostly a matter of reading a representation the engine already
writes:

  • Case A — the counterpart kept its husk. The note differ's reference-correspondence pass
    reconciles the baseline's cited definition with the counterpart's uncited-but-identical one into
    a matched, equal pair (AppendUnreferencedNotes), so the definition passes through the redline
    with its content unmarked.
  • Case B — the counterpart deleted the note. The definition pairs with nothing, becomes
    all-DeleteBlock ops, and lands in the redline with every block deletion-marked: runs inside
    w:del, paragraph mark carrying w:rPr/w:del.

Accepting therefore treats them differently already: Case A's definition survives with its
content; Case B's is stripped to a childless <w:footnote w:id=".."/>. What was missing is the
last step: nothing removed that stripped-bare shell.

The fix

The note-lifecycle rule (single owner: Internal/NoteReferenceOps.cs) gains an accept-side
variant, PruneNotesEmptiedByAccept: remove a definition whose last citation this accept removed
and which the accept left without block content. The blockless guard is exact — an unmarked
paragraph always survives an accept even when its runs are deleted, so a definition can only come
out bare when every one of its blocks was deletion-marked, i.e. when the redline said "this whole
note is deleted". Word's own tracked deletions carry exactly this shape: RP050-Deleted-Footnote
(Word-authored) marks every run of the definition w:del and deletes the paragraph mark. One
honest nuance, now recorded in docs/ooxml_corner_cases.md: Word's accept of that fixture left
a childless <w:footnote w:id="1"/> shell in the oracle rather than removing the element. We
remove it — the schema-valid form (CT_FtnEdn requires a block child), what the session has done
since #516, and materially safer in LibreOffice, which re-associates references to definitions
positionally and is thrown off by leftover definitions. The reversibility sweep compares against
Word's oracle semantically, where the two forms are the same note store, and stays green.

RevisionProcessor's accept core captures the cited-note ids before the walk and prunes after it,
mirroring what the reject side has done since #614 — same scoping (only citations this operation
removed; a pre-existing dangling note is left alone), same whole-package citation scan (a note
still cited from a header survives). Because the rule sits in the shared accept core, every place
an accepted view of a document is computed — the stateless accept, DocxDiff's
PreAcceptInputRevisions flatten, IrReader's revision view, and WmlComparer's input flatten —
now agrees about what accepting a wholly-deleted note means, instead of leaving a bare husk to
ride into downstream redlines as debris.

No policy setting was added: with the representation read correctly, accept reproduces the
counterpart in both cases and there is no decision left to push onto callers.

Tests

  • DS432_StatelessAccept_TakesAWhollyDeletedNoteWithItsCitation (new, red before the fix:
    1 note instead of 0): on the WC035 pair compared in the deleting direction, the stateless accept
    now matches the counterpart's note count, agrees with DocxSession.AcceptAllRevisions on the
    same redline, and the stateless reject still reproduces the baseline.
  • DS430_StatelessAccept_PreservesACounterpartsOwnOrphanedNote — the Case A pin the issue
    flagged for revisiting — passes unchanged; only its rationale comment was updated. The guard is
    what keeps it true.
  • DS418/DS419/DS429/DS431 (the session-side rule, its scoping, the stateless reject, the
    header-citation scan) all pass unchanged.
  • Full suite: 4,222 passed, 0 failed, 3 skipped (7m55s).

Corpus differential

Baseline on a main worktree, --check on this branch, 678 documents: exactly 1 mismatch of
12,882 observations
RP/RP050-Deleted-Footnote.docx#preaccept/redline, on the result channel
only. That is the one corpus fixture whose defining feature is a Word-authored tracked note
deletion: under PreAcceptInputRevisions the input flatten's accepted view now drops the emptied
husk instead of letting a childless <w:footnote/> ride into the rendered redline as debris. No
other row moved, and every other channel stayed clean/stable/unchanged.

Docs

docs/architecture/docx_mutation_api.md's note-lifecycle section now describes the accept-side
guard instead of the old "accept deliberately does not apply it" contract; CHANGELOG.md records
the behaviour change under Fixed; docs/ooxml_corner_cases.md gains the RP050 finding with its
minimal XML reproducer and a Word / LibreOffice / Docxodus comparison table.

After adversarial review (second commit)

A multi-verifier review of this diff confirmed one coverage gap and two prose overclaims, all
fixed here, and two pre-existing neighbouring defects, filed as #636 rather than bolted on:

  • DS433_AcceptFunnel_TakesAWordAuthoredNoteDeletionsDefinition (new, red on main): every
    accept in the library funnels through one private core — the stateless facade, the
    PreAcceptInputRevisions flatten, IrReader's revision view, WmlComparer's input flatten,
    WmlToHtmlConverter's accept — and DS432 pinned only the facade. DS433 pins the funnel itself
    on the Word-authored RP050 fixture (an assertion the existing RP001/RRS001 sweeps were verified
    to be structurally blind to).
  • The transport-agreement claim is scoped to the shape it is true for. Session and stateless
    accept agree about a wholly-deleted note; on the counterpart-kept husk they remain deliberately
    distinct contracts (DS430 vs DS418), and the docs now say so instead of implying general parity.
  • The corner-case entry's RRS001 explanation was wrong (RP050 is pinned
    acceptEquivalent: false there; recovery is asserted through story text, not modeled-semantic
    equivalence) — corrected, and the entry gains the reproducer/table the house rules require.
  • The rule's rationale now lives once on NoteReferenceOps.PruneNotesEmptiedByAccept
    (RevisionProcessor's two comments shrink to pointers), IsBlockless documents why a
    direct-children check is sufficient after accept's coalescing pass, and ReferencedNoteIds
    early-outs on documents with no notes parts instead of walking every story twice per resolution.
  • Reject has the mirror of #631's husk bug (blocked by #614's unmarked definitions), and accept can ship a cited childless note #636 records the two confirmed pre-existing defects: the reject side has the exact mirror
    of this bug but its guard collides with insertFootnote/insertEndnote under render_inline recording write an irreversible mark instead of refusing #614's deliberate unmarked-definition choice (needs its
    own design round), and an accept can ship a schema-degenerate cited childless note when the
    definition is emptied while its citation survives.

JSv4 added 3 commits August 31, 2026 07:06
…h it

The redline itself distinguishes the two reference-less-husk cases (issue
#631). A note definition the counterpart KEPT is reconciled by the note
differ's reference-correspondence pass into a matched equal pair and passes
through the redline unmarked; one the counterpart DELETED pairs with nothing
and lands with every block deletion-marked -- runs in w:del, paragraph mark
deleted -- exactly the shape Word itself authors for a tracked note deletion
(RP050-Deleted-Footnote). Accepting strips the second shape bare, and nothing
removed the stripped shell: the stateless accept shipped a childless
<w:footnote/> the counterpart did not have, and disagreed with
DocxSession.AcceptAllRevisions about the same redline.

The note-lifecycle rule (single owner: Internal/NoteReferenceOps.cs) gains
PruneNotesEmptiedByAccept: remove a definition whose last citation this accept
removed AND which the accept left without block content. The guard is exact --
an unmarked paragraph always survives an accept, so a definition only comes
out blockless when the redline deleted the whole note -- and it is what keeps
Accept(Compare(l, r)) == r true for a counterpart's own husk (DS430) while
making it true for a deleted note (DS432, new; red before this fix). Applied
in RevisionProcessor's shared accept core, so every accepted view -- the
stateless accept, the PreAcceptInputRevisions flatten, IrReader's revision
view, WmlComparer's input flatten -- agrees on what accepting a wholly-deleted
note means. No policy setting: with the representation read, there is no
decision left to push onto callers.

Full suite 4,222 passed / 0 failed. Corpus differential vs a main baseline:
exactly 1 mismatch of 12,882 -- RP050-Deleted-Footnote#preaccept/redline, the
one fixture whose defining feature is a tracked note deletion, whose flattened
view now drops the emptied husk. Word's own accepted oracle for RP050 keeps a
childless shell where we remove the element; the difference is recorded in
docs/ooxml_corner_cases.md, the reversibility sweep compares semantically and
stays green, and removal is the schema-valid form CT_FtnEdn requires.
…ighten the rule's prose to one owner

Post-review round on #631. DS433 pins the shared accept core on the
Word-authored RP050 fixture -- the single private funnel behind the stateless
facade, the PreAcceptInputRevisions flatten, IrReader's revision view,
WmlComparer's input flatten and WmlToHtmlConverter's accept -- which DS432
alone did not cover (red on main: the childless shell counts as a note there).

The transport-agreement claim is scoped to the shape it is true for: session
and stateless accept agree about a wholly-deleted note, and stay deliberately
distinct on the counterpart-kept husk (DS430 vs DS418) -- the test comment,
docx_mutation_api.md and the corner-case entry now say so, and the corner-case
entry gains the minimal XML reproducer and renderer comparison table the house
rules require, plus the accurate description of WHY RRS001 stays green
(RP050 is pinned acceptEquivalent:false; recovery is asserted through story
text, not modeled-semantic equivalence).

The rule's rationale now lives once, on NoteReferenceOps.PruneNotesEmptiedBy-
Accept; the two RevisionProcessor comments shrink to pointers. IsBlockless
documents why a direct-children check is sufficient after accept's coalescing
pass and how it relates to the wider pre-accept block classifiers. And
ReferencedNoteIds early-outs when the document has no notes parts at all,
instead of walking the body and every header/footer twice per resolution on
note-free documents -- the common case on the comparison flatten paths.
@JSv4
JSv4 merged commit d6a6905 into main Aug 31, 2026
14 checks passed
@JSv4
JSv4 deleted the fix/631-accept-takes-wholly-deleted-notes branch August 31, 2026 13:15
JSv4 added a commit that referenced this pull request Aug 31, 2026
…tateless reject reads it (#638)

* fix: a recorded note's definition carries insertion markup, and the stateless reject reads it

The stateless reject pruned every definition its resolution orphaned,
unconditionally. That was cover for #614's deliberately unmarked definitions
-- and it made the redline ambiguous to a stateless consumer: a w:ins citation
next to an unmarked definition is ALSO what a comparison produces when the
counterpart merely cites a husk the baseline already owned, and rejecting that
redline deleted the content-bearing definition the baseline was entitled to
keep. Reject(Compare(l, r)) != l -- the exact mirror of the #631 accept bug,
found by #634's adversarial review and filed as #636.

InsertFootnote/InsertEndnote under RenderInline now mark the definition's
content inserted, through MarkParagraphContentAndMark -- the existing single
owner of "this whole paragraph is one revision" -- matching what Word writes
and what the diff engine's own redline already carried for a wholly-inserted
note. With every producer marking, the stateless reject takes the same
blockless guard as the accept side: PruneNotesEmptiedByAccept becomes
PruneNotesEmptiedByResolution, and a definition the resolution both orphaned
and emptied goes while an unmarked husk stays.

DS369 pins the marking and both stateless resolutions on session-authored
redlines (both note kinds); DS434 pins the mirror case the review reproduced
(baseline-owned husk, counterpart citation: reject now preserves it). The
#614 family (DS366-368) and the #631 family (DS429-433) stay green unchanged.
Full suite 4,226 passed / 0 failed; corpus differential adds zero movement
beyond #631's known RP050 row (no fixture carries a live w:ins note citation,
and the reject path is not corpus-exercised).

Compatibility: a redline saved from a #614-era session (definition unmarked)
now rejects to an orphaned husk through the stateless path instead of losing
the note; re-authoring with the current version restores full reversibility.
Noted in the CHANGELOG.

* docs: record the cited-childless residual shape in the corner-cases entry

The second defect #636's review confirmed -- a definition emptied while its
citation survives ships as a cited childless shell -- is a degenerate shape
Word's UI cannot author, so it lives in the corner-cases doc as known,
deliberately-unhandled behavior with the deferred fix sketched, rather than
as an open tracker item.

* review: make DS434/DS369 observe the shapes they exist to pin, and document partial resolution

The gap sweep on PR #638 found both new tests asserting counts where the
claims are about shape. DS434 now asserts the redline's load-bearing premise
directly -- the citation arrives inside w:ins while the reconciled definition
passes through unmarked -- and checks each resolution's substance: reject
leaves the husk's content with no surviving body reference (content-level,
because the redline renumbers note ids in body-reference order and an
unreferenced husk can only pair by id, so GetRevisions reads the same husk at
a shifted id as a delete+insert of identical text), and accept reproduces the
counterpart to zero revisions. DS369's accept leg now asserts the body
citation survived -- without it, an accept that shipped an uncited invisible
note passed every count-based assert in the suite.

Also documents the one granularity consequence of marking the definition,
shared with the diff engine's own redlines which carry the identical shape:
the definition's insertions are individually resolvable, so rejecting only
them while keeping the citation yields an empty but still-cited note -- the
ordinary meaning of partially resolving a compound change; resolve-all in
either direction never produces it.

* review: read WmlComparer's marker scaffolding as emptiness, share one revision date, and preserve cited bare notes

Second adversarial round on #638, three behavioral findings, each verified:

The guard learns the LEGACY engine's dialect. WmlComparer re-synthesizes an
UNMARKED w:footnoteRef marker run into every inserted definition it renders,
so a purely-blockless guard shipped an orphaned marker-husk on reject where
the pre-#636 unguarded prune removed the note. IsEmptiedToScaffolding treats
note-reference-mark scaffolding as emptiness; DS435 pins both resolutions of
a WmlComparer redline over the WC035 pair.

One user action, one revision identity: the citation envelope and every
definition paragraph now share a single author/date pair (previously each
MarkParagraphContentAndMark call took its own monotonic date, splintering one
insert into per-paragraph revision groups under exact-date grouping). DS369
asserts a single distinct w:date across body and note.

A still-cited note a resolution strips bare stays bare. The reviewer's
suggested repair -- restore a minimal empty paragraph -- was implemented and
then REVERTED by evidence: the corpus genuinely contains cited childless
notes (WC064-Footnote ships one; WC063-Footnote-Mod's note is childless), and
the repair broke both WC round-trip suites because Accept(Compare(l,r)) == r
means reproducing exactly that shape. DS436 pins the contract: the
partial-resolution sequence keeps the note cited and bare, and the
orphan-scoped prune never deletes a note out from under its citation.

Also: DS434 gains the session-leg distinctness pin (the editorial reject
still strips the husk -- deliberately distinct, now said on purpose) and
shares one husk fixture builder with DS430; stale/contradictory prose in
docx_mutation_api.md, ooxml_corner_cases.md and the CHANGELOG (including the
same release notes' #614 entry) is reconciled; the reject-side comment shrinks
to a pointer at the rule's owner, which now documents the scaffolding
predicate, the preserved-shape decision, and the session interlock (the
session resolver keeps a note's last block alive, so unifying it onto the
guarded prune would silently stop pruning session-rejected recorded notes).

Full suite 4,228 passed / 0 failed. Corpus differential: zero
library-attributable movement.
JSv4 pushed a commit that referenced this pull request Aug 31, 2026
#638 completes what #625 started on the path this demo exercises. #625
made the citation the reversible unit but left the definition unmarked,
compensating with an unconditional prune on the stateless reject. That
made the redline ambiguous to a stateless consumer: a w:ins citation
beside an unmarked definition is also exactly what a comparison emits
when the counterpart merely cites a husk the baseline already owned, and
the unguarded prune ate that husk. Now both halves record and the prune
is guarded on the definition being emptied as well as orphaned.

The demo's comments and the tracked_changes.md pointer both described the
#625 mechanism, so both were describing an encoding the engine no longer
writes. Corrected.

The browser assertion is extended rather than left alone, because the old
one would still pass against the ambiguous shape: it checked the citation
run sits inside a w:ins and stopped there. It now also pulls the note
definitions out of the redline and requires the one Act II authored to
carry insertion markup. Identifying it by its text matters — the part
also holds Word's two reserved separator notes, which carry no markup and
must not.

No re-measurement. #634 and #638 are on the note-lifecycle and reject
paths; the stress loop times docxDiffGetRevisions and
docxDiffCompareProducts, which neither touches. Published figures stand
as measured at #627.

Verified on the rebuilt engine: 14/14 browser assertions, 61/61 node
checks. Also merges #633 and #637, both test-only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Accepting a redline does not reproduce the counterpart's note store, and neither choice is right for every document

1 participant