core (0 internal deps)
↑
docx (imports core)
↑
cli (imports core, docx)
↑
lsp-server (imports core; imports cli for config parsing only)
↑
vscode-extension (LSP protocol to lsp-server; imports core/docx/cli for types only)
Build order: core → docx → cli → lsp-server → vscode-extension All packages use TypeScript with strict mode. Core emits both CJS (dist/) and ESM (dist-esm/).
Text input
│
▼
CriticMarkupParser.parse(text) → VirtualDocument { changes: ChangeNode[] }
│
▼
Operations (accept-reject, amend, supersede, navigation, tracking, comment)
│
▼
Renderers (settled-text, committed-text, sidecar views)
Key types:
- ChangeNode — parsed change with ChangeType, ChangeStatus, offsets, metadata
- ChangeType — Insertion | Deletion | Substitution | Highlight | Comment
- ChangeStatus — Proposed | Accepted | Rejected
- VirtualDocument — container for ChangeNode[], provides query methods
| Type | Syntax | Example |
|---|---|---|
| Insertion | {++text++} |
{++added text++} |
| Deletion | {--text--} |
{--removed text--} |
| Substitution | {~~old~>new~~} |
{~~before~>after~~} |
| Highlight | {==text==} |
{==highlighted==} |
| Comment | {>>text<<} |
{>>note<<} |
Highlights can attach comments with no whitespace: {==text==}{>>comment<<}
L2 (on-disk format): Inline CriticMarkup with footnote metadata. This is the
canonical, persisted format. All files on disk are L2. Footnotes ([^cn-N])
carry author, timestamp, status, and discussion metadata.
L3 (live editing projection): L2 with deterministic line anchoring. L3 exists
for editors like VS Code that don't handle interleaved delimiter characters well.
Changes are moved to footnote definitions with LINE:HASH {edit-op} anchoring,
and the document body contains clean text.
Key properties of L3:
- Never persisted to disk — exists only during active editing sessions
- Round-trip compatible: L2 → L3 → L2 must be lossless
- Deterministic: same L2 input always produces same L3 output
- Line anchoring uses xxhash of the clean body line content
Conversion: convertL2ToL3() in packages/core/src/operations/l2-to-l3.ts
Reverse: convertL3ToL2() in packages/core/src/operations/l3-to-l2.ts
Format detection: isL3Format() checks for FOOTNOTE_L3_EDIT_OP regex matches
(both in packages/core/src/footnote-patterns.ts). This is an O(n) scan — use
FormatService.getDetectedFormat() to centralize detection. Do not call isL3Format()
directly in hot paths.
L3 advantages for editing:
- Clean body — no interleaved CriticMarkup delimiters to confuse cursor navigation
- Stable anchors — LINE:HASH coordinates survive body edits via matching cascade
- Ghost decorations — deletions rendered as
::beforepseudo-elements, not inline markup
Projection is the organizing data-flow model for what content a port receives. It replaces ViewMode as the semantic model inside BaseController and ProjectionService.
Three projections (Projection type in packages/core/src/model/types.ts):
current— text as authored; accepted text in place, markup visibledecided— accepted changes resolved, rejected changes removedoriginal— strip all tracked changes, show original text
ViewMode (review / changes / settled / raw) is the display-layer vocabulary
that maps to Projection + DisplayOptions via VIEW_MODE_PRESETS in types.ts. ViewMode
remains the vocabulary for the LSP protocol and the VS Code command surface.
DisplayOptions (packages/core/src/host/types.ts) controls rendering within a
projection: delimiter visibility (delimiters: 'show' | 'hide'), per-change-type
visibility, author colors, cursor reveal, author/status/id filters.
ProjectionService (packages/core/src/host/projection-service.ts) computes and
caches projection results. Cache key is uri:version:projection:format:display.
ProjectionService.get(request) returns ProjectionResult with text,
visibleChanges, and a pre-built decorationPlan. Cache is invalidated on
document close (invalidate(uri)).
Six-level matching in findUniqueMatch() (packages/core/src/file-ops.ts):
- Exact —
text.indexOf(target)with uniqueness check - Ref-transparent — Strips
[^cn-N]footnote refs from both haystack and needle - Normalized — NFKC unicode normalization
- Whitespace-collapsed — All whitespace runs → single space
- Committed-text — Strips pending proposals (accepted changes stay)
- Settled-text — Strips all CriticMarkup, expands match to cover constructs
Each level is tried only if the previous fails. Returns UniqueMatch with index,
length, original text, and flags indicating which level matched.
Critical invariant: never silently normalize confusables (ADR-022/061). The cascade is diagnostic — it tells you which level matched, it doesn't silently transform input.
The core uses a ports-and-adapters (hexagonal) pattern defined in packages/core/src/host/.
┌─────────────────────────────────────────────────────────┐
│ Platform Host │
│ (VS Code extension, website-v2, future hosts) │
└──────┬──────────────────────────────────────┬───────────┘
│ inbound │ outbound
┌──────▼──────────┐ ┌────────────────▼───────────┐
│ EditorHost │ │ DecorationPort │
│ (platform → │ │ PreviewPort │
│ controller) │ │ (controller → platform) │
└──────┬──────────┘ └────────────────────────────┘
│
┌──────▼──────────────────────────────────────────────────┐
│ Core Services │
│ DocumentStateManager · DecorationScheduler │
│ TrackingService · ReviewService │
│ NavigationService · CoherenceService │
│ FormatService · ProjectionService │
└──────┬──────────────────────────────────────────────────┘
│ service dependency
┌──────▼──────────┐
│ LspConnection │
│ (typed LSP I/O) │
└─────────────────┘
Inbound port — EditorHost: Platform adapter that feeds editor events (text
changes, cursor moves, config changes) into the controller. VS Code implements this;
website-v2 implements WebsiteEditorHost.
Outbound ports — DecorationPort, PreviewPort: Controller pushes decoration
plans and preview HTML through these. Each platform provides its own adapter (e.g.,
WebDecorationAdapter, WebPreviewAdapter in website-v2).
Core services (packages/core/src/host/services/): TrackingService,
ReviewService, NavigationService — platform-agnostic business logic that any
host can compose.
Host adapters in practice:
- VS Code extension —
BaseController+VsCodeEditorHost+VsCodeDecorationAdapter - website-v2 — reference implementation;
WebsiteControllercomposes all ports and services - Future hosts (Sublime, Neovim) — implement EditorHost + outbound port adapters
BaseController (packages/core/src/host/base-controller.ts) is implemented and
in use by all current hosts.
Key design:
- Composition, not inheritance — hosts pass
ControllerConfig, no subclassing required - LSP is optional —
lsp?: TypedLspConnection; omit for standalone mode (usesNULL_LSP_CONNECTION) - FormatAdapter is required —
formatAdapter: FormatAdapteris the only mandatory pluggable dep ControllerHooks— lifecycle callbacks (onWillOpenDocument,onDidCrystallize, etc.)
setViewMode() is a @deprecated facade on BaseController. Use setProjection() +
setDisplay() for new code.
TypedLspConnection (packages/core/src/host/types.ts) is the typed interface that
BaseController and services consume. Platform adapters wrap their native LSP client
to implement it. Includes convertFormat(uri, text, targetFormat) for LSP-mediated
format conversion.
DocumentUri (packages/core/src/host/uri.ts) is a branded string type. All
per-document Maps in BaseController use UriMap<T> (keyed by DocumentUri) to
prevent raw string/URI confusion.
Controller (packages/vscode-extension/src/controller.ts, ~1,130 lines):
State machine managing tracking mode, view mode, edit boundary detection, and
cursor position. Decomposed from ~2,750 lines via extraction of 8 managers into
core services and the hexagonal port layer. See packages/vscode-extension/AGENTS.md
for the full state field inventory and event handler chain.
Key files by role:
extension.ts— entry point, registers commands and activates controllercontroller.ts— state machine: tracking, view mode, events, pending editslsp-client.ts— LSP connection, notification handlers, decoration cachedecorator.ts— 17TextEditorDecorationTypeinstances, applies decoration plansreview-panel.ts— webview panel with accept/reject controls and discussion threads
Key state groups:
- Tracking & view —
_trackingMode,_viewMode,_showDelimiters - Document shadow —
documentShadow(Map<uri, string>) for deletion detection - Edit boundary —
pendingEditManagerwraps coreEditBoundaryState - Projected view —
projectedViewmanages buffer swap for settled/raw modes - Per-document —
convertingUris,nextScIdMap,userTrackingOverrides,documentStates - Cursor —
lastCursorOffsets,cursorPositionSender(for CodeLens)
Core services consumed by the controller: DocumentStateManager, DecorationScheduler,
TrackingService, ReviewService, NavigationService — all from packages/core/src/host/.
L3 is an in-memory projection that exists only during active editing. The LSP server owns promotion (L2→L3) and the extension/application owns demotion (L3→L2 on save).
Automatic on file open if the document has tracked changes.
File opens in VS Code
↓
LSP onDidOpen → parse L2, find changes
↓
convertL2ToL3(text) → L3 text with LINE:HASH anchors
↓
Parse L3 → cache, send decorationData (pre-cache for instant render)
↓
Send promotionStarting notification → extension sets convertingUris guard
↓
workspace.applyEdit() → replace buffer with L3
↓
promotingUris guard suppresses echo re-parse
↓
Send promotionComplete → extension clears guard, refreshes decorations
Guards:
promotingUris(LSP) — suppresses re-parse of the echo didChangebatchEditUris(LSP) — suppresses re-promotion during multi-file batch opssuppressRepromotionAfterDiskRevert(LSP) — prevents re-promoting after "Don't Save" closeconvertingUris(extension) — suppresses tracking during promotion/demotion
Not automatic — the application is responsible for calling convertL3ToL2() before
writing to disk. In the extension, this happens in onWillSaveTextDocument.
User saves (Ctrl+S)
↓
Extension flushes pending edits
↓
convertL3ToL2(L3text) → L2 with inline CriticMarkup restored
↓
WorkspaceEdit replaces buffer with L2 (convertingUris guard active)
↓
File written to disk as L2
L2 on disk:
The team {++new ++}[^cn-1]prototype last week.
[^cn-1]: @alice | 2026-03-16 | ins | proposed
L3 in memory:
The team new prototype last week.
[^cn-1]: @alice | 2026-03-16 | ins | proposed
1:a3f {++new ++}
Body is clean (no delimiters, no refs). Each footnote's first body line is
LINE:HASH {edit-op} where LINE is 1-indexed and HASH is xxhash of the
clean body line. The matching cascade (findUniqueMatch()) re-locates changes
during L3→L2 conversion even if the body has been edited.
L2 → L3 → L2 must be lossless. This is enforced by:
- All metadata lives in footnote headers (preserved verbatim)
- Discussion lines preserved as continuation lines
findUniqueMatch()6-level cascade re-locates changes in the body- Status determines body text state (accepted insertions stay, rejected removed)
End-to-end trace from user action to rendered result.
User: Command palette / CodeLens / Review Panel → Accept or Reject
↓
Extension: acceptChangeAtCursor() → optional QuickPick for reason
↓
Extension: sendLifecycleRequest('changedown/reviewChange', {
uri, changeId, decision, reason
})
↓
LSP: handleReviewChange() → getDocumentText(uri)
↓
Core: applyReview(text, changeId, decision, reason, author)
├─ Find footnote block for changeId
├─ Insert review line: " approved: @author date "reason""
├─ Update footnote header status (proposed → accepted/rejected)
├─ Cascade to children if grouped change
└─ Return updatedContent
↓
LSP: optional auto-settle (applyAcceptedChanges / applyRejectedChanges)
↓
LSP: return fullDocumentEdit → extension applies via workspace.applyEdit()
↓
LSP: re-parse on didChange → sendDecorationData → extension refreshes decorations
Bulk operations (reviewAll): sorted in reverse document order (highest offset
first) to prevent offset invalidation. Single auto-settle pass at the end.
Key detail: The primary accept/reject path uses applyReview() (footnote-level
metadata manipulation), NOT computeAccept/Reject() (low-level text edit primitives
used by settled-text rendering).
Operations return OperationResult (packages/core/src/host/types.ts):
interface OperationResult {
requiredEdits: readonly StructuredEdit[]; // ALL must be applied atomically
resultingProjection: ProjectionResult;
affectedChangeIds: readonly string[];
sourceVersion: number;
}
interface StructuredEdit {
edit: RangeEdit;
region: 'body' | 'footnote' | 'footnote-definition';
role?: 'insertion' | 'deletion' | 'anchor' | 'metadata';
changeId?: string;
}All edits in requiredEdits must be applied atomically for document coherence.
Partial application leaves the document in an inconsistent state.
The edit boundary groups rapid keystrokes into single tracked changes.
User types character
↓
onDidChangeTextDocument fires
↓
Selection-confirmation gate:
Deletions auto-confirm
Insertions/substitutions → queue unconfirmedTrackedEdit, 50ms timeout
↓
onDidChangeTextEditorSelection fires (1-5ms later)
Confirms pending edit → handleTrackedEdits()
↓
PendingEditManager.handleEdit() → core processEvent()
Returns effects: updatePendingOverlay | crystallize | mergeAdjacent
↓
crystallize: wrap text in {++...++}, {--...--}, or {~~...~~}
Apply edit to document, emit footnote (L3)
Crystallization flow: PendingEditManager (packages/core/src/host/pending-edit-manager.ts)
wraps the core state machine. processEvent(state, event) returns effects: crystallize
(wrap in CriticMarkup + emit footnote), mergeAdjacent (extend existing change), or
updatePendingOverlay (send preview to extension). On crystallization, the server
sends a pendingEditFlushed notification and the extension applies the edit.
Flush triggers:
- Cursor moves outside pending range (
shouldFlushOnCursorMove) - Safety-net timer exceeds
pauseThresholdMs(default 30s, 0 = disabled) - Document save
- Tracking mode toggled off (abandons pending, does not crystallize)
- Manual flush via
changedown/flushPendingnotification - Explicit request from user or agent
Six rules that must hold at all times for format-aware document processing:
- Format detection on open — call
FormatService.getDetectedFormat()on everyonDidOpenDocument - Format-aware parsing — use
parseForFormat()which selects L2 vs L3 parser; never hardcode parser - Projection reflects current format —
ProjectionSelector.formatmust matchDocumentState.format - No stale format cache —
FormatService.remove(uri)on document close; detect on reopen - PEM uses format-aware crystallization —
PendingEditManagercontext must carry correctdocumentFormat - Format re-detect on large changes — if
totalChangeLength > text.length * 0.5, re-run format detection (BaseController enforces this inhandleContentChange)
The LSP server and extension maintain synchronized state via notifications.
| Notification | Payload | Trigger |
|---|---|---|
decorationData |
ChangeNode[] |
parse complete (debounced 60ms) |
changeCount |
counts by type | same as decorationData |
allChangesResolved |
uri | when total changes = 0 |
documentState |
tracking + viewMode | doc open, header change, config change |
viewModeChanged |
uri + viewMode | view mode confirmation |
pendingEditFlushed |
uri + range + newText | pending edit crystallizes |
promotionStarting |
uri | before L2→L3 buffer replace |
promotionComplete |
uri | after L2→L3 success or failure |
| Notification | Payload | Purpose |
|---|---|---|
trackingEvent |
type + offset + text | route to pending edit manager |
batchEditStart / batchEditEnd |
uri | suppress re-promotion during batch |
flushPending |
uri | hard break: crystallize pending |
updateSettings |
reviewerIdentity | update attribution |
pendingOverlay |
uri + overlay | in-flight insertion preview |
setViewMode |
uri + viewMode | view mode change |
cursorPosition |
uri + line + changeId | cursor-gated CodeLens |
setCodeLensMode |
mode | user preference (cursor/always/off) |
| Request | Purpose | Core function |
|---|---|---|
getChanges |
fetch parsed ChangeNode[] | getMergedChanges |
reviewChange |
accept/reject one change | applyReview |
reviewAll |
bulk accept/reject | applyReview (loop) |
amendChange |
modify change text | computeAmendEdits |
supersedeChange |
replace change | computeSupersedeResult |
replyToThread |
add discussion comment | computeReplyEdit |
resolveThread / unresolveThread |
thread resolution | computeResolutionEdit |
compactChange |
compact change level | compactToLevel1/0 |
annotate |
git-based annotation | annotateMarkdown |
getProjectConfig |
read config | project config state |
convertFormat |
L2↔L3 conversion | FormatService.promoteToL3/demoteToL2 |
Core (packages/core/src/host/decorations/) owns plan building; platforms own rendering.
LSP server: parse → ChangeNode[] → sendDecorationData notification
↓
Extension lsp-client: cache in decorationCache Map
↓
Controller: scheduleDecorationUpdate (50ms debounce)
↓
Core: buildDecorationPlan(changes, viewMode, text, showDelimiters)
→ DecorationPlan with offset ranges for each decoration kind
↓
Core: applyPlan(target: DecorationTarget, plan)
DecorationTarget is per-editor — VS Code wraps TextEditor, website wraps DOM
↓
Platform adapter: editor.setDecorations() or DOM class updates
VIEW_MODE_VISIBILITY constant (packages/core/src/host/decorations/styles.ts)
drives which decoration kinds are visible in each view mode.
View modes:
- review — full CriticMarkup visible with type coloring
- changes (simple) — delimiters hidden, cursor-reveal on hover
- settled — projected view, accepted text only, read-only buffer
- raw — projected view, original text only, read-only buffer
Ghost decorations (L3 only): deletions rendered as ::before pseudo-elements with
strikethrough styling. The editor body shows clean text; deleted content appears as
translucent ghost text at the deletion point.
Two bin entries from packages/cli (changedown npm package):
cdown— main agent + user CLI; routes to git diff driver / user commands / agent commandschangedown— init wizard only (changedown init)
Three-path routing in cdown: git diff driver (7-arg detection) → user commands (Commander, status|list|diff|…) → agent commands (runAgentCommands() → runCommand()).
Engine layer (packages/cli/src/engine/, exported as changedown/engine) is the shared contract consumed by both cdown and the MCP server. Key components:
-
Handler signature contract: All 16 engine handlers share:
(args: Record<string, unknown>, resolver: ConfigResolver, state: SessionState) => Promise<{ content: [...]; isError?: boolean }>This is the MCP tool result format. The CLI wraps it viahandlerToCliResult(). Adding a new operation: write handler → export fromengine/index.ts→ add toagent-command-registry.ts→ add to MCP server'sCallToolRequestSchema. -
ConfigResolver— Session-scoped, lazy per-file config loader. Walks up to.changedown/config.toml, caches by project root, file-watches for live reload. One instance per MCP stdio session; one percdowninvocation (disposed after viaresolver.dispose()). -
SessionState— Per-session ID counter and hash registry. Tracksct-Nallocation per file, manages change groups, records per-line hashes for staleness detection. -
Protocol mode (
classicvscompact) is read from.changedown/config.tomlviaresolveProtocolMode().getListedToolsWithConfig()selects betweenclassicProposeChangeSchema(old_text/new_text) andcompactProposeChangeSchema(LINE:HASH + CriticMarkup op) at tool-list time. The MCP client sees a differentpropose_changeschema depending on the project's config.
The 6-tool MCP surface (engine/listed-tools.ts): read_tracked_file, propose_change, review_changes, amend_change, list_changes, supersede_change. Additional backward-compat handlers exist (raw_edit, propose_batch, respond_to_thread, etc.) but are not in the listed surface.
LSP server CLI import is narrow: only parseConfigToml and DEFAULT_CONFIG from changedown/config. The LSP server does not use engine handlers — all change operations go through @changedown/core directly.
Two coordinate systems coexist:
- RangeEdit — LSP native, 0-indexed line/character pairs. Used by the LSP
protocol, VS Code APIs, and editor-facing code. Carried in
TextEditobjects. - OffsetEdit / OffsetContentChange — Byte offsets into the document string. Used by the core parser, operations, and the matching cascade.
Conversion: transformRange() in packages/core/src/host/range-transform.ts
converts between the two.
These must remain true across all changes:
- Parser is single-pass O(n). No multiple passes.
- Status fallback:
node.metadata?.status ?? node.inlineMetadata?.status ?? node.status - No silent confusable normalization. Diagnostic detection only.
- L2 → L3 → L2 round-trip is lossless.
hiddenObjdecorator usestextDecoration: 'none; display: none;'— load-bearing CSS.- Edit boundary:
pauseThresholdMs=0means "disable timer" (core guard checks> 0). - Extension communicates with core through LSP, not direct import, for change operations.
isL3Format()is O(n). UseFormatService.getDetectedFormat()to centralize detection.- All
OperationResult.requiredEditsmust be applied atomically.
These invariants govern concurrent use of the MCP server by multiple AI agents operating on the same document or session simultaneously. They apply to the file-backend path exercised by MCP tool handlers; the same contracts hold for the Word (StreamableHTTP) backend because all writes flow through the same engine handlers.
Changes are recorded in arrival order: each successful propose_change call
receives the next available cn-N ID from SessionState.getNextId(). Because
individual tool calls are processed serially within a single MCP stdio session,
the cn-N sequence is a total order on proposal arrival time. Concurrent agents
sharing one session see the same document state after each write; agents on
separate sessions observe whichever state was written to disk when they issue
their next read_tracked_file.
File-backend subscriptions (and resources/subscribe for Word sessions) fire a
document_changed (or notifications/resources/updated) event after each
propose_change write completes. Notifications are debounced by 50ms (the ReconcileScheduler default) to
coalesce rapid writes from the same agent. A subscribing agent is guaranteed to
observe the notification before issuing its next read, provided it waits for the
debounce window to flush.
When two agents propose a change targeting the same old_text:
- Same author — the engine auto-supersedes the earlier proposal: the old
change is rejected in-memory, its markup is removed, and the new change lands.
The success response includes a
superseded: [cn-N, ...]array naming the retired IDs. - Different authors — the engine throws the overlap guard
(
resolveOverlapWithAuthor→guardOverlap), returning anisError: trueresult to the second agent. The first agent's change is preserved intact on disk.
In neither case is the first-arrived change silently overwritten. The invariant is: no proposal is ever lost without an explicit audit trail (supersede record or error return).
When propose_change or review_changes is called, the author identity is
resolved through a five-tier precedence chain:
- Explicit
authorargument — highest priority; always wins when present clientInfoheader — MCP initialize request;synthesizeAuthorFromClientInfo()maps client name toai:<id>CHANGEDOWN_AUTHORenvironment variable — set in MCP server config (e.g. Cursormcp.json)config.author.defaultin.changedown/config.toml"unknown"— system fallback when all other sources are absent
Author strings must match /^[a-z][a-z0-9]*:[a-zA-Z0-9_.-]+$/ (e.g.
ai:claude-opus-4-6, human:alice). The "unknown" fallback is exempt from
format validation.
By default, any agent may review any change regardless of who authored it.
The review_changes handler resolves the reviewer's author identity and writes a
review line to the footnote, but it does not check whether the reviewer matches
the original proposer. There is no author-mismatch error in the current
implementation.
Open Question #1 (deferred): A future
config.review.may_review_own_onlyflag could restrict agents to reviewing only their own changes. This deferred variant is not implemented; all current callers rely on the PERMISSIVE behavior documented here.
When a subscribed session's send queue exceeds maxQueuedNotificationsPerSession
(default: configurable in SubscriptionManager), the oldest queued
notification is dropped to make room for the newest. The session is resumed
when its consumer drains the queue. A onDrop callback can be registered for
observability. Notifications are never blocked synchronously — the tool call that
triggers the fan-out always completes, regardless of how slow subscribers are.
ChangeNode carries two boolean fields that govern position safety:
anchored: boolean— true when a[^cn-N]footnote ref exists in the file for this node. Set by both parsers. Always true for L3 nodes by construction (they are created from footnotes). Always meaningful for L0/L1/L2 inline nodes (false means the node has no identity link).resolved: boolean— true when the node's position was deterministically located during parsing. Set only byFootnoteNativeParser(L3). When the op text search fails,resolved: falseis set with a sentinel range{0,0}. Callers must checkresolvedbefore using a node's offsets.
Prior to this split, FootnoteNativeParser expressed resolution failure by setting anchored: false. This dual use of one field (documented in docs/findings/2026-03-17-anchored-dual-semantics.md) required consumers to guard with anchored === false && level >= 2. The split removes the compound guard: all mutation consumers now check resolved === false directly.
See: ADR-062 (docs/decisions/062-anchored-resolved-split-and-zombie-elimination.md)
assertResolved(doc: Document) is called at every mutation site before any byte-splice:
- Settlement operations (
packages/core/src/operations/settlement.ts) - MCP handlers (
packages/cli/src/mcp/handlers.ts) - LSP server document-mutation paths
- Host services write-back
- DOCX export (
packages/docx/src/export.ts) - CLI commands that mutate file content
If the document contains any ChangeNode with resolved: false, assertResolved throws UnresolvedChangesError carrying the full Diagnostic[] array (per the ADR-034 failure taxonomy). The error includes structured evidence fields so agents can identify exactly which changes are unresolved before retrying (ADR-061 informed-retry principle).
The chokepoint is guarded by the feature flag CHANGEDOWN_ASSERT_RESOLVED (default on since Tranche 4). The flag and its opt-out path are scheduled for removal in Tranche 10.
All byte-writes to tracked files go through writeTrackedFile(path, doc, fs). Before calling fs.writeFile, the function runs validateStructuralIntegrity(doc):
- No nested markup (ADR-028 no-nesting, ADR-049 §3 no-stacking)
- No orphaned footnote refs (unmatched refs or footnote blocks)
- No parser-emitted diagnostics of blocking severity
If validation fails, the function throws without touching the file. The existing content is left intact.
Direct calls to fs.writeFile / fs.promises.writeFile on tracked-file paths are prohibited by the ESLint rule no-direct-tracked-file-write (Tranche 5). This prevents regressions: the rule fires if any fs import is followed by a writeFile call with a path passing through the tracked-file registry.
See: ADR-062, Tranche 5 implementation.
propose_batch is atomic by default: either all changes succeed or none are applied. Callers that need partial application must opt in with { partial: true }.
This restores the intent of ADR-036 §4, which specified atomic-default but shipped with the default inverted (partial-by-default). The upstream defect was an original path enabling partially-written batches to leave anchored: false changes silently in documents.
Migration: Callers that relied on partial-by-default behavior (old default) must add partial: true to their batch request. Callers that did not depend on partial behavior are unaffected.
See: ADR-036 §4, ADR-062.