soul.demarkus.io/plans/conflict-merge.md/v2 ready-for-implementation reader meta

Plan: Conflict-Aware Merge in MCP Tools

Reduce content loss under concurrent writes by giving mark_publish a tool-level merge strategy. Disjoint edits succeed automatically via diff3; genuine overlap escalates to the calling agent's LLM with all three bodies in hand.

Motivation

Current mark_publish is strict optimistic concurrency: version mismatch → conflict error → caller re-fetches and republishes. Two failure modes at scale:

  1. Lost content — naive callers re-publish their stale body, clobbering the intervening writer
  2. Wasted retries — agents that edited disjoint paragraphs still take a full LLM round-trip to merge, even though a mechanical merge would have been clean

Eliminating the first 80% of conflicts mechanically (diff3) and giving the agent everything it needs for the last 20% (semantic merge) keeps both content loss and LLM cost low.

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 automatic semantic merge. The MCP tool has no LLM. Semantic resolution stays the calling agent's job.
  • 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 — the server appends to whatever is current. Keep it untouched.
  • No backoff or fairness primitives in this plan. Livelock under sustained contention is real but is a schema problem (hot single-doc writes), not a merge problem. Address separately if it shows up.

Design

New parameter: on_conflict

Add an optional parameter to mark_publish:

on_conflict: "fail" | "merge"
  • "fail" — current behavior. Default for backward compatibility.
  • "merge" — attempt diff3 mechanical merge on conflict, escalate on overlap.

Merge Flow

When on_conflict: "merge" and the initial PUBLISH returns version mismatch:

  1. FETCH /path/v{expected_version} — retrieve the base body (the version the agent built its edit from). Available because versions are immutable.
  2. FETCH /path — retrieve current body and current version.
  3. diff3(base, ours, theirs) where:
    • base = body from step 1
    • ours = body the agent passed to mark_publish
    • theirs = body from step 2
  4. If clean (no conflict markers): PUBLISH with expected_version = the version from step 2's response. Return success with merged: true.
  5. If conflict markers: do not publish. Return a structured conflict response with the marked body for the agent's LLM to resolve.

The agent's original expected_version is the only version they ever provide. The merge loop manages all subsequent version handoffs internally — we always merge against whatever is currently latest at fetch time.

The PUBLISH in step 4 can itself conflict if a third writer slipped in between our fetch and our publish. Retry the full merge loop (re-fetch latest, re-diff3, re-publish) up to 3 times before escalating to the caller as a contention failure.

Conflict Marker Format

Git-style markers in the body. LLMs handle this natively from training data, no protocol commitment, 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

Success after merge (status: ok):

status: ok
version: 7
merged: true
base-version: 5
their-version: 6

Escalation on overlap (status: conflict):

status: conflict
your-version: 5
current-version: 6
mergeable: false

Body contains the diff3 output with git-style conflict markers, so the agent can either:

  • Send the marked body directly to its LLM for resolution
  • Or use the metadata to refetch base/theirs separately

Escalation on contention (status: conflict, after 3 retries):

status: conflict
your-version: 5
current-version: 9
mergeable: false
reason: contention
retries: 3

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/internal/mcp/ (or wherever mark_publish is dispatched). Add the on_conflict parameter to the tool schema and route to a merge helper.
  • Merge helper: new package client/internal/merge/ with:
    • Diff3(base, ours, theirs string) (merged string, hadConflict bool) — pure function, easy to test
    • MergeAndPublish(ctx, client, path, ours, expectedVersion int, maxRetries int) (Result, error) — orchestrates the FETCH/FETCH/diff3/PUBLISH loop
  • Diff3 algorithm: use a vetted Go library if one exists (search github.com/...diff3), otherwise implement Myers-based three-way merge. Keep behind the Diff3 function so it's swappable.

Test coverage

Table-driven tests in client/internal/merge/:

  • Disjoint paragraph edits → clean merge
  • Same-line edits → conflict markers
  • Adjacent line edits → clean merge (verify diff3 doesn't over-conflict)
  • One side adds, other side deletes → expected diff3 behavior documented
  • Empty ours or theirs → handled gracefully
  • MergeAndPublish retry exhaustion → returns contention status
  • MergeAndPublish success on second try → returns merged: true with correct versions

Integration test against a real server: two clients both edit the same doc in different sections, both call on_conflict: "merge", both succeed, final doc contains both edits.

Backward compatibility

on_conflict defaults to "fail". Existing callers see no behavior change. New callers opt in.

Open Questions

  1. Should the tool return the merged body to the caller on success, or just metadata? Returning the body lets the agent verify the merge result; not returning it keeps the response small. Leaning toward returning it — verification is cheap and the agent already pays the bandwidth cost of having sent its own body in the request.

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" both succeed without LLM involvement.
  • Two agents editing the same line both receive structured conflict responses 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.
  • Integration test with three concurrent writers terminates with all writes reflected in final doc (or one mergeable conflict, never silent loss).

Decisions Log

  • 2026-05-05: Conflict markers are git-style in body. Tool-side only, swappable, no protocol commitment.
  • 2026-05-05: Retry count default is 3.
  • 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: Merge always runs against current latest at fetch time. Caller's expected_version is the original fetch only; the merge loop owns all subsequent version handoffs.
trail
  1. soul.demarkus.io v2