--- status: ready-for-implementation owner: claude-code --- # Plan: Conflict-Aware Merge in MCP Tools Reduce content loss under concurrent writes by giving `mark_publish` a tool-level merge strategy that produces a structurally-merged candidate body for the agent to semantically verify before publishing. ## Motivation Current `mark_publish` is strict optimistic concurrency: version mismatch → conflict error → caller re-fetches and republishes from scratch. Two failure modes at scale: 1. **Lost content** — naive callers re-publish their stale body, clobbering the intervening writer 2. **Wasted reasoning** — agents that conflicted on disjoint paragraphs still have to mentally re-apply their edits onto the new latest, even though a mechanical merge would have done the structural work Diff3 produces a structurally-correct merge candidate. The agent reviews it semantically (looking for duplicate bullets, contradictions, list reorder collisions that line-level merge cannot detect), refines if needed, and publishes. The agent stops doing structural work; the agent keeps owning semantic correctness. ## Non-Goals - **No DIFF verb.** Versions are immutable; clients can compute diffs locally from any two FETCHes. Adding it to the wire format pulls processing into the server with no protocol-level benefit. - **No server-side merge.** The server stays content-agnostic. Merge logic lives in the MCP tool (Go client code). - **No tool-side semantic merge.** The MCP tool has no LLM. Diff3 is mechanical (line-level). Semantic verification — duplicate bullet detection, contradiction reconciliation, marker resolution — is always the calling agent's job. - **No tool-side auto-publish after merge.** Earlier drafts of this plan auto-published clean diff3 results. That silently allowed semantic content loss (two agents adding the same idea as different bullets, etc.). The current design **always** returns the candidate to the agent, who publishes after verifying. Diff3's value is "skip the structural work", not "skip the LLM". - **No internal retry loop in the tool.** Each `mark_publish` call is one-shot: try the publish, return either success or a fresh merge candidate. Iteration lives in the agent's calling code (the agent already does fetch-modify-publish loops). - **No changes to `mark_append`.** Append is an end-of-doc stream primitive by design; auto-resolve handles its tiny race window. Two concurrent appends serialize cleanly because there is nothing to merge. ## Design ### New parameter: `on_conflict` Add an optional parameter to `mark_publish`: ``` on_conflict: "fail" | "merge" ``` - `"fail"` — current behavior. Default for backward compatibility. - `"merge"` — on conflict, return a diff3 merge candidate the agent verifies and republishes. ### Flow When `on_conflict: "merge"`: 1. Try **PUBLISH** with the agent's body and `expected_version`. 2. If success → return `status: ok` with version metadata. Done. 3. If version conflict: a. **FETCH** `/path/v{expected_version}` — retrieve the base body the agent edited from. (For `expected_version: 0`, base is empty — "create" conflicts are merged against an empty base.) b. **FETCH** `/path` — retrieve current body and current version. c. Run **diff3(base, ours, theirs)** to produce a candidate body. Conflict markers appear in the body wherever both sides changed the same lines differently. d. Return `status: merge-candidate` with the candidate body, `has-markers` flag, and `publish-at-version` (the current version the agent should target on the follow-up publish). ### Agent-side loop The agent owns iteration. Pseudocode: ``` loop: result = mark_publish(body, expected_version=N, on_conflict=merge) if result.status == "ok": done if result.status == "merge-candidate": body = semantic_review(result.body) # resolve markers, dedupe semantically, etc. N = result.publish_at_version continue ``` If the agent's follow-up publish itself conflicts (a fourth writer slipped in), that's just another `mark_publish` call returning a new candidate. No special contention handling — the agent decides when to give up or escalate. ### Conflict Marker Format Git-style markers in the body. LLMs handle this natively from training data; format lives entirely in `client/internal/merge/` Go code and can be swapped later without breaking anything. ``` some unchanged text <<<<<<< ours agent A's version of the line ======= agent B's version of the line >>>>>>> theirs more unchanged text ``` ### Response Shapes **First-try success** (`status: ok`) — unchanged from default `mark_publish` shape: ```yaml status: ok version: 7 modified: 2026-05-05T20:00:00Z content-hash: sha256-... ``` **Merge candidate** (`status: merge-candidate`): ```yaml status: merge-candidate your-version: 5 current-version: 6 publish-at-version: 6 has-markers: false base-version: 5 [diff3 candidate body, with or without markers] ``` Agent action: review the body. If `has-markers: true`, resolve them. Either way, publish with `expected_version: publish-at-version`. ### Wire-Level Impact Zero. `on_conflict` is an MCP tool parameter handled in the Go client. Server protocol unchanged. Versioned FETCH (`/path/vN`) already exists. ## Implementation ### Where the code lives - **MCP handler**: `client/cmd/demarkus-mcp/main.go`. Adds `on_conflict` parameter to the `mark_publish` tool schema and routes to a merge helper on the merge path. - **Merge package**: `client/internal/merge/` - `Diff3(base, ours, theirs string) Result` — pure function, easy to test - `MergeCandidate(client, path, ours, expectedVersion, meta) (Outcome, error)` — orchestrates publish-or-merge in one shot - **Diff3 algorithm**: implemented in-package as a line-based LCS + hunk walk. ~250 lines, no external dependency. Available libraries (`epiclabs-io/diff3`, `nasdf/diff3`) considered and rejected — adding a niche dep for a stable, well-understood algorithm doesn't pay for itself. ### Test coverage Table-driven tests in `client/internal/merge/`: - Disjoint paragraph edits → clean candidate - Same-line edits → candidate with markers - Adjacent line edits → clean candidate (verify diff3 doesn't over-conflict) - One side adds, other side deletes → documented behavior - Empty `ours` or `theirs` → handled gracefully - `MergeCandidate` first-try success → no diff3 invoked - `MergeCandidate` conflict path → returns candidate with correct versions Integration test (out of scope for the initial PR): two clients both edit the same doc in different sections with `on_conflict: "merge"`, both follow the agent loop, final doc contains both edits. ### Backward compatibility `on_conflict` defaults to `"fail"`. Existing callers see no behavior change. New callers opt in. ## Out of Scope (Future) - **`mark_update_section`** — heading-scoped writes that further reduce contention surface. Higher leverage than this plan but bigger change. Track separately. - **Server-side hot-path detection / batching window** — backpressure for sustained contention. Only build if contention shows up in practice. - **Soft leases** — agent reserves a path for N seconds. Fairness primitive, not a merge primitive. ## Success Criteria - Two agents editing different paragraphs of the same document with `on_conflict: "merge"` each receive a clean candidate, semantically verify, and successfully publish — final doc contains both edits. - Two agents editing the same line each receive a candidate with git-style markers in the body. - No change to existing callers using default `on_conflict: "fail"`. - `mark_append` untouched and behavior unchanged. - Diff3 helper has full branch coverage in unit tests. ## Decisions Log - **2026-05-05**: Conflict markers are git-style in body. Tool-side only, swappable, no protocol commitment. - **2026-05-05**: `mark_append` is out of scope. Append-as-stream is the right primitive there; auto-resolve already handles its race window. - **2026-05-05**: Tool never auto-publishes a diff3 result. Always returns the candidate to the agent for semantic verification. Earlier draft auto-published clean merges; rejected because line-disjoint ≠ semantically disjoint (duplicate bullets, contradictions, list reorder collisions slip through line-based merge). - **2026-05-05**: No internal retry loop in the tool. Each `mark_publish` call is one-shot. Iteration lives in the agent's natural fetch-modify-publish loop. Removes the contention-status escape valve and simplifies the tool. - **2026-05-05**: For `expected_version: 0` (create-only) conflicts, base is an empty string. Diff3 of (empty, ours, current) merges naturally — non-overlapping insertions both make it through, overlapping insertions get markers. - **2026-05-05**: Diff3 implemented in-package (no external library). Niche libraries exist but the project values minimal deps; diff3 is a stable, well-understood algorithm with full test coverage of our own.