Version control for world models¶
A schema is a world model, and manifests are its provenance. This page covers what GraFlo records about a manifest's history: how a manifest gets a content address, how change sets become commits, and how two lines of change are reconciled.
The model is a git log, not an Alembic script. Alembic's core abstraction is
a reversible upgrade() / downgrade() pair, and GraFlo cannot honour that:
merge_vertices discards which source each property came from,
change_field_types discards the previous type, sanitize and
project_manifest drop material outright. A downgrade that quietly produces a
different manifest is worse than none. So history moves forward, and going
back means replaying from the base.
Nothing here touches a database. A history is a fact about the contract; see Migration and practices for the database-facing plane.
Content addressing¶
Two manifests that describe the same world model must hash equal.
from graflo.architecture.evolution import manifest_hash
manifest_hash(a) == manifest_hash(b) # same model, however each was reached
to_minimal_canonical_dict() already normalizes defaults, None, aliases and
key order. What it does not normalize is list order — and most lists in the
contract are declaration order over a set, so two identical schemas authored in
different order, or one authored and one replayed, hashed differently.
canonical_payload adds exactly that normalization.
Sorted or preserved¶
Which lists may be sorted is a per-field decision, recorded in LIST_ORDER
(architecture/evolution/canonicalize.py) as a total classification of every
sequence field reachable from GraphManifest.
The two mistakes are not symmetric. Marking an order-significant list
SORTED makes two different models hash equal, and nothing downstream can
detect it. Marking an order-insignificant one PRESERVED is a missed dedup —
visible and harmless. So doubt resolves to PRESERVED, and every SORTED entry
carries the reason order does not matter there.
| Sorted | Preserved |
|---|---|
| vertices, edges, properties, secondary identities | resource pipelines (an ordered program) |
| resource and transform registries, bindings entries | Vertex.identity — a backend addresses an endpoint through the first identity field |
| index sets, edge specs | compound-index columns |
semantic exact_match / synonyms |
identity funnel branches (first firing branch wins) |
| selector and membership sets | transform argument tuples, join and projection order, filter operands |
Sorting is by each element's canonical JSON rendering rather than a per-field key: total over heterogeneous unions, no tie-break rule, and no way for the result to depend on input order. Nothing reads that order — it is hash-side only, and authored YAML keeps its declaration order.
Reaching an unclassified sequence raises UnclassifiedListField rather than
guessing, and CANON_VERSION is mixed into the hashed bytes so a future change
to these rules produces different hashes by construction instead of silently
reinterpreting old ones.
Provenance¶
The content address and lineage travel with the artifact, so a shipped manifest is self-describing outside any registry.
metadata:
provenance:
content_hash: "…64 hex…"
canon: graflo/canon@3
parents: [a3f9c21e4b70, 9e11d02c55aa]
commit: c4d1e9a2b3f0
merge_recipe: "…"
Provenance is never part of the content hash. That exclusion is the definition, not an optimization: content identity must be path independent, so two routes reaching the same world model agree that they did. A hash covering the parents would make identity depend on history, and dedup could never fire. The role of a hash-that-covers-ancestry is played by the commit id, exactly as in git.
Stamping is explicit — stamp_provenance(...) at a commit point, never
something apply_evolution does. Applying the same ops twice must not produce
two artifacts that disagree about their own lineage.
Commits¶
from graflo.architecture.evolution import build_commit, History, checkout
first = build_commit(base, ops, label="add email")
second = build_commit(after_first, more_ops, parents=[first.id], label="rekey")
history = History(commits=[first, second])
restored = checkout(base, history) # replays, verifying every tree
as_of_first = checkout(base, history, first.id)
A Commit carries its ops, its parents (empty for a root, one for an edit,
two or more for a merge or merge3) and the content hash before and after it.
build_commit applies the ops rather than trusting them, so both trees describe
a transition that actually happened, and it refuses a change set that leaves the
manifest unchanged — a commit that moves nothing is a lie about history.
Commit ids are content-derived from the ops and the parent order, so regenerating the same change set yields the same id rather than a duplicate under a new name.
Forks are recorded facts¶
Two commits may share a parent. History validates what is genuinely broken —
duplicate ids, a parent that does not exist, a cycle, a first-parent edge whose
trees do not line up — and represents everything else, including multiple heads.
Two people evolving the same version is a thing that happens. A history that refuses to represent it is not a record of what happened.
history.heads() # more than one means it has forked
history.linearize() # raises when there is no single path
history.topological() # always available, deterministic on ties
Merge commits are materialized¶
A merge commit's ops are the diff from its first parent to the merged
result — not an interleaving of both sides. That single decision keeps
everything else simple: first-parent replay and hash verification work
identically for edit and merge commits, so nothing downstream needs a special
case. The declarative record of how the merge was resolved rides alongside as
a recipe.
Undoing¶
History is append-only, so undoing a change moves forward:
build_revert_commit records a new commit applying the inverses. Inversion is
exact or it fails — an op with no total inverse, or one whose inverse needs data
the current manifest no longer holds, raises rather than producing a manifest
that merely resembles the earlier state. When the base is available, checking
out the parent commit is always exact and is the better tool.
| Reversible | Irreversible |
|---|---|
| add ↔ remove: vertices, edges, vertex/edge properties, indexes | merge_vertices, merge_edges |
rename: vertices, relations, resources, properties; canonicalize that only renames |
change_field_types, canonicalize that merges |
set_edge_directed, retarget_edges, add_inverse_edges, set_native_inverses, set_inverse_emission; declare_edge_inverses ↔ retract_edge_inverses |
sanitize, project_manifest |
replace_identity (with retire: keep), secondary identities |
merge_manifests (binary) |
Reversible is a property of the op and the manifest it met. invert_op replays
its candidate inverse and offers it only when the round trip lands back on the
pre-state by content hash, so an inverse is exact or absent:
- a
remove_verticesthat cascaded — over incident edges, profile entries, pipeline steps — has no inverse, because re-adding the vertex restores none of that. Remove the edges first, asdiff_manifestsdoes, and each step inverts; - a relation-addressed property op has no inverse when the relation's edges disagreed about the field beforehand;
- a property rename onto a name already taken folds two fields into one, and renaming back cannot make them two again;
- an op the manifest refuses has no inverse: nothing was done.
A removed property is restored as the field it was — type, description and grounding — not as a bare name.
Merging two branches¶
from graflo.architecture.evolution import find_merge_base, merge_three_way, take_left
base_id = find_merge_base(history, left_id, right_id)
merged, result = merge_three_way(ancestor, left, right)
if not result.clean:
merged, result = merge_three_way(
ancestor, left, right, resolutions=[take_left(result.conflicts[0])]
)
Merging is not diffing. Both sides descend from a common ancestor, so the
question is never "what is different" but "what did each side change, and do
those changes collide". find_merge_base returning None means the two share
no ancestor — which is the signal that the operation wanted is merge, not
merge.
Slots¶
Reconciliation happens per slot — the addressable location an op touches,
such as vertex/person/field/age. Disjoint slots merge automatically; the same
change on both sides merges once; different changes to one slot are a
MergeConflict carrying 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.
Three things make the slot the right unit:
- An order-significant sequence is one slot. A resource pipeline is an ordered program, and half-merging two edits to a program produces something neither author wrote.
- A rename occupies both names. Renaming
person→customerwhile the other side adds a field topersonis a genuine collision, invisible unless the rename is understood to touch the old slot too. - An op touching several slots is atomic: if any one is contested, the whole op is held back. Applying half an op is not a merge.
Slots nest, so vertex/person contains vertex/person/field/age, and they are
keyed on the canonical name — order_line and OrderLine occupy the same
slot and conflict, rather than merging into two unrelated types with the data
split between them.
Edges nest under their relation: relation/knows contains
relation/knows/edge/person/company. Ops address edges two ways — by relation
name (remove_edges, rename_relations, merge_edges) and by triple
(set_edge_directed, retarget_edges, the index and identity ops) — and
containment is what lets the two families see each other, so removing a
relation on one side conflicts with flipping one of its edges on the other. A
relation-wide property edit (relation/knows/field) and a per-edge edit stay
disjoint, which is right: they merge. An edge with no relation keeps its own
root (edge/person/company), since no relation-addressed op can reach it. A declared inverse sits under both of its relations
(relation/knows/inverse), so renaming or removing a relation conflicts with
declaring or retracting its inverse on the other side; a symmetric declaration
sits under its one relation. A native inverse is a relation slot
(relation/knows/native_inverse), matching TigerGraph, where the reverse type
belongs to the relation's edge type.
What an op reads¶
A slot is what an op writes. That alone does not tell whether two ops are
independent: add_edges writes an edge and depends on its endpoint vertices,
and a remove_vertices on the other side — which cascades over that vertex's
edges — writes a different slot altogether. Merged on written slots only, one
side order drops the new edge without a word and the other does not apply. So an
op also carries a read set (op_reads):
| Op | Reads |
|---|---|
add_edges, retarget_edges, and every op addressed by edge triple |
the endpoint vertices (old and new, for a retarget) |
ops addressed by relation — edge properties, remove_edges, rename_relations, merge_edges, the inverse ops |
the vertices that relation connects in the base; the op names only the relation |
replace_identity, add_secondary_identities |
the fields they key on, and those fields' types |
add_vertex_indexes; edge index and identity ops |
the fields they index or key on |
A read is disturbed by a write at or above it, never beneath: an edge onto
company conflicts with removing or renaming company, and merges with a new
field on it. Two ops reading the same thing are independent — two edges onto
one vertex. An op both sides made is agreement, not a dependency. The conflict
is reported at the written slot with both ops attached, and resolves like any
other. ops_independent(a, b, base) is the test the merge and the law suite
share.
A change no operation expresses — one of a relation's edges gaining a
property its siblings lack, an edited pipeline — cannot be merged at all: the
merge is assembled from each side's ops, so the result would silently lack it.
merge_three_way raises MergeError naming the residue rather than return a
clean result that is incomplete.
Determinism¶
The same inputs produce the same merged manifest, the same conflicts in the same
order, and the same content hash. Resolutions take the place of the ops they
replace rather than being appended, because op order is a precondition:
diff_manifests emits an identity change before the secondary-identity add that
depends on it.
Merge is not merge3¶
| merge3 (three-way) | merge (the model operation) | |
|---|---|---|
| Inputs | two descendants of a common ancestor | unrelated lineages |
| Names | expected to agree; disagreement is a conflict | expected to disagree; a declared equivalence reconciles them |
| Reached by | merge_three_way |
merge_manifests |
| Side order | significant by construction — the commit's ops are the diff from its first parent | significant in six slots only; see below |
Both produce multi-parent commits, and both are called a merge in prose — which is why the stored kind, the CLI verb and the preview class spell the three-way merge3. "merge" names a third thing again inside a schema (combining the definitions one name has on both sides). See Words for combining things for the rule on reading a bare merge.
What merge does and does not depend on side order¶
Merging B onto A and A onto B produce the same content hash for everything the outer union assembles. Every container the union concatenates — vertices, edges, resources, transforms, connectors, semantic anchors, indexes — is classified SORTED in the canonical form, so the order the two sides were walked in is normalized away before anything is hashed. Metadata is excluded from the hash entirely, so the folded name (a+b), the joined description and the left side's version do not move the content address either.
Six slots are order-dependent, and all six are reached through the same call: the merge of two declarations of one name (merge_vertex_models([left, right], name)). They are the fields the canonical form marks PRESERVED, because their order carries meaning that sorting would destroy:
Vertex.identity— the composite key's column orderVertex.hash_identity_properties— feeds the identity digestVertex.filtersIdentityFunnel.branches— branch order is the key's fallback order- auto-assigned
SecondaryIdentitynames (secondary_0,secondary_1) — positional Vertex/Edge/Field.description— joined in side order
So merge is commutative in the world model and not in those six. Where a value is a claim rather than an ordering, merge refuses instead of electing a side: two declared db_flavors raise, a disputed iri clears to None, conflicting force_types, storage names, field types and units all raise.
Merge is recorded too¶
A merge joins two lineages that share no ancestor, so both must already be in
the store — graflo commit --root starts the second one rather than extending
the first. The commit is materialized exactly as a merge commit is, as the
verified diff from its first parent, so checkout and hash verification
need no special case; what distinguishes it is the recipe, which records the
whole declaration (equivalences, canonical maps, identity alignments) and no
merge base, because there is none.
This is why the bindings and profile blocks needed ops. A merge commit is a
diff, and a diff that cannot express what changed is refused rather than
recorded — so before set_bindings existed, merging an overlay that carried
bindings could not be recorded at all.
Tracked merges¶
A MergeRecipe records how a merge was resolved, content-addressed with its
resolutions hashed in slot order.
from graflo.architecture.evolution import build_recipe, re_merge
recipe = build_recipe(ancestor, left, right, resolutions=resolutions)
merged, result = re_merge(recipe, ancestor, advanced_left, right)
When the left side advances, re_merge replays the recorded decisions and
surfaces only genuinely new conflicts. That is what keeps an overlay
maintainable rather than a fork someone re-litigates every release.
A recorded resolution whose slot no longer conflicts is reported as unused, never force-applied — re-applying a stale decision to a slot nobody contested is how a re-merge quietly reverts someone's work.
CLI¶
graflo commit --from-manifest base.yaml --to-manifest target.yaml -m "add order"
graflo log --graph
graflo verify --base base.yaml --against target.yaml
graflo checkout <commit> --base base.yaml --output-path out.yaml
graflo merge <left> <right> --base base.yaml --take left
graflo revert <commit> --base base.yaml
graflo stamp manifest.yaml --commit <commit>
graflo merge A.yaml B.yaml -o AB.yaml -m "join" # a two-parent merge commit
Commits live under .graflo/commits by default, one YAML per commit. The store
rebuilds the DAG from the recorded parent ids, not from the filenames.
Distinct from graflo migrate-schema, which plans and executes changes against
a database. These verbs record and replay changes to the manifest.
Not in scope¶
Applying a commit history to a live database. migrate remains the
DB-facing plane, and extending it beyond additive DDL is tracked separately.
Commits describe the contract.
See also¶
- Manifest evolution — the op vocabulary a commit records
- Example 20 — fork, conflict, resolve, merge, end to end
- Example 19 — merging unrelated manifests instead
Further reading¶
The mechanisms on this page have prior art; the differences are stated here so a reader knows what to compare against.
- Curino, Moon, Zaniolo — Graceful Database Schema Evolution: the PRISM Workbench, PVLDB 1(1),
- Schema-modification operators with per-operator inverses for relational schemas. GraFlo's inverses are instead computed against the pre-state manifest and may be refused when that state does not determine them.
- Diskin, Xiong, Czarnecki — From State- to Delta-Based Bidirectional Model Transformations,
JOT 2011 / MODELS 2011. The delta-lens view in which an inverse needs the delta, not just the
end state — the shape of
invert_ops. - Bernstein, Melnik — Model Management 2.0, SIGMOD 2007; Melnik, Rahm, Bernstein — Rondo, SIGMOD 2003. Match, Compose, Diff and Merge as generic operators over models. GraFlo borrows both the operators and their spelling: there Merge takes two models plus correspondences and Compose composes two mappings, which is how this page uses the two words. GraFlo spelled them the other way round until they were swapped; the mapping is in Words for combining things.
- Pottinger, Bernstein — Merging Models Based on Given Correspondences, VLDB 2003, and Associativity and Commutativity in Generic Merge, LNCS 5600, 2009. Their Merge — two models plus correspondences — is the operator this page calls merge, and those papers are where its commutativity is studied. GraFlo's merge is commutative in the union and not in six preserved slots (above). Three-way merge is symmetric in its two sides — the same conflicts, or the same content hash — and a clean result is each side's change applied on top of the other; both are checked over generated inputs on the structural ops. Associativity across three branches is not claimed.
- Edwards, Petricek — Baseline: Operation-Based Evolution and Versioning of Data, 2025; Deshpande — Living Databases, 2026. Contemporary operation-based versioning of data, where the operations are the diff — the same design position, applied to instances rather than contracts.