Version control¶
A manifest changes over time, often in more than one hand. This page shows how GraFlo records that history: how a manifest gets a content address, how change sets become commits, how you go back to an earlier version, and how two lines of change to the same manifest are reconciled. After reading it you can keep a manifest's history in a commit store and replay, verify and merge it from the shell or from Python.
Two operations here combine manifests, and they are different. A three-way
merge reconciles two branches of one manifest's history (merge_three_way,
graflo merge3, the version control example (22));
a union combines two unrelated manifests (merge_manifests, graflo merge, the
manifest union example (20)), and is
described on Merging manifests.
History moves forward, like a git log, not like a migration script with an
upgrade() and a downgrade() for every step. A reversible pair per step is
not possible here: merge_vertices discards which source each property came
from, change_field_types discards the previous type, sanitize overwrites the
storage names it replaces, and project_manifest drops material outright. A
downgrade that produces a different manifest from the one you started with is
worse than none. So going back means replaying from an earlier version.
Nothing here touches a database. A history records changes to the manifest; see Schema migration for changes to a database.
Content addressing¶
Two manifests that describe the same graph hash equal:
from graflo.architecture.evolution import manifest_hash
manifest_hash(a) == manifest_hash(b) # same graph, however each was reached
The hash covers the schema, the ingestion model and the bindings. It leaves out
the manifest's name, version and provenance. to_minimal_canonical_dict()
already ignores defaults, None values, aliases and key order. What it keeps is
list order, and most lists in a manifest are sets written in some order, so two
identical schemas authored in different orders, or one authored and one
replayed, would hash differently. canonical_payload adds that normalization.
Sorted or preserved¶
Which lists may be sorted is decided per field, in LIST_ORDER, which
classifies every list field reachable from GraphManifest.
The two possible mistakes are not equally bad. Sorting a list whose order matters makes two different manifests hash equal, and nothing downstream can detect it. Preserving the order of a list whose order does not matter only misses a match, which is visible and harmless. So a doubtful field is preserved, and every sorted field states why its order does not matter.
| Sorted | Preserved |
|---|---|
| vertices, edges, properties, secondary identities | resource pipelines (an ordered program) |
| resource and transform registries, bindings entries | Vertex.identity: a database addresses an endpoint through the first key field |
| index sets, edge specs | the columns of a compound index |
semantic exact_match / synonyms |
identity funnel branches (the first branch that fires wins) |
selector and membership sets, weight fields |
transform arguments, join and projection order, filter operands |
identity digest inputs: hash_identity_properties, a branch's fields and when_all_present (the digest hashes a JSON object with sorted keys) |
Sorting uses each element's canonical JSON rendering rather than a key per field, so it works for mixed element types, needs no tie-break rule, and cannot depend on the input order. Only the hash sees the sorted order; authored YAML keeps its own order.
A list field that is not classified raises UnclassifiedListField rather than
being guessed. CANON_VERSION is part of the hashed bytes, so a change to these
rules produces different hashes instead of reading old hashes under new rules.
Language version¶
metadata.gstl states the GSTL version a manifest is written in, as
MAJOR.MINOR:
A manifest that states a major version graflo does not read is refused; any
minor of a supported major is accepted, and an absent gstl means unstated.
Every manifest the graflo CLI writes is stamped with the version it writes.
Like all of metadata, gstl is outside the content hash.
Provenance¶
The content address and lineage can travel with the manifest, so a shipped file describes itself:
metadata:
provenance:
content_hash: "…64 hex…"
canon: graflo/canon@5
parents: [a3f9c21e4b70, 9e11d02c55aa]
commit: c4d1e9a2b3f0
recipe: "…"
Provenance is never part of the content hash. Content identity must not depend on the path taken, so that two routes to the same manifest agree that they arrived at the same place. A hash over the parents would make identity depend on history, and equal manifests could never be recognized as equal. The commit id is the hash that covers ancestry, as in git.
Stamping is explicit: stamp_provenance(...) or graflo stamp at a commit
point, never a side effect of apply_evolution. Applying the same ops twice
must not produce two files that disagree about their own lineage.
Commits¶
from graflo.architecture.evolution import History, build_commit, checkout
first = build_commit(base, ops, label="add commissioning date")
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 (none for a root, one for an edit or a
lift, two or more for a merge) and the content hash before and after it. Its
kind says what produced it: root, edit, merge, merge3, revert or
lift. build_commit
applies the ops rather than trusting them, so both hashes describe a change
that actually happened, and it refuses a change set that leaves the manifest
unchanged, since such a commit would record nothing.
A commit id is derived from the ops and the order of the parents, so recording
the same change set again yields the same id rather than a duplicate under a new
name. graflo rehash recomputes every id when an op's serialization changes.
A release that bumps CANON_VERSION moves every tree instead; graflo rehash
--base <manifest> replays each lineage from its base and records the new trees,
keeping every id (rehash_trees in Python). A history with several roots takes
one --base ROOT=PATH per root.
Forks are recorded¶
Two commits may share a parent. History rejects what is broken (a duplicate
id, a parent that does not exist, a cycle, a first-parent edge whose hashes do
not line up) and records everything else, including several heads. Two people
changing the same version is normal, so the history records both branches:
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 store a diff from the first parent¶
A merge commit's ops are the diff from its first parent to the merged result,
not an interleaving of both sides. So replay and hash verification work the
same way for edit and merge commits, and nothing downstream needs a special
case. The record of how the merge was resolved is stored beside it as a recipe.
Undoing¶
History is append-only, so undoing a change moves forward:
build_revert_commit (graflo revert) records a new commit that applies the
inverses. Each inverse is computed against the manifest its op was applied to,
passed as before=; checkout_parent(base, history, commit_id) rebuilds it
from the base. The inverses are then applied to the head. Inversion is exact
or it fails: a commit holding an op with no inverse is refused, and so is one
whose inverses no longer apply to the head because a later commit changed what
they touch. Checking out the parent commit is always exact.
| Reversible | Irreversible |
|---|---|
| add and remove: vertices, edges, vertex and 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 and retract_edge_inverses |
sanitize, project_manifest |
replace_identity (with retire: keep), secondary identities, replace_edge_identities |
add_resource_transforms, ensure_extracted_fields, merge_manifests |
add, remove and replace resources; set_bindings, set_db_profile; grounding, superclasses, superproperties and descriptions |
Whether an op can be undone depends on the op and on the manifest it met.
invert_op applies its candidate inverse and offers it only when the result
has the content hash of the state before, so an inverse is exact or absent:
- a
remove_verticesthat also removed edges, profile entries or pipeline steps has no inverse, because adding the vertex back restores none of them. Remove the edges first, asdiff_manifestsdoes, and each step can be undone; - an op on a relation's properties has no inverse when the relation's edges disagreed about the property beforehand;
- a property rename onto a name already taken folds two properties into one, and renaming back cannot make them two again;
- a
remove_resourcesof resources that bindings served has no inverse, because the bindings removed with them are not restored; nor has anadd_resourcesorreplace_resourcesthat registered a transform the manifest did not hold, because no op withdraws one; - an op the manifest refuses has no inverse, because nothing was done.
A removed property is restored as the property it was, with its type, description and grounding.
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])]
)
A three-way merge is not a diff. Both sides descend from a common ancestor, so
the question is not what differs between them but what each side changed, and
whether those changes collide. When find_merge_base returns None, the two
share no ancestor, and the operation you want is a
union, not a three-way merge.
Slots¶
Changes are reconciled per slot: the place in the manifest an op touches, such
as vertex/machine/field/serial_number. Changes to different slots merge on
their own, the same change on both sides merges once, and different changes to
one slot are a MergeConflict. A conflict carries both sides' ops and the
ancestor's state, because what the slot looked like before either change is
what a person resolving it needs to know, and a two-way diff cannot show it.
Three rules make the slot the right unit:
- An ordered list is one slot. A resource pipeline is an ordered program, and merging half of each side's edits to it produces a program neither author wrote.
- A rename occupies both names. Renaming
machinetoequipmentwhile the other side adds a property tomachineis a real collision, visible only if the rename counts as touching the old slot too. - An op touching several slots is atomic. If any of them is contested, the whole op is held back. Applying half an op is not a merge.
Slots nest, so vertex/machine contains vertex/machine/field/serial_number,
and they are keyed on the canonical name: work_order and WorkOrder 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/feeds contains
relation/feeds/edge/machine/line. 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
nesting is what lets the two see each other: removing a relation on one side
conflicts with flipping one of its edges on the other. A relation-wide property
edit (relation/feeds/field) and a per-edge edit do not collide, and merge. An
edge with no relation has its own root (edge/machine/line), since no op
addressed by relation can reach it. A declared inverse sits under both of its
relations (relation/feeds/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 slot
of its relation (relation/feeds/native_inverse), as on 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 types,
while a remove_vertices on the other side, which also removes that type's
edges, writes a different slot. Merged on written slots only, one order of the
sides would drop the new edge without a word and the other would fail to
apply. So an op also has a read set (op_reads):
| Op | Reads |
|---|---|
add_edges, retarget_edges, and every op addressed by edge triple |
the endpoint types (old and new, for a retarget) |
ops addressed by relation: edge properties, remove_edges, rename_relations, merge_edges, the inverse ops |
the types that relation connects in the base, or, after a rename on the op's own side, the types the renamed relation connects; 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 |
add_resources, replace_resources |
the types the pipeline names, the relations its edge steps write, and the properties it maps from a named column (a vertex step's from, a router's vertex_from_map); not a property a step fills under its own name |
A read is disturbed by a write at or above it, never below: an edge onto line
conflicts with removing or renaming line, and merges with a new property on
it. Two ops reading the same thing are independent, like two edges onto one
type. An op both sides made is agreement, not a dependency. The conflict is
reported at the written slot with both ops attached, and is resolved like any
other. ops_independent(a, b, base) is the test the merge and its tests share.
A change that no op expresses, such as one of a relation's edges gaining a
property its siblings lack, cannot be merged at all: the
merge is built from each side's ops, so the result would lack it.
merge_three_way raises MergeError naming what is left over rather than
return a result that looks clean and is incomplete.
Determinism¶
The same inputs produce the same merged manifest, the same conflicts in the same
order, and the same content hash. A resolution takes the place of the ops it
replaces rather than being appended, because op order is a precondition:
diff_manifests emits an identity change before the secondary-identity
addition that depends on it.
Union and three-way merge¶
| Three-way merge | Union | |
|---|---|---|
| Inputs | two descendants of a common ancestor | two unrelated manifests |
| Names | expected to agree; a disagreement is a conflict | expected to differ; declared equivalences reconcile them |
| Function and command | merge_three_way, graflo merge3 |
merge_manifests, graflo merge |
| Order of the sides | significant: the commit's ops are the diff from the first parent | significant in six fields only; see below |
Both produce commits with several parents. The stored commit kind and the
CLI verb spell the three-way merge merge3 so that the two stay apart.
What depends on the order of the sides¶
A union of B onto A and of A onto B has the same content hash for everything
the union assembles. Every list it concatenates (vertices, edges, resources,
transforms, connectors, grounding, indexes) is sorted in the canonical form, so
the order in which the two sides were walked disappears before anything is
hashed. Metadata is not hashed at all, so the combined name (a+b), the joined
description and the version do not change the content address either.
Five fields do depend on the order, all set when the two definitions of one type are combined. They are the fields the canonical form preserves, because their order carries meaning:
Vertex.identity: the column order of a composite key;Vertex.filters;IdentityFunnel.branches: branch order is the key's fallback order;- secondary identities named automatically (
secondary_0,secondary_1), by position; Vertex,EdgeandFielddescriptions, joined in side order.
Where a value is a claim rather than an order, the union refuses instead of
choosing a side: two declared db_flavor values raise, a disputed iri is
cleared, and conflicting storage names, field types and units raise.
A union is recorded too¶
A union joins two lines of history that share no ancestor, so both inputs must
already be commits in the store: graflo commit --root starts the second line
rather than extending the first. The commit stores the diff from its first
parent, like any merge commit, so checkout and hash verification need no
special case. Its recipe records the whole declaration (equivalences with their
identities, canonical maps, and the resolved origins when the union names keys
by origin) and no merge base, because there is none. A recipe
whose declaration the op model no longer loads is refused as a CommitError
that says so. Because
the commit is a diff, every block the union changes needs an op; set_bindings
and set_db_profile cover the bindings and the database profile.
Tracked merges and lifts¶
A merge, merge3 or lift commit carries a recipe (Commit.recipe): the
declaration that produced it, with its content hash, which a replay reads.
metadata.provenance.recipe points at it from a stamped manifest. A commit of
another kind carries none, and a recipe of the wrong kind is refused.
A MergeRecipe records how a three-way merge was resolved. It is
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 moves on, re_merge replays the recorded decisions and
reports only conflicts that are new. You keep an overlay on top of a manifest
that changes, and decide each conflict once.
A recorded resolution whose slot no longer conflicts is reported as unused and never applied: applying an old decision to a slot nobody contests any more would undo someone's later change without anyone noticing.
A LiftRecipe records a lift: the aspects, the version of each, and the spec.
plan_relift plans it again on a manifest that has changed since; see
Recording a lift.
CLI¶
graflo commit --from-manifest base.yaml --to-manifest target.yaml -m "add work orders"
graflo log --graph
graflo verify --base base.yaml --against target.yaml
graflo checkout <commit> --base base.yaml --output-path out.yaml
graflo merge3 <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 union, recorded as a two-parent commit
graflo lift A.yaml --spec lift.yaml -o lifted.yaml -m "lift" # recorded as a lift commit
graflo commit derives the ops from two manifests with diff_manifests and
refuses to store a change set that does not reproduce the target; --hints
names renames the differ cannot infer. graflo merge3 stops at conflicts
unless --take left or --take right settles them; --plot draws where the
two branches met. When the history has several heads, graflo log names the
verb that joins them: graflo merge3 for heads that share an ancestor,
graflo merge for unrelated lineages. Commits live under .graflo/commits by
default, one YAML file per commit (--store changes it). The store rebuilds the graph of commits
from the recorded parent ids, not from the file names.
These commands record and replay changes to a manifest. graflo migrate-schema
is different: it plans and applies changes to a database.
Rules and limits¶
- A commit history changes manifests only. Applying it to a live database is
not part of version control;
graflo migrate-schemaplans database changes and applies additive ones. - A change no op expresses, such as one of a relation's edges gaining a property its siblings lack, cannot be recorded from a diff or merged three ways; both refuse and name it.
Further reading¶
The mechanisms on this page have prior art; the differences are stated so you know what to compare against.
- Curino, Moon, Zaniolo: Graceful Database Schema Evolution: the PRISM Workbench, PVLDB 1(1), 2008. Schema-modification operators with an inverse per operator, for relational schemas. GraFlo's inverses are computed against the manifest before the change instead, and are refused when that manifest 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 and not only 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. There Merge takes two models plus correspondences,
which is what
merge_manifestsdoes, and Compose composes two mappings, which is whatcompose_canonical_mapsdoes. - Pottinger, Bernstein: Merging Models Based on Given Correspondences, VLDB 2003, and Associativity and Commutativity in Generic Merge, LNCS 5600, 2009. Their Merge is the operator this page calls a union, and those papers study its commutativity. GraFlo's union is commutative except in the five preserved fields above. The 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 properties are checked over generated inputs for 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. Operation-based versioning of data, where the operations are the diff: the same design, applied to data rather than to manifests.
What to read next¶
- Version control example (22): a fork, a conflict, a resolution and a merge, end to end.
- Manifest evolution: the ops a commit records.
- Merging manifests: combining two unrelated manifests.