Sync Conflicts

Real-time conflicts are rare and meaningful. When they happen, the picker is interactive and the unit is the function.

A sync conflict occurs when two peers edit the same function within the same 5-second sync window. Unlike a Git merge conflict — which is a text-level reconciliation performed after the fact — a sync conflict is a live, function-scoped, and usually small event. This page covers how sync conflicts arise, why they are structurally rarer than Git conflicts, how the interactive picker resolves them, and what the data model looks like underneath. The key insight: function-level granularity plus a short sync window means most "simultaneous" edits touch different functions and simply compose. The leftover real conflicts are few, and when they happen, the resolution is a choice between two coherent function bodies, not a three-way patch over scrambled text.

Overview

A sync conflict is the moment Live Sync notices: you edited function F, a peer also edited function F, and neither of you had seen the other's version when you saved. The conflict is raised on the peer whose push arrives second, because by the time the Mothership fans out the first push, the second peer's local version has already diverged.

The moment it is detected, the system does not apply the incoming body automatically. Instead:

  • The incoming body is staged in a sidecar file.
  • A conflict record is written to the WAL.
  • The user (or agent) is notified with an interactive picker.
  SYNC CONFLICT: billing::compute_tax

    local  (you, 14s ago)         remote (alice, 3s ago)
    ────────────────────────      ────────────────────────
    fn compute_tax(cart)          fn compute_tax(cart, region)
      let rate = 0.07;              let rate = table[region];
      cart.subtotal * rate          cart.subtotal * rate

  [l] keep local   [r] keep remote   [b] keep both (rename one)
  [e] edit merged  [d] defer         [s] show semantic diff

How sync conflicts differ from merge conflicts

| Property | Git merge conflict | Aura sync conflict | |---|---|---| | When detected | At merge / rebase time | Within seconds of the second save | | Unit | Line hunks inside a file | One function body | | Typical size | Tens to hundreds of lines | One function body (often dozens of lines) | | Context available | Two text snapshots + base | Two ASTs + base + author identity + timestamps | | Resolution tool | Text editor, 3-way diff | Interactive function picker with semantic diff | | Tests run after | Manually | Optionally auto-run affected tests | | Blast radius | The whole rebase may be wrong | Exactly one function |

The most important row is the last one. A Git merge conflict can hide a silent logic breakage in a neighboring function because text merge does not understand logic. A sync conflict is bounded to exactly the function being picked.

Why sync conflicts are rare

Three independent filters thin the conflict set before you ever see one:

  1. Function granularity. Most "simultaneous" edits touch different functions in the same file. Those compose without interaction.
  2. Short sync window. Within a 5-second window, the probability that two humans saved the same function is low. It happens more often when a human and an AI agent race on the same module, which is exactly the case Live Sync is designed to surface.
  3. AST normalization. If both sides made the "same" change — say, both reformatted the body or renamed an internal variable to the same name — the aura_id lands identically on both sides and no conflict is raised. This is the same normalization that makes semantic diff quiet.

The empirical conflict rate in internal dogfooding sits around 1.2% of pushes. The remaining 98.8% apply silently.

How It Works

The detection logic is a three-way compare at the AST level:

     base_hash     = last hash both peers agreed on
     local_hash    = my working copy hash
     remote_hash   = incoming hash

     if local_hash == base_hash && remote_hash != base_hash:
         APPLY remote silently
     if local_hash != base_hash && remote_hash == base_hash:
         NOP (my change will push shortly)
     if local_hash != base_hash && remote_hash != base_hash:
         CONFLICT
     if local_hash == remote_hash:
         NOP (we converged independently)

The base hash is tracked per-function in the WAL. When both local and remote diverge from base, the conflict ceremony begins.

The interactive picker

The picker is the human-facing resolution UI. In the terminal it is a curses-style TUI; inside Claude Code and other MCP clients it is a structured tool payload.

Six actions

  • l — keep local. Discard remote. The next push will propagate your version and overwrite alice's. Recorded as "decided against alice" so her side is informed.
  • r — keep remote. Discard local. Your working copy is updated to alice's version. Your pending push for this function is cancelled.
  • b — keep both. Aura renames one copy (usually the newer, with a numeric suffix) and keeps both in your working copy. You can then manually integrate.
  • e — edit merged. Opens your editor with a three-way view (local / remote / base). The resulting function is what ships.
  • d — defer. Staged, not applied. A sidecar file billing.rs.conflict.alice.<ts> is written and the conflict remains open until resolved. Useful when you want to think.
  • s — show semantic diff. Renders the AST-level diff instead of the textual one. See semantic diff.

Agent flow

AI agents should never silently pick — they should either resolve deterministically (if the right answer is obvious from the task) or defer and ask the human.

A typical Claude Code agent turn when a conflict is raised:

  1. aura_status  -> sees live_sync.conflicts = 1
  2. read the two bodies + base
  3. if my current task clearly produced local, keep local
  4. if the remote body supersedes my task (e.g. fixes a bug I was about to fix),
       keep remote and update my plan
  5. otherwise defer, message the other party via aura_msg_send,
       and continue with unrelated work

The MCP payload for a conflict looks like:

{
  "conflict_id": "conf-9b2e",
  "function": "billing::compute_tax",
  "base_hash": "4a21...",
  "local":  { "hash": "77cc...", "body": "...", "ts_ms": 1740000000000 },
  "remote": { "hash": "9b11...", "body": "...", "ts_ms": 1740000003000, "author": "alice" }
}

Config

[live.conflicts]
# How long a deferred conflict remains staged before auto-escalating.
defer_timeout_minutes = 60

# Auto-keep-remote if the incoming body passes all tests that touch this fn.
auto_accept_if_green = false

# Auto-keep-local if the incoming body is strictly smaller (likely deletion in error).
never_auto_accept_deletions = true

Gotcha: auto_accept_if_green sounds attractive. It is off by default because "passes the tests that touch this function" is not the same as "does what you intended." Enable only in repos with strong test coverage and strong intent logging.

Troubleshooting

Conflict keeps reappearing. You picked a side, but the other peer keeps pushing their version. They have not yet pulled your resolution. Ask them to aura live sync pull or wait for their next tick.

Staged conflict file piling up. Deferred conflicts write sidecars. Clean them with aura live sync cleanup after resolving.

"Ghost" conflicts where both sides look identical. Usually a base-hash mismatch — one peer has an older base pointer. Run aura live sync reconcile (see sync troubleshooting).

Worked example

Alice and Bob both open the billing module at 10:00. Alice is adding regional tax rates. Bob is adding cart-level discounts. Both touch compute_tax because the discount has to apply before the tax calculation.

  10:02:15  Alice saves:
              fn compute_tax(cart, region) -> Tax { let r = table[region]; cart.subtotal * r }

  10:02:19  Bob saves:
              fn compute_tax(cart) -> Tax { let r = 0.07; (cart.subtotal - cart.discount) * r }

  10:02:20  Alice's push reaches Mothership, fans to Bob.
            Bob's local has already diverged from base. CONFLICT.

The picker shows Bob both bodies with the base (the version both started from). Bob sees:

  • Alice added a region argument — a signature change he did not expect.
  • His own change is orthogonal (discount logic).
  • The right answer is to merge: take Alice's signature and his discount logic.

He picks e (edit merged) and produces:

fn compute_tax(cart: &Cart, region: Region) -> Tax {
    let r = table[region];
    (cart.subtotal - cart.discount) * r
}

Saves. His push goes out. Alice's next pull applies the merged version silently (her local matches the last body she pushed, so her side is considered the "remote" that converged). Both peers now agree on compute_tax, and the region-tax-plus-discount feature works end-to-end without a single PR round-trip.

Safety properties

A few properties worth calling out:

  • No silent loss. Even if you pick l (keep local), the remote body is retained in the WAL for audit. You can recover it with aura live wal show <record_id>.
  • Conflicts are idempotent. Re-running the same resolution produces the same result. This matters for AI agents that may retry.
  • Bounded blast radius. A conflict on compute_tax does not affect any other function. There is no "the whole file is in conflict" state.
  • History preserved. Both sides' versions are linked via the conflict record. Later review tools can reconstruct the decision.

Why not always auto-merge?

Aura's semantic merge can auto-merge many cases that Git cannot. Why does Live Sync not auto-apply these?

Because live is the wrong moment for it. Semantic merge is designed for the merge/rebase step, where both sides are committed units of work. During a live session, each peer's working copy is potentially half-done. Auto-merging a half-finished refactor with another half-finished refactor produces code neither party endorsed. Better to flag it and let a human (or agent) decide.

Merge at merge time; conflict at sync time. Different moments, different rules.

See Also