Skip to content

Example 20: Version control for a world model — fork, conflict, resolve, merge

Two people start from the same schema. One decides the SSN identifies a person; the other decides the email does. Both are right about their own half of the business, and a registry that stores versions in a line has nowhere to put that: the later write wins, silently, and the disagreement becomes a bug someone finds months later in the data.

A commit DAG puts it somewhere. Both branches are recorded, both replay from the base, and the history states plainly that it has two heads. Reconciling them is a separate, explicit act that produces a third commit naming both parents — and records how the disagreement was settled, so the same decision does not have to be made twice.

No database. A commit history is a fact about the contract, not about any deployment of it.

Prerequisites

  • Python 3.11+
  • GraFlo package (run from the example directory with uv run)
  • No database. Everything here is the contract plane.

The shape

                  78397faf  key people by email    ← head
                 /
97382d5d ───────┤                                    f26ae87f  merge
 created_at      \                                  /
                  f42a0133  key people by SSN  ─────┘  ← head

The shared commit matters: without a common ancestor there is nothing to merge against, and combining two unrelated lineages is merge, not merge.

Why each branch changes two things

Each branch re-keys the vertex and adds a property the other does not contest. That is not padding. If the identity were the only difference, taking one side would reproduce that side exactly, and build_multi_parent_commit would rightly refuse — a commit that moves nothing is a lie about history. With both, the merge is a real merge: one side's decision wins the contested slot, and both sides' uncontested work survives.

Slots

Reconciliation happens per slot — the addressable location an op touches. The two re-keys both land on vertex/person/identity, so they collide. The two property additions land on different field slots, so they merge with no question asked. That is the whole mechanism: conflicts are scoped to where the disagreement actually is.

A conflict carries both sides' ops and the ancestor's state, because "what did this look like before either change" is the question a resolver needs answered and the one a two-way diff cannot express.

Run it

No live graph database required.

cd examples/20-version-control
uv run python build_history.py              # → artifacts/commits/, two heads
uv run python build_history.py --show       # the log, without rewriting
uv run python merge_branches.py             # the conflict, resolved by taking left
uv run python merge_branches.py --take right
uv run python merge_branches.py --advance-left

The same history through the CLI:

uv run graflo log --store artifacts/commits
uv run graflo checkout --base manifest.yaml --store artifacts/commits <commit>
uv run graflo verify --base manifest.yaml --store artifacts/commits

Tracked merges

--advance-left is the flag worth understanding. It moves the left branch on (the vertex gains nickname) and then runs the recorded merge again:

replayed the recorded resolution:
  note: 1 recorded resolution(s) replayed: vertex/person/identity

merged (took left):
  identity  : ['ssn']
  properties: [..., 'ssn_verified', 'nickname', 'email_verified']

The identity decision is not asked again — it is replayed from the recipe, which is content-addressed with its resolutions hashed in slot order. Only genuinely new conflicts would surface. That is what keeps an overlay maintainable instead of a fork someone re-litigates every release.

A recorded resolution whose slot stops conflicting is reported as unused rather than force-applied: re-applying a stale decision to a slot nobody contested is how a re-merge quietly reverts someone's work.

What to look for

  • The fork is a recorded fact. build_history.py prints two heads. Nothing was dropped and nothing was overwritten to get there.
  • Both sides' uncontested work survives. The merged vertex carries ssn_verified and email_verified, whichever side won the identity.
  • The merge commit names both parents, and materializes its ops against the first — which is what lets verified replay treat it like any other commit.
  • Taking the other side changes the hash, not just the identity: content addressing is over the whole model.
  • The merged manifest is stamped with content_hash, canon, both parents and the recipe hash, so it is self-describing outside any store.

Seeing where they met

A conflict list says what; the slot tree says where. Slots are paths that contain one another, so vertex/person sits above the field edits inside it, and a rename colliding with three of them reads as one conflict about the vertex rather than three about fields that happen to live there.

uv run python merge_branches.py --plot-dir figs

The contested slot

The contested slot carries both branches' ops and the ancestor's state — the question a decision actually turns on, and the one a two-way diff cannot answer. Alongside it, the lineage, with first-parent edges solid because that is the line a commit's ops are a diff along, and the common ancestor marked:

The lineage

graflo merge --plot slots.svg --plot-history lineage.svg draws the same two from the shell. Both are written before the unresolved-conflict refusal, not instead of it.

Files

File Purpose
manifest.yaml The base world model: a person keyed by id.
build_history.py Records the shared commit and the two branches; prints the log. Importable and side-effect free.
merge_branches.py Finds the ancestor, shows the conflict, resolves it, records the merge commit, and replays the recipe under --advance-left.
artifacts/commits/ The recorded history, one YAML per commit.
  • Version control — content addressing, the commit DAG, slots, tracked merges
  • Manifest evolution — the op vocabulary a commit records
  • Vertex identity — what re-keying a vertex actually changes
  • Example 19 — merging two unrelated manifests by declared equivalence, which is the operation to reach for when there is no common ancestor
  • Example 17 — identity funnels, for when the right answer is "whichever key this record actually carries" rather than one side winning