Manifest evolution¶
A manifest changes over its life: types are renamed, properties added, keys replaced, relations given a reverse name. This page shows how to make such a change as a list of operations that you can review, store, replay and often undo, instead of editing YAML by hand. After reading it you will know which operations exist, how to apply them from Python or the shell, how to undo them, and how to derive them from two versions of a manifest.
What an operation is¶
An operation, or op, is one typed change to a manifest, such as "rename the
vertex type Asset to Machine". It names what changes, and GraFlo carries the
change through every place that refers to it: the schema, the resources that
feed the type, the database profile and the bindings. An ordered list of ops is
a change set.
Ops are written in YAML or built in Python. This change set renames a type and adds a typed property to it:
- op: rename_vertices
renames: {Asset: Machine}
- op: add_vertex_properties
additions:
Machine:
- {name: commissioned_on, type: DATETIME}
Every op is checked when it is applied. An op that names a type the manifest does not declare, or that would leave the manifest inconsistent, is refused with a message rather than applied halfway.
Ops change the manifest only. A database already loaded with the old manifest keeps its data as it was; see Evolving a manifest for what to do about it.
Operations¶
Each table lists the op name as written in YAML (the Python class is the same
name in CamelCase with Op appended, such as RenameVerticesOp), what it
does, and whether it can be undone.
Vertex types¶
| Op | What it does | Undo |
|---|---|---|
add_vertices |
Adds vertex types, given as full definitions. Refuses a name that exists. | yes |
remove_vertices |
Removes vertex types, the edges that touch them, and the pipeline steps, profile entries and bindings that refer only to them. A vertex router keeps routing the surviving types. | only when nothing else was removed with it; remove the edges first |
rename_vertices |
Renames vertex types everywhere they are named. The map must not send two names to one. | yes |
merge_vertices |
Merges sources into one type into: edges are redirected, and properties are combined by name. Refuses two different types or units for one property. |
no |
Properties¶
| Op | What it does | Undo |
|---|---|---|
add_vertex_properties |
Adds properties to vertex types, as bare names or with type and grounding. |
yes |
remove_vertex_properties |
Removes properties and the indexes, from mappings and keep_fields entries that name them. Refuses a property the type does not declare. |
yes; the property comes back with its type and grounding |
rename_vertex_properties |
Renames properties per type. Resources keep reading the old column name, through a from mapping. Refuses a new name the type already declares, unless the map sends both names there on purpose ({a: c, b: c}). |
yes, unless it folded two properties into one |
add_edge_properties |
Adds properties to every edge of a relation. | yes |
remove_edge_properties |
Removes properties from every edge of a relation. | yes, when the relation's edges agreed on the property |
rename_edge_properties |
Renames properties of a relation, in the schema, profile and edge steps. | yes |
change_field_types |
Sets the type of vertex or edge properties. An edge entry retypes every edge of the relation that declares the property. Checked against the target database, and refuses to make a key property a LIST. |
no |
Edges and relations¶
| Op | What it does | Undo |
|---|---|---|
add_edges |
Adds edges between existing vertex types. | yes |
remove_edges |
Removes edges by relation (relations, every pair of types) or by exact (source, target, relation) (edges). |
yes |
rename_relations |
Renames relations everywhere they are named. | yes |
merge_edges |
Merges several relation names into one. | no |
retarget_edges |
Changes which vertex types an edge connects, keeping its properties, keys, direction and database settings. | yes |
set_edge_directed |
Sets directed on selected edges. |
yes |
Inverse relations¶
A declared inverse is a second name for reading a relation backwards, such as
has_work_order for targets. See
Inverse relations below.
| Op | What it does | Undo |
|---|---|---|
declare_edge_inverses |
Declares inverse pairs (inverses) and relations that are their own inverse (symmetric). Stores nothing. |
yes |
retract_edge_inverses |
Withdraws declarations. Refused while the database maintains the pair or a step still writes it. | yes |
add_inverse_edges |
Stores declared pairs as edges in the reverse direction, written from the same records (materialized). | yes |
set_native_inverses |
Has TigerGraph maintain declared pairs (enabled: false withdraws). |
yes |
set_inverse_emission |
Sets or clears emit_inverse on edge steps addressed by position. |
yes |
Identity¶
| Op | What it does | Undo |
|---|---|---|
replace_identity |
Replaces how a vertex type is keyed: new fields or a new mode. Decides what becomes of the old key and of the edge steps that match on it. See Evolving a manifest. | only with retire: keep |
add_secondary_identities |
Adds lookup keys to vertex types; each gets an index. | yes |
remove_secondary_identities |
Removes lookup keys. Refused while an edge step matches on one. | yes |
replace_edge_identities |
Replaces the uniqueness keys of edges. | yes |
Database profile¶
| Op | What it does | Undo |
|---|---|---|
add_vertex_indexes, remove_vertex_indexes |
Adds or removes secondary indexes on vertex types. Indexes that come from secondary identities are removed with remove_secondary_identities. |
yes |
add_edge_indexes, remove_edge_indexes |
Adds or removes indexes on edges. | yes |
set_db_profile |
Replaces the whole database profile: target database, namespace, storage names, defaults and indexes. | yes |
sanitize |
Records storage names that a target database accepts, for names it reserves or cannot store. The logical names are unchanged. | no |
Resources and bindings¶
| Op | What it does | Undo |
|---|---|---|
add_resources |
Adds resources, with any named transforms their steps use. | yes, unless it registered a transform the manifest did not hold |
remove_resources |
Removes resources and the bindings that served only them: their resource_connector entries, and each connector left serving no resource, with its connector_connection entry. |
yes, unless bindings wired them |
replace_resources |
Replaces the definitions of existing resources, keeping their positions and bindings. Any edit to a pipeline is written this way. | yes, unless it registered a transform the manifest did not hold |
rename_resources |
Renames resources and every bindings reference to them. | yes |
add_resource_transforms |
Appends transform steps to resources, at the root or at a nested level (at). |
no |
ensure_extracted_fields |
Makes a vertex router keep named fields that its keep_fields or extraction_scope would drop. |
no |
set_bindings |
Replaces the whole bindings block; null removes it. |
yes |
Grounding and descriptions¶
These ops change what a type means to a reader. Nothing reads them when records are cast or written.
| Op | What it does | Undo |
|---|---|---|
set_vertex_semantics |
Grounds vertex types in an external vocabulary; null clears. |
yes |
set_edge_semantics |
Grounds edges. | yes |
set_field_semantics |
Grounds properties of vertices or edges, including their unit. |
yes |
set_vertex_descriptions |
Sets or clears the description of vertex types. | yes |
Whole manifest¶
| Op | What it does | Undo |
|---|---|---|
canonicalize |
Renames types, properties and relations by one map, all at once. A group of names sent to one target merges them and needs allow_merges. |
when it only renames |
project_manifest |
Keeps a part of the manifest: named vertex types and edges, or the neighborhood of some types. See Keeping part of a manifest. | no |
merge_manifests is the one op that takes two manifests. It is applied with
merge_manifests or graflo merge, not with apply_evolution; see
Merging manifests.
Applying ops¶
In Python, apply_evolution applies a change set and returns a new manifest;
the input is not changed:
from suthing import FileHandle
from graflo import GraphManifest
from graflo.architecture.evolution import apply_evolution, manifest_hash, ops_from_yaml
manifest = GraphManifest.from_config(FileHandle.load("maintenance.yaml"))
ops = ops_from_yaml("changes.yaml")
changed = apply_evolution(manifest, ops)
print(manifest_hash(manifest)[:12], "->", manifest_hash(changed)[:12])
apply_evolution validates the result as loading a manifest does, and bumps the
minor part of schema.metadata.version. Pass bump_version=False to keep the
version, or finish_init=False to skip the validation.
Ops are also Python classes, which is convenient when a program builds them:
from graflo.architecture.evolution import AddVertexPropertiesOp, RenameVerticesOp
ops = [
RenameVerticesOp(renames={"Asset": "Machine"}),
AddVertexPropertiesOp(
additions={"Machine": [{"name": "commissioned_on", "type": "DATETIME"}]}
),
]
ops_to_yaml_str(ops) writes a change set as YAML, and ops_from_yaml reads a
file path or a YAML string back.
manifest_hash is the manifest's content hash. It covers the schema, the
ingestion model and the bindings but not the name or version, so two manifests
that describe the same graph hash equal.
Many ops rewrite resources as well as the schema. Applied to a manifest without
an ingestion_model, they change only the schema, and the resources you keep
elsewhere would still use the old names. ops_reaching_ingestion(ops) lists
the ops in a change set for which that matters.
From the shell, there is no command that applies a hand-written change set. These commands produce or replay one:
| Command | What it does with ops |
|---|---|
graflo commit --from-manifest A --to-manifest B |
derives the ops from A to B and records them; see Deriving ops from two manifests |
graflo checkout --base A |
replays recorded ops to rebuild a manifest |
graflo inverses realize\|repair\|switch\|withdraw ... --emit-ops FILE -o OUT |
plans ops for inverse relations, writes them, and applies them |
graflo lift --spec FILE --emit-ops FILE -o OUT |
plans the ops that add state and provenance types |
Undoing ops¶
invert_ops computes the ops that undo a change set, from the manifest as it
was before the change:
from graflo.architecture.evolution import invert_ops
inverses, blockers = invert_ops(ops, manifest=manifest)
if not blockers:
restored = apply_evolution(changed, inverses)
An inverse is exact or absent: GraFlo applies each candidate inverse and keeps
it only when it restores the earlier manifest's content hash. When blockers is
not empty, some op could not be undone, and the inverses do not restore the
manifest. Rebuild it from the earlier version instead, with
checkout.
These ops never have an inverse, because the information needed to go back is gone once they run:
| Op | Why it cannot be undone |
|---|---|
merge_vertices, merge_edges, canonicalize that merges |
which source each property and key came from is lost |
change_field_types |
the previous type is overwritten |
sanitize |
the storage names it replaces are not recorded |
project_manifest |
it drops everything outside the part kept |
add_resource_transforms, ensure_extracted_fields |
no op removes the steps or fields they add |
merge_manifests |
it has two inputs, so there is no single earlier manifest |
Others can be undone only from some manifests, as the tables above say: a
remove_vertices that also removed edges, or a replace_identity that demoted
the old key, has no inverse. To keep a change undoable, split it: remove the
edges first, then the vertex type.
Deriving ops from two manifests¶
When you have two versions of a manifest, such as a file edited by hand,
diff_manifests derives the ops that turn one into the other.
diff_manifests_verified also applies them and checks that the result has the
target's content hash:
from graflo.architecture.evolution import RenameHints, diff_manifests_verified
ops, warnings = diff_manifests_verified(
before, after, hints=RenameHints(vertices={"Asset": "Machine"})
)
The differ does not guess renames. A type Asset that disappears while a type
Machine appears could be a rename or a removal plus an addition, and guessing
wrong turns a change that keeps data into one that drops it. Without the hint
above, the diff removes Asset and adds Machine. RenameHints takes
vertices, relations, resources, vertex_properties and edge_properties.
warnings lists what the ops could not express, such as one of a relation's
edges gaining a property its siblings lack; when that happens, replaying the ops
does not reproduce the target, and the last warning says so. A change set with
warnings is incomplete, and graflo commit refuses to record it. An edited
pipeline is written as replace_resources, unless the only edit is transform
steps appended at the end (add_resource_transforms) or the trimming that a
removal already does.
The ops come in an order in which each one's preconditions hold: renames first, then additions, then changes, then removals.
Inverse relations in detail¶
A relation can often be read from both ends: WorkOrder targets Machine and
Machine has_work_order WorkOrder. Declaring the pair gives the reverse
reading a name and stores nothing; reads of has_work_order follow targets
edges backwards, which most databases do at no extra cost. Storing the reverse
reading is a separate choice, made per pair, in one of two ways:
- materialized: the reverse edges are declared edges, written from the same
records by edge steps that set
emit_inverse: true. Works on every database. - native: TigerGraph maintains the reverse edge type itself
(
db_profile.native_inverses).
Core components shows how each is declared in YAML. This section covers changing them.
Planning the change¶
Five planners look at a manifest and return the ops for a change, together with the pairs they skipped and why. They apply nothing, so the op list is what you review:
from graflo.architecture.evolution import apply_evolution, plan_realize_inverses
plan = plan_realize_inverses(manifest, strategy="materialized")
plan.ops # [AddInverseEdgesOp(op='add_inverse_edges', relations=['targets'])]
plan.skipped # pairs left alone, each with a code and a reason
manifest = apply_evolution(manifest, plan.ops)
| Planner | Emits | Use it to |
|---|---|---|
plan_realize_inverses(manifest, strategy=...) |
set_native_inverses or add_inverse_edges |
store declared pairs: native, materialized, or auto, which chooses from what a reverse read costs on the target database and often stores nothing |
plan_repair_inverses(manifest) |
whatever fills each gap the audit reports | make every resource feed a stored inverse; conflicts are left to you |
plan_switch_realization(manifest, relations, to=...) |
a withdrawal, then a realization | move pairs from native to materialized or back |
plan_withdraw_realization(manifest, relations) |
set_native_inverses off, or remove_edges |
stop storing the reverse edges and keep the declaration |
plan_declare_symmetric(manifest, relations) |
set_edge_directed, then declare_edge_inverses |
declare relations that read the same both ways, such as adjacent_to |
Each plan is tried on a copy of the manifest before it is returned, so an op the manifest would refuse is reported as skipped rather than handed to you. Planners emit the basic ops rather than one combined op so that a recorded change replays without the planner and each step can be undone on its own.
From the shell, graflo inverses runs the same planners:
graflo inverses audit machines.yaml
graflo inverses realize machines.yaml --strategy materialized \
-o machines_both.yaml --emit-ops realize.ops.yaml
graflo inverses repair merged.yaml -o fixed.yaml --emit-ops fix.ops.yaml
graflo inverses switch machines.yaml --to native -r targets -o out.yaml
graflo inverses withdraw machines.yaml -r targets -o out.yaml
Without -o a command prints the plan only; --dry-run writes nothing.
Auditing what you have¶
audit_inverses reports, for each declared pair, how it is stored and how each
resource that writes the forward relation feeds the reverse one:
from graflo.architecture.profile import audit_inverses
for line in audit_inverses(manifest).to_lines():
print(line)
A pair is declared when nothing stores it, native or materialized when
something does, and partial or conflicting when something is wrong. Findings
come in three severities:
| Severity | Meaning | Examples |
|---|---|---|
repairable |
One place states something another omits. Filling it in cannot change the meaning. | a resource writes the forward relation but not its stored inverse (unfed_inverse); the reverse edge exists for some pairs of types only; one direction declares a property the other lacks |
conflict |
Two places disagree, and only you know which is right. | a pair whose two relations run the same way; the two directions declare different keys or property types; a step writes an inverse no edge declares |
note |
Nothing is wrong. | a pair that is only declared, on a database that cannot read an edge from its target |
plan_repair_inverses fills in repairable findings and never touches a
conflict: where one place is silent the manifest has one answer, and where two
places disagree it has two. The same audit runs as the inverses
conformance profile (graflo check --profile
inverses), where conflicts fail and omissions warn. graflo inverses audit
exits 1 when it finds a conflict.
Rules the schema enforces¶
A manifest that breaks one of these rules is refused when it is loaded:
| Rule | Refused when |
|---|---|
| One inverse per relation | a relation would get two inverses, such as a-b and b-c, or it is both paired and symmetric |
| No self-pair | a pair names one relation twice; declare it symmetric instead |
| No dangling declaration | a pair or symmetric name names no edge |
| One direction per relation | edges of one relation disagree on directed |
| Pairs are directed | a paired relation is on an undirected edge |
| Symmetric is undirected | a symmetric relation is on a directed edge |
| Native needs a pair | a relation in native_inverses has no declared pair, or is symmetric |
| One way to store a pair | a relation is native and declared edges also carry its inverse, or both sides of a pair are native |
| One edge type | relation_name overrides store a native relation under several names |
| TigerGraph only | native_inverses is set for another database |
| One type namespace | an inverse name equals a vertex type name (TigerGraph type names are global) |
| A flag that can write | a step that names one edge sets emit_inverse, but its relation has no stored inverse |
Ops are refused by the same rules, which is why making a relation symmetric
takes two ops in a fixed order (set_edge_directed, then
declare_edge_inverses).
Stored reverse edges are ordinary edges once they exist: a later op on the forward edge does not reach them. That is the drift the audit reports.
Keeping part of a manifest¶
project_manifest keeps a part of a manifest and removes everything else,
including the pipeline steps, profile entries and bindings that referred to it:
from graflo.architecture.evolution import ProjectManifestOp, apply_evolution
around_machines = apply_evolution(
manifest, [ProjectManifestOp(keep_vertices=["Machine"], depth=1)]
)
With keep_vertices alone, the listed types are kept together with the edges
among them, and a listed type left with no edge is dropped. keep_edges lists
exact (source, target, relation) edges to keep. keep_resources narrows the
resources too, and connectors left serving no resource are removed with their
connector_connection entries.
A resource whose pipeline names both kept and removed types is trimmed to the
kept part and keeps reading its source; the projection logs a warning naming
each one. partial_resources: drop removes those resources instead, keeping
only the resources the projection leaves whole.
depth turns keep_vertices into starting points and keeps every type within
that many edges of one of them, with every edge among the kept types:
| Setting | Effect |
|---|---|
depth: 0 (default) |
keep_vertices is the literal list. |
depth with keep_edges |
the walk follows only the listed edges |
direction |
any (default), out or in; an undirected edge is followed both ways |
keep_inverse_edges: true |
with keep_edges, also keeps the stored reverse of each kept edge |
Further reading¶
- Bonifati, Furniss, Green, Harmer, Oshurko, Voigt: Schema Validation and Evolution for Graph Databases, ER 2019. Property-graph schema evolution as graph rewriting; the closest earlier set of operations for property-graph schemas.
- Hausler, Klettke, Störl: A language for graph database evolution and its implementation in Neo4j, ER Forum 2023. An evolution language bound to one database; the ops on this page are independent of the database.
- Bonifati: Versatile Property Graph Transformations, PVLDB 18(12), 2025.
Declarative graph-to-graph transformations; a comparison point for
project_manifest. - Bernstein: Applying Model Management to Classical Meta Data Problems, CIDR
- The generic operators Match, Compose, Diff, Merge and ModelGen, of which
diff_manifestsandmerge_manifestsare instances for manifests.
What to read next¶
- Evolving a manifest: a change set written, applied, undone and recorded, step by step.
- Merging manifests: combining two manifests into one.
- Version control: recording change sets as commits.