Operand

do no harem.

gram: docs

> ./openspec/changes/tracked-structural-changes/design.md

# Design: Tracked Structural Changes
## OOXML reference
Most structural revision markers carry the attribute triple `w:id` (required, `xsd:int`), `w:author` (required, `xsd:string` display name), `w:date` (optional, `xsd:dateTime`). The schema-level base is `CT_TrackChange` (`wml.xsd:805`). A few exceptions extend `CT_Markup` instead and carry **only `w:id`**:
- `<w:tblGridChange>` (`CT_TblGridChange`, extends `CT_Markup`, `wml.xsd:893`) — id only.
Per-marker prior-state elements (`*Change`) carry a **full prior snapshot**, not a diff. The schema base of each prior element is a `*Base` type (e.g. `pPrChange` contains `CT_PPrBase`, not `CT_PPr`), so the prior **cannot** itself nest `rPr`, `sectPr`, or a further `*Change`.
### Schema-mandated child ordering inside `<w:rPr>` and `<w:pPr>`
The serializer MUST honor these orderings or Word and strict readers will reject the output:
- Inside a run `<w:rPr>` (per `EG_RPrContent`, `wml.xsd:1784`): regular base properties first, **`<w:rPrChange>` last**.
- Inside a paragraph-mark `<w:pPr><w:rPr>` (per `EG_ParaRPrTrackChanges`, `wml.xsd:1837`): `<w:ins>` / `<w:del>` / `<w:moveFrom>` / `<w:moveTo>` **first** (in that order), then base properties, then `<w:rPrChange>` last.
- Inside `<w:pPr>` (per `CT_PPr`, `wml.xsd:1044`): base properties first, **`<w:pPrChange>` last**.
- Inside `<w:tcPr>`, `<w:trPr>`: structural change elements appear in `EG_CellMarkupElements` / `EG_TrackChange` positions per `wml.xsd:977,2330`; the `*Change` snapshot variants appear last.
### `cellMerge` is vertical-merge tracking, not horizontal
`CT_CellMergeTrackChange` (`wml.xsd:811`) extends `CT_TrackChange` with **`vMerge`** and **`vMergeOrig`** attributes of type `ST_AnnotationVMerge` (values `cont`, `rest`). There is **no `val` attribute** and there is no horizontal-merge dimension in this element. **Horizontal merge** tracking in Word is conveyed by `<w:cellIns>` on the merging cell and `<w:cellDel>` on each absorbed cell (the absorbed cells remain in the row XML until the merge is accepted; on accept they are removed and the surviving cell's `gridSpan` is increased).
### Distinct paragraph-mark rPr change
`CT_ParaRPrChange` (`wml.xsd:938`) is a **separate element** from `CT_RPrChange` (the regular run rPr change). It tracks changes to the formatting of the paragraph-mark glyph itself ("the paragraph mark used to be bold"), and lives at the **end of `CT_ParaRPr`** (i.e. `<w:pPr><w:rPr><w:rPrChange>` uses `CT_ParaRPrChange`, not `CT_RPrChange`). Wiring it to the inline `revision_change` mark would write to the wrong schema position. It needs its own paragraph-node attr.
### Tier 1 — MUST support for round-trip without data loss
| Marker | Location | Tracks | Currently parsed | Currently serialized |
| ----------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | --------------------------------------- | -------------------------- |
| `<w:ins>` / `<w:del>` wrapping runs | inside `<w:p>` | inline insert/delete | yes (mark) | yes |
| `<w:pPr><w:rPr><w:ins/>` | inside `<w:pPr><w:rPr>` | paragraph-mark inserted | **no** | no |
| `<w:pPr><w:rPr><w:del/>` | inside `<w:pPr><w:rPr>` | paragraph-mark deleted | **no** | no |
| `<w:pPrChange>` | last child of `<w:pPr>` | prior paragraph props (full snapshot, frozen) | yes (model only, dropped at toProseDoc) | yes (only if model has it) |
| `<w:rPrChange>` (run) | last child of `<w:rPr>` | prior run props (full snapshot, frozen) | yes (model only, dropped) | yes (only if model has it) |
| `<w:rPrChange>` (paragraph-mark, `CT_ParaRPrChange`) | last child of `<w:pPr><w:rPr>` | prior paragraph-mark rPr | **no (separate code path)** | no |
| `<w:sectPrChange>` | last child of `<w:sectPr>` (both body-level and `pPr/sectPr` placements legal) | prior section props | **no** | no |
| `<w:trPr><w:ins/>` | inside `<w:trPr>` | row inserted | **no** | no |
| `<w:trPr><w:del/>` | inside `<w:trPr>` | row deleted | **no** | no |
| `<w:trPrChange>` | child of `<w:trPr>` | prior row props | yes (model only, dropped) | yes (only if model has it) |
| `<w:tcPr><w:cellIns/>` | inside `<w:tcPr>` (choice, exclusive) | cell inserted | yes (model only, dropped) | no |
| `<w:tcPr><w:cellDel/>` | inside `<w:tcPr>` (choice, exclusive) | cell deleted (and used to mark cells absorbed by horizontal merge) | yes (model only, dropped) | no |
| `<w:tcPr><w:cellMerge vMerge=…>` | inside `<w:tcPr>` (choice, exclusive) | vertical cell merge | yes (model only, dropped) | no |
| `<w:tcPrChange>` | child of `<w:tcPr>` | prior cell props | yes (model only, dropped) | no |
| `<w:tblPrChange>` | child of `<w:tblPr>` | prior table props | yes (model only, dropped) | no |
| `<w:tblPrExChange>` | child of `<w:tblPrEx>` **inside `<w:tr>`** (per-row exceptions) | prior table-exception props for this row | yes (model only, dropped) | no |
| `<w:tblGridChange>` (`CT_TblGridChange`, **id only**) | child of `<w:tblGrid>` | prior grid (column widths) | yes (model only, dropped) | no |
### Tier 2 — SHOULD support (covered later)
| Marker | Location | Tracks |
| -------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------ |
| `<w:moveFrom>` / `<w:moveTo>` | wrap runs | block move source / destination |
| `<w:moveFromRangeStart/End>` | inline | move-source span anchors |
| `<w:moveToRangeStart/End>` | inline | move-target span anchors |
| `<w:numPr><w:ins/>` (in schema; `<w:numberingChange>` is NOT in `wml.xsd`) | inside `<w:numPr>` | list-item assignment inserted (no `numPr/del` exists — limitation) |
### Tier 3 — NICE-TO-HAVE (out of scope)
`<w:customXmlInsRangeStart/End>` and its variants (parser SHOULD at minimum round-trip them as opaque to avoid stripping third-party tracking).
### Identity and grouping
Sidebar entries and `acceptChangeById` resolution group by the **triple `(w:id, w:author, w:date)`**, not bare `w:id`. The schema does not enforce id uniqueness and Word does not scope ids by author, so id collisions across authors are possible.
### Accept / Reject behavior
Per-marker semantics. Word's behavior is the reference (sources cited in research notes accompanying this proposal).
| Marker | Accept | Reject |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| inline `w:ins` | keep text, drop wrapper | remove text and wrapper |
| inline `w:del` | remove text and wrapper | keep text, drop wrapper |
| `pPr/rPr/ins` (paragraph-mark ins, on first paragraph of the split) | clear marker, keep split | join this paragraph with next; **resulting paragraph inherits the _second_ paragraph's `pPr`**; clear marker |
| `pPr/rPr/del` (paragraph-mark del) | join this paragraph with next; resulting paragraph inherits the second paragraph's `pPr`; clear marker | clear marker, keep split |
| `pPrChange` | clear `pPrChange`, current props win | restore full prior `pPr` snapshot, clear `pPrChange` |
| `rPrChange` (run) | clear, current rPr wins | restore full prior `rPr` snapshot, clear |
| `paraRPrChange` (paragraph-mark) | clear, current paragraph-mark rPr wins | restore full prior paragraph-mark rPr, clear |
| `sectPrChange` | clear, current section props win | restore full prior `sectPr`, clear |
| `trPr/ins` | clear marker | delete row |
| `trPr/del` | delete row | clear marker |
| `trPrChange` | clear | restore prior `trPr`, clear |
| `cellIns` | clear marker | delete cell (and adjust `gridSpan`) |
| `cellDel` | delete cell (and adjust `gridSpan` of surviving cells) | clear marker |
| `cellMerge` (vertical, `vMerge`/`vMergeOrig` pair on adjacent rows' cells sharing a `(id, author, date)` triple) | apply the vertical merge | clear markers |
| `tcPrChange` | clear | restore prior `tcPr`, clear |
| `tblPrChange` / `tblPrExChange` (per-row) | clear | restore prior, clear |
| `tblGridChange` (id-only) | clear | restore prior `<w:tblGrid>`, clear |
#### Edge cases
- `pPrIns` reject on the **last paragraph in the document** has no following sibling to join with. Behavior: clear attr without joining, log a diagnostic, return `true`.
- `pPrDel` accept on the **first paragraph** has no previous sibling. Behavior: clear attr without joining, log a diagnostic, return `true`. (Word emits this rarely; conformance still requires it.)
- `trDel` accept on the **only row** of a table leaves the table with zero rows, which is invalid per PM schema. Behavior: accept removes the entire table node.
- `cellIns` / `cellDel` / `cellMerge` on the same cell are mutually exclusive per `EG_CellMarkupElements` (`wml.xsd:977`). Authoring a second on a cell that already carries one collapses both (insert-then-delete in the same session ⇒ no marker; insert-then-merge ⇒ keep only the more recent).
- Adjacent paragraphs each carrying `pPrIns`: rejecting either is per-id, and "join with following" uses the post-acceptance position, not the original (so rejecting in id-order is well-defined).
## PM schema additions
All revision attrs use the shape:
```ts
type RevisionInfo = {
revisionId: number; // matches OOXML w:id (xsd:int)
author: string; // display name from w:author
date: string | null; // ISO 8601 UTC ("…Z"), null if w:date absent
};
type PropertyChangeInfo<P> = RevisionInfo & {
prior: P; // full frozen snapshot; not field-merged
};

revisionId is a number to match (a) OOXML w:id (decimal integer), (b) the existing InsertionExtension / DeletionExtension mark attrs in TrackedChangeExtensions.ts, (c) the existing agents-package acceptChange(id: number) signature in packages/agents/src/changes.ts.

date is normatively ISO 8601 with explicit Z (UTC) and no fractional seconds; the parser accepts any valid xsd:dateTime and normalizes; missing w:date is tolerated as null.

prior for pPrChange is the parsed ParagraphFormatting snapshot without _originalFormatting, _sectionProperties, or any nested change. prior is frozen on first edit within a (id, author, date) revision session and never updated by subsequent edits in the same session. After every edit, the implementation compares the resulting current properties against prior; if all prior fields equal current, the *Change attr is cleared (Word's "no-op net change ⇒ no revision" behavior).

Relationship to existing _originalFormatting attrs

ParagraphAttrs._originalFormatting (and the TableAttrs / TableCellAttrs counterparts) already exists in packages/core/src/prosemirror/schema/nodes.ts. Its purpose is parse-time baseline preservation so unhandled OOXML round-trips losslessly. pPrChange.prior is edit-time pre-snapshot for revision tracking — a different lifecycle. The two coexist and never overwrite each other. On save, the serializer reads _originalFormatting for fields the editor doesn't model and pPrChange.prior for tracked-change history; both are emitted in their respective XML positions.

Multi-author / multi-session changes per node

Existing model types (Paragraph.propertyChanges: ParagraphPropertyChange[]) treat property changes as an array: two authors editing the same paragraph stack changes. To preserve this, the new attrs are arrays:

// ParagraphAttrs additions
pPrIns: RevisionInfo | null;
pPrDel: RevisionInfo | null;
pPrChange: (PropertyChangeInfo < ParagraphFormatting > []) | null;
paraRPrChange: (PropertyChangeInfo < ParagraphMarkFormatting > []) | null;
sectPrChange: PropertyChangeInfo<SectionFormatting> | null;
sectPrChangeBodyLevel: boolean | null; // true if the sectPr is body-level rather than pPr-level

pPrIns and pPrDel remain single-valued (only one author can be "the one who inserted" a given paragraph mark).

table / table_row / table_cell node — additions

// table
tblPrChange: PropertyChangeInfo<TableFormatting>[] | null
tblGridChange: PropertyChangeInfoNoAuthor<TableGrid> | null // CT_Markup: id only
// table_row
trIns: RevisionInfo | null
trDel: RevisionInfo | null
trPrChange: PropertyChangeInfo<TableRowFormatting>[] | null
tblPrExChange: PropertyChangeInfo<TablePropertyExceptions>[] | null
// table_cell (cellIns / cellDel / cellMerge are mutually exclusive per schema)
cellMarker: | { kind: 'ins', info: RevisionInfo }
| { kind: 'del', info: RevisionInfo }
| { kind: 'merge', info: RevisionInfo, vMerge: 'rest' | 'cont', vMergeOrig?: ... }
| null
tcPrChange: PropertyChangeInfo<TableCellFormatting>[] | null

PropertyChangeInfoNoAuthor<P> is { revisionId: number; prior: P } for the CT_Markup-extending change types (only tblGridChange). Sidebar entries for these display "Unknown" for author/date.

New revision_change mark (for run rPrChange only)

revisionChange: {
attrs: { revisionId, author, date, prior: RunFormatting }
inclusive: false
}

Used only for run-level <w:rPrChange>. The paragraph-mark CT_ParaRPrChange does NOT use this mark — it uses the paragraph node's paraRPrChange attr.

Mark identity / merging: PM Mark.eq compares attr objects by reference. To allow adjacent runs that share a revision to render as a single span, prior is canonicalized at creation: keys are deterministically sorted, and the resulting object is Object.freezed. A shared prior reference is reused when possible. findAdjacentRevision in suggestionMode.ts is extended to deep-compare revision_change attrs for adjacency.

Cache key updates

hashParagraphBlock (packages/core/src/layout-bridge/measuring/cache.ts) keys paragraph measurements by a fixed allowlist of attrs (text, alignment, indent, spacing, default font, borders, suppress flag). Adding the new revision attrs without updating this hash would cause cross-doc cache bleed: two paragraphs with identical text and different pPrIns would share an entry. The hash MUST include:

Equivalent updates apply to hashTableBlock if it exists (verify at implementation), and to whatever measures TableBlock.

Parser changes

Files:

No new parser categories — most of the work is surfacing what's already read.

Conversion changes

packages/core/src/prosemirror/conversion/toProseDoc/:

packages/core/src/prosemirror/conversion/fromProseDoc/ — inverse, including the array-shaped attrs.

packages/core/src/prosemirror/utils/extractTrackedChanges.ts — must be extended to traverse node attrs in addition to text marks, so the sidebar shows structural revisions.

Serializer changes

packages/core/src/docx/serializer/paragraphSerializer.ts — emit <w:pPr><w:rPr><w:ins/> / <w:del/> (in the schema-mandated first position inside the rPr) when pPrIns / pPrDel is set. Emit paraRPrChange as the last child of <w:pPr><w:rPr>. Existing pPrChange serialization stays; verify it goes at the end of <w:pPr> per the ordering rule above.

packages/core/src/docx/serializer/runSerializer.ts (or wherever run rPr is emitted) — rPrChange must be the last child of <w:rPr>. Verify and fix if not.

packages/core/src/docx/tableSerializer.ts — emit the new attrs back to OOXML. Emit <w:cellMerge w:vMerge="rest|cont"/> (NOT w:val). Emit <w:tblGridChange w:id="…"/> with id only, no author/date. Move tblPrExChange emission to per-row position.

Section emission lives in documentSerializer.ts / paragraphSerializer.ts (the spec file sectionSerializer.ts does not currently exist; this is an edit, not a creation, unless we refactor). Emit <w:sectPrChange> as the last child of <w:sectPr> in whichever placement (pPr/sectPr or body-level) the source used.

Suggesting-mode keymap

packages/core/src/prosemirror/plugins/suggestionMode.ts currently:

The existing Enter is splitBlockClearBorders in BaseKeymapExtension.ts:96-119,216,223 — it copies paragraph style attrs into the new paragraph and clears borders. The suggesting-mode Enter handler MUST compose with this, not replace it: call splitBlockClearBorders first, then post-process the resulting split to set pPrIns on the first paragraph. Otherwise paragraph-style inheritance regresses on every suggesting-mode split.

Plugin keymap fires before extension keymap; we use that ordering deliberately, but the plugin handler must invoke the extension command synchronously rather than dispatching independently.

New behavior:

  1. Enter at non-empty selection inside a paragraph — invoke splitBlockClearBorders, then in the same transaction set pPrIns: { revisionId, author, date } on the first of the two resulting paragraphs. Normative: the first paragraph (P1) receives the marker. The second (P2) is untouched.
  2. Enter while selection covers content — wrap covered content in deletion mark (existing behavior), then apply rule 1.
  3. Enter in an empty paragraph — same as rule 1; the new (empty) paragraph below gets nothing, the original (now also empty) carries pPrIns.
  4. Backspace at paragraph start (collapsed, non-first paragraph) — instead of joining, set pPrDel on the previous paragraph (whose mark is being eaten). The actual join is deferred to accept. The PM transaction is a pure attr update; the cursor lands at the end of the previous paragraph.
  5. Delete at paragraph end (collapsed, non-last paragraph) — symmetrical, set pPrDel on the current paragraph.
  6. Selection spans paragraph boundary then user presses any deletion key — wrap inline content with deletion, mark any fully-covered paragraph marks with pPrDel. Cursor lands at the original selection's from.
  7. Backspace at the first paragraph start — no-op (no previous paragraph to mark).
  8. Wrap commands for table operationsaddRow, deleteRow, addColumn, deleteColumn, mergeCells (vertical only — horizontal merge tracking is via per-cell ins/del), splitCells get suggesting-aware variants. These live in packages/core/src/prosemirror/extensions/nodes/TableExtension.ts.
  9. Paragraph-property commands — snapshot prior pPr on first edit per (id, author, date) session; on subsequent edits in the same session, do not overwrite the snapshot. After each edit, if all snapshotted fields equal current values, clear pPrChange.
  10. Run-property commands — snapshot prior rPr of each affected run as a revision_change mark; same session/clear-on-equal semantics.
  11. Table / section property commands — analogous to (9).

The snapshot-on-property-edit machinery is not an existing dispatcher; it's new infrastructure. Phase 1 lands it as a small withSuggestingSnapshot(commandImpl, snapshotter) helper in packages/core/src/prosemirror/plugins/suggestionMode.ts, applied at each property-command site.

Accept / Reject command surface

packages/core/src/prosemirror/commands/comments.ts houses the existing acceptChange(from, to) / rejectChange(from, to). We add:

Mechanics:

Resolution order in acceptAll / rejectAll: inline run-level → run-property marks → paragraph-property → paragraph-mark → cell-level → row-level → table-level → section-level. Inner-to-outer ensures structural revisions resolve after the content they contain.

Idempotence: acceptChangeById / rejectChangeById on a revisionId not found in the document return false (no-op). This includes double-accept of an already-resolved id.

Cross-revision dependencies: Rejecting a pPrIns on a paragraph that also carries pPrChange joins the paragraph with the next, destroying the host. The implementation MUST first reject the pPrChange (restore prior) if and only if the pPrIns reject would remove the paragraph. Otherwise inner revisions are preserved on the surviving paragraph.

Painter cues

packages/core/src/layout-painter/renderParagraph.ts and renderTable.ts are the canonical painter (per CLAUDE.md "Key file map"); both React and Vue inherit from this. Painter edits go here, not in packages/react/src/layout-painter/:

renderRun.ts (or wherever run-level rendering lives) — revision_change mark renders with class ep-revision-change (subtle wavy underline or background tint; exact CSS in editor.css).

packages/vue/src/composables/useDocxEditor.ts — verify it picks up painter output transparently (it should, via the shared layout-painter). The Vue adapter's only change for this feature is wiring the new data-revision-id events to the sidebar.

FlowBlock measurement

FlowBlock invariant holds — no new variants. ParagraphBlock.attrs (the contract consumed by hashParagraphBlock and measureBlock) needs the new revision-presence flags plumbed through toFlowBlocks. This is a small but easy-to-miss task: without it, the cache and measurement are inconsistent.

Review sidebar

packages/react/src/components/UnifiedSidebar.tsx and packages/react/src/hooks/useCommentSidebarItems.tsx already render both comments and tracked-change entries (TrackedChangeEntry). Extend TrackedChangeEntry to carry structural revisions and update extractTrackedChanges to walk node attrs in addition to text marks. Each entry shows:

The sidebar groups entries by the (id, author, date) triple (not bare id) so cross-author id collisions are not silently merged. Multi-site revisions (a row + its cells under one (id, author, date)) render as a single entry.

comments-sidebar.spec.ts is a required regression for every phase (CLAUDE.md lists it as gated on the files this change touches).

Vue parity

Painter changes land in packages/core/src/layout-painter/ and inherit into Vue. Suggestion-mode keymap is in packages/core/src/prosemirror/plugins/, framework-agnostic. The only Vue-specific work is sidebar wiring in packages/vue/src/, mirroring whatever the React sidebar does.

The parity contract (scripts/parity/parity.contract.json) is updated when acceptChangeById / rejectChangeById / acceptChangesInRange / rejectChangesInRange are added to the public DocxEditorRef.

Public API impact

New public exports on DocxEditorRef:

acceptChange / rejectChange / acceptAll / rejectAll signatures unchanged but behavior extended (additive).

Public @public types receiving new fields (per docs/api/docx-editor-core/prosemirror-schema.api.md): ParagraphAttrs, TableAttrs, TableRowAttrs, TableCellAttrs. Each new attr is a public-API addition. Snapshot regen via bun run api:extract after each PR.

Agents package (packages/agents/src/changes.ts): existing acceptChange(body, id: number) operates on the parsed Document model. Phase 1 extends it to handle structural-revision fields on Paragraph and the new cell/row revision fields, or explicitly defers with a tracking issue. The chosen course is documented in tasks.md.

Test plan

Per phase (see tasks.md):

Phasing

Three landable PRs. Phase 1 lands the snapshot-and-restore infrastructure even though it ships only paragraph-mark + section revisions, so Phases 2 and 3 are independent:

Open questions / risks

Key files

Layer File Change
Schema packages/core/src/prosemirror/schema/nodes.ts Add revision attrs to paragraph, table, table_row, table_cell
Schema packages/core/src/prosemirror/extensions/marks/TrackedChangeExtensions.ts Add revision_change mark (run only) + human descriptions
Parser packages/core/src/docx/paragraphParser/properties.ts Read pPr/rPr/ins, pPr/rPr/del, CT_ParaRPrChange
Parser packages/core/src/docx/documentParser.ts or sectionParser.ts if it exists Read sectPrChange (both pPr-level and body-level)
Parser packages/core/src/docx/tableParser.ts Wire existing table revision parses; add trPr/ins, trPr/del; relocate tblPrExChange to row
Cache packages/core/src/layout-bridge/measuring/cache.ts Add revision attrs to hashParagraphBlock / hashTableBlock
Conversion packages/core/src/prosemirror/conversion/toProseDoc/paragraph.ts Map paragraph revisions to attrs
Conversion packages/core/src/prosemirror/conversion/toProseDoc/table.ts Map table revisions to attrs
Conversion packages/core/src/prosemirror/conversion/toProseDoc/run.ts Map run rPrChange to mark with canonicalized prior
Conversion packages/core/src/prosemirror/conversion/fromProseDoc/* Inverse
Conversion packages/core/src/prosemirror/utils/extractTrackedChanges.ts Walk node attrs in addition to marks
Layout packages/core/src/layout-engine/toFlowBlocks* Plumb new attrs into ParagraphBlock.attrs
Serializer packages/core/src/docx/serializer/paragraphSerializer.ts Emit pPr/rPr/ins/del first; emit paraRPrChange last; enforce ordering
Serializer packages/core/src/docx/serializer/runSerializer.ts (or wherever) Enforce rPrChange last in rPr
Serializer packages/core/src/docx/tableSerializer.ts Emit row/cell/table revisions; cellMerge uses vMerge (no val); tblGridChange id-only
Serializer packages/core/src/docx/serializer/documentSerializer.ts (or paragraphSerializer.ts) Emit sectPrChange at both pPr-level and body-level placements
Plugin packages/core/src/prosemirror/plugins/suggestionMode.ts Block-boundary keymap composing with splitBlockClearBorders; snapshot-on-property-edit helper
Commands packages/core/src/prosemirror/commands/comments.ts Add acceptChangeById, rejectChangeById, acceptChangesInRange, rejectChangesInRange; extend accept/reject all
Commands packages/core/src/prosemirror/extensions/nodes/TableExtension.ts Suggesting-aware row/column/merge commands
Painter packages/core/src/layout-painter/renderParagraph.ts Pilcrow + change bar
Painter packages/core/src/layout-painter/renderTable.ts Row/cell cues; dashed cellMerge boundary
Painter packages/core/src/layout-painter/renderRun.ts (or equivalent) revision_change mark cue
Sidebar (React) packages/react/src/components/UnifiedSidebar.tsx and useCommentSidebarItems.tsx Group by (id, author, date) triple; structural revision items
Sidebar (Vue) packages/vue/src/components/UnifiedSidebar.vue (or equivalent) Mirror
Agents packages/agents/src/changes.ts Extend or defer (decision in tasks.md)
i18n packages/i18n/en.json + all 6 sibling locales 15 keys (stubs in Phase 1; values land per phase)
Parity scripts/parity/parity.contract.json New DocxEditorRef methods
API docs/api/*.api.md Snapshot regen per phase
Tests packages/core/src/docx/__tests__/fixtures/tracked-structural/ Per-marker fixtures
Tests packages/react/src/__tests__/playwright/tracked-changes-structural.spec.ts Per-phase end-to-end