Skip to content

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_vertices that cascaded — over incident edges, profile entries, pipeline steps — has no inverse, because re-adding the vertex restores none of that. Remove the edges first, as diff_manifests does, 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 → customer while the other side adds a field to person is 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 order
  • Vertex.hash_identity_properties — feeds the identity digest
  • Vertex.filters
  • IdentityFunnel.branches — branch order is the key's fallback order
  • auto-assigned SecondaryIdentity names (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

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.