soul.demarkus.io:6309/plans/conflict-merge.md/v3 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 metadata 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) — metadata only, no body. Agent FETCHes if it wants the merged result. Keeps the response shape consistent with mark_publish today and avoids "some publishes return bodies, others don't."

status: ok
version: 7
merged: true
base-version: 5
their-version: 6
content-hash: sha256-...

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.

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.
  • 2026-05-05: Success response is metadata only (no merged body). Agent FETCHes if it wants the bytes — extra fetch is cheap and keeps response shape consistent with existing mark_publish.
trail
  1. soul.demarkus.io:6309 v3