Skip to content
C.W.K.
Lesson 01 of 05 · published

A Named Pair Remembers

~13 min · two-way-sync, archive, unison, deletion, init

Level 0Loose File
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Question a Mirror Cannot Answer

Track 6 moved bytes one way. delta sync mirrors a source onto a destination, and the rule for a file that exists on only one side is simple because one side is the truth: the source has it, so the destination gets it; the destination has it and the source does not, so with --delete it goes. Nobody asks why the two sides differ. The source is right by definition.

Two Macs that both edit the same folder break that rule on the first difference. Say a note exists on this Mac and not on the other. Two very different histories produce that exact picture:

  • Someone created it here, and it has not crossed yet. The right move is to copy it there.
  • Both Macs had it, and someone deleted it there. The right move is to delete it here.

Looking at the two trees cannot tell these apart. They are byte-for-byte the same state. A tool that guesses "new" brings every deleted file back on the next run; a tool that guesses "deleted" destroys every new file before it ever crosses. The missing input is not in either tree. It is what the two trees looked like the last time they agreed.

The Archive Is the Memory

Delta's answer is a named pair: op: pair, implemented in delta/pair.py. A pair has a name (letters, digits, _ . -, up to 64 characters), this Mac's folder as src, the other Mac's folder as dst, and an archive in this Mac's Delta state at ~/cwk-dbs/delta-db/pairs/<name>.json. The archive records every file both sides agreed on at the last run:

FieldWhat it remembers
kindfile or link
sizebytes at agreement
shathe SHA-256 both sides held (a link's is the hash of its target text)
targeta link's target
mt_here, mt_thereeach side's own mtime, in nanoseconds: two Macs never share one clock, so each side is compared with itself

Beside the files it keeps the folders both sides held, the pair's identity (here, there, exclude), and created / updated stamps. It is written the careful way: to a temporary file, mode 0600, then os.replace over the old one, so a crash leaves either the old archive or the new one, never half of each.

With the archive in hand, the ambiguous note resolves itself. In the archive and missing there: deleted there. Not in the archive and present here: new here. The two trees stop being a riddle once a third, older tree stands beside them.

Only init Writes the First Archive

A pair has three modes: init, sync, verify. The rule that makes the whole design safe is about which one may create an archive: only init, and only after proving the two trees equal. compare_trees lists both sides, compares path, kind and size, then hashes every remaining file on both Macs (the other Mac answers a batched hashes request, up to 1,000 paths each) and compares SHA-256. Any difference stops it with trees_differ and the list of paths: "only on this Mac", "only on that Mac", "differs". Nothing is recorded. Run init on a pair that already holds the same identity and it says so and does nothing.

Why so strict? Picture the tempting shortcut: sync finds no archive, so it writes a fresh one from whatever is there and carries on. Every file present on one side only would then read as new, and cross. If the archive was lost after a deletion, the deleted file comes back. So a sync with no archive refuses with archive_missing, and its message says exactly why: a fresh archive over unequal trees brings deleted files back. The fix is a person's step: compare the trees, settle the differences, run init.

The same caution covers a pair whose roots change. The archive stores the identity it was made for; a run that names other folders or other excludes stops with pair_changed instead of reading one tree's history against another tree.

Where the Rule Came From

The rule is older than Delta. The family's vault pair, two Macs keeping one folder of notes in step, used to run Unison every minute over SSH. When Delta was born on 2026-11-09, Dad was offered the choice of how that pair would continue, and picked the first option:

"1번" — 아빠
("Number one": keep the rule Unison applied, and carry it on our own service.)

So pair.py is not a new sync algorithm. It is Unison's well-tested behaviour (an archive of the last agreement, a conflict copy for the loser, central backups) re-implemented on Delta's routes, tickets and verified transfers, with SSH out of the data path. The commit that landed it the same day carried tests that run two real daemons through every rule. Today Claude Code's auto-memory rides it too: one named pair per project key between the workbench Macs, because 98 notes in 30 keys had drifted apart while the only path between them was a hand-run copy over SSH.

Code

The pair's gate in front of every sync (trimmed excerpt)·python
# delta/pair.py (trimmed excerpt): the gate in front of every sync
def run(runner, job) -> None:
    ...
    spec = job.spec
    name = spec["pair"]
    try:
        here, there = runner.parse(spec["src"]), runner.parse(spec["dst"])
        if not here.local or there.local:
            raise BadRequest("a pair is this Mac's path (src) and another Mac's path (dst)")
        ...
        route = runner.delta.routes.get(there.mac)
        ...
        job.skew_s = round(clock_skew(runner, there.mac), 3)    # every mode reports it; only sync refuses on it
        path = archive_path(runner.delta, name)
        archive = load_archive(path)
        mode = spec["mode"]
        if mode in ("init", "verify"):
            ...
            compare_trees(runner, job, here, there, spec, write=(path if mode == "init" else None))
            job.state = "done"
            return
        if archive is None:
            raise Stop("archive_missing", f"pair {name} has no archive: it never ran init, or the archive is gone. "
                                          "Nothing ran, because a fresh archive over unequal trees brings deleted "
                                          "files back; compare the trees and run init")
        if any(archive.get(k) != v for k, v in identity(spec).items()):
            raise Stop("pair_changed", f"pair {name} was initialized for {archive.get('here')} and "
                                       f"{archive.get('there')} (exclude {archive.get('exclude')}); this run names "
                                       f"{spec['src']} and {spec['dst']}: compare the trees and run init")
        skew = job.skew_s
        if abs(skew) > spec["max_skew_s"]:
            raise Stop("clock_skew", f"the clocks of {runner.delta.label} and {there.mac} differ by {skew:+.1f} s "
                                     f"(more than {spec['max_skew_s']:g} s): newer-wins would pick the wrong side, "
                                     "so nothing ran")
        workers = int(spec.get("concurrency") or WORKERS.get(route.kind, 3))
        sync(runner, job, here, there, spec, archive, path, workers)
        job.state = "done"
    except Stop as stop:
        job.state, job.cause, job.message = "failed", stop.cause, stop.message
        job.errors.append(f"{stop.cause}: {stop.message}")
Why a two-way sync needs a memory: the same trees, read with and without an archive·python
# why_archive.py: the same two trees, read with and without memory
here = {"notes/a.md": "v1", "notes/b.md": "v1"}            # this Mac
there = {"notes/a.md": "v1"}                                 # the other Mac
archive = {"notes/a.md": "v1", "notes/b.md": "v1"}           # what both agreed on last run


def without_memory(here, there):
    """A one-way mirror's view: a file on one side only must be new."""
    plan = []
    for rel in sorted(set(here) | set(there)):
        if rel in here and rel not in there:
            plan.append(("copy to there", rel))
        elif rel in there and rel not in here:
            plan.append(("copy to here", rel))
    return plan


def with_archive(here, there, archive):
    """The pair's view: the archive tells a deletion from a new file."""
    plan = []
    for rel in sorted(set(here) | set(there) | set(archive)):
        h, t, a = here.get(rel), there.get(rel), archive.get(rel)
        if h == t:
            continue
        if a is not None and t is None and h == a:
            plan.append(("delete here (deleted there)", rel))
        elif a is not None and h is None and t == a:
            plan.append(("delete there (deleted here)", rel))
        elif a is None and t is None:
            plan.append(("copy to there (new here)", rel))
        elif a is None and h is None:
            plan.append(("copy to here (new there)", rel))
        else:
            plan.append(("look closer", rel))
    return plan


print("no memory:   ", without_memory(here, there))
print("with archive:", with_archive(here, there, archive))

External links

Exercise

Run the memory demo, then extend it with one more case: a file present on both sides with different content and absent from the archive. Decide what the pair should do with it, write that branch, and explain why neither tree alone could have told you the file was created twice rather than edited twice.
Hint
Not in the archive and present on both sides means both sides created it independently. Equal bytes simply agree; different bytes are a conflict, the same as an edit on both sides. Print the archive lookup next to each decision so the role of the third tree stays visible.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.