Aspects¶
Every graph sooner or later has to answer questions that are not about its domain: where did this fact come from? who reported it? what was this value last March? No domain schema owns the types that answer them, and every team that adds them by hand names and keys them differently.
An aspect is such a vocabulary, packaged once: a small set of vertex and edge types, grounded in a W3C standard, that GraFlo adds to any manifest. You do not copy it into your schema; you lift your manifest with it, and the lift writes the change as ops you review before anything is applied.
Four aspects ship with GraFlo:
| Aspect | Adds | Answers | Standard | Needs a spec |
|---|---|---|---|---|
provenance |
SourceRecord, Agent, wasDerivedFrom, wasAttributedTo |
where a fact was read from, and who produced that record | PROV-O | no |
state |
<Type>State, <Type>Observation, specializationOf, hasFeatureOfInterest |
how a fact changed over time, and what was measured in which unit | PROV-O, SOSA, QUDT | yes |
access |
a reserved access_zones label on the types you name |
which readers may see each element | — | yes |
sync |
a reserved sync_hash on the types a resource's sync block owns |
whether a record changed since it was last written | — | no |
They are independent. provenance and state are lifted by default; access
and sync only when you name them.
Lifting a manifest¶
graflo lift manifest.yaml --aspect provenance -o lifted.yaml
graflo lift manifest.yaml --spec lift.yaml --emit-ops ops.yaml -o lifted.yaml
The first lifts provenance alone, which needs no spec. The second lifts both
aspects; lift.yaml is a lift spec. --emit-ops writes the
plan, --dry-run writes nothing, and the lifted manifest is checked against the
world-model profile unless you pass --no-check.
From Python:
from graflo.architecture.evolution import apply_evolution, invert_ops
from graflo.architecture.evolution.aspects import LiftSpec, plan_lift
ops = plan_lift(manifest, LiftSpec(provenance_for=["Ticket"]), aspects=["provenance"])
lifted = apply_evolution(manifest, ops)
undo, blockers = invert_ops(ops, manifest=manifest)
The plan lists everything it adds before anything it removes: groundings,
directions and units first, then the state types, then the provenance types,
and last the removal of properties that moved onto a state type. ASPECTS
lists the aspects by name, each with a version that moves when its
vocabulary or what it plans changes.
A lift changes the schema only. Nothing fills the new types until a resource does; see Filling the provenance types.
What if the manifest already has some of the vocabulary?¶
A lift adds what the manifest lacks and keeps what it already declares. An existing type, edge or property of the aspect's vocabulary is kept and gains only its missing parts:
| Already declared | The lift |
|---|---|
| nothing of that name | adds the type, edge or property |
| the same thing | keeps it |
| a type without some of the aspect's properties | adds those properties |
| a type, edge or property with no IRI | adds the aspect's IRI |
| further properties, secondary identities, a description | keeps them as they are |
another key, IRI, property type, reserved or merge, or another directed on an edge |
refuses |
Lifting twice is the same as lifting once. Composing manifests that each carry part of the vocabulary and then lifting gives the same schema as lifting each one and then composing them.
Recording a lift¶
What a lift adds looks the same as vocabulary declared by hand, so a lifted manifest cannot show which aspects produced it. Record the lift in a commit store to keep that:
-m records a commit of kind lift whose one parent is the commit holding
manifest.yaml. The manifest must already be in the store (--store, default
.graflo/commits), or the command exits 2. The commit holds the planned ops and
a lift recipe: the aspects in the order named, the version of each, the spec
without its defaults, and the graflo version. The written manifest names the
commit and the recipe in metadata.provenance. Lifting a manifest that already
carries the aspects records nothing.
From Python:
from graflo.architecture.evolution.aspects import build_lift_recipe, plan_lift
from graflo.architecture.evolution.lift_commit import build_lift_commit
ops = plan_lift(manifest, spec, aspects=["provenance"])
recipe = build_lift_recipe(manifest, spec, aspects=["provenance"])
commit = build_lift_commit(manifest, ops, parent=parent_id, recipe=recipe)
To retract a lift, revert its commit with graflo revert. To lift again after
the manifest has changed or an aspect's version has moved, plan from the
recorded recipe:
from graflo.architecture.evolution.aspects import plan_relift, recipe_drift
from graflo.architecture.evolution.lift_commit import lift_recipe_of
recipe = lift_recipe_of(commit)
recipe_drift(recipe) # aspects whose version moved: {"provenance": (1, 2)}
ops = plan_relift(recipe, manifest)
The record says what was lifted and how. Whether a manifest carries an aspect is still read from its schema.
The provenance aspect¶
Two types and two edges:
| Type | Key | Properties | Grounded as |
|---|---|---|---|
SourceRecord |
source_record_id |
source_uri, media_type, content_hash, retrieved_at |
prov:Entity |
Agent |
agent_id |
label, agent_kind |
prov:Agent, sosa:Sensor |
| Edge | Properties | Grounded as |
|---|---|---|
<Type> -wasDerivedFrom-> SourceRecord |
— | prov:wasDerivedFrom |
SourceRecord -wasAttributedTo-> Agent |
role |
prov:wasAttributedTo |
SourceRecord -mentions-> <Type> |
— | schema:mentions |
A SourceRecord is the record a fact was read from: a document, a message, an
API response, a table row. It holds the record's address and content hash,
never its content; the content stays where it was stored, and the graph
points at it. An Agent is whoever or whatever produced the record: a person,
a team, a sensor, a system. role says how, such as author or reporter.
Which types gain wasDerivedFrom:
- the types you name in the spec's
provenance_for; - every
<Type>Stateand<Type>Observation, when thestateaspect is lifted too.
SourceRecord -wasAttributedTo-> Agent is added even when nothing else is, so
the schema can always say who produced a record.
mentions says a record names an entity, for the types in the spec's
mentions_for. It answers "which records name this?" in one hop for any type,
including one whose facts come from elsewhere, such as a ticket named in a
message.
A source kit can declare its own SourceRecord with only the properties it
fills, plus its own. The lift adds the rest of the table to it, as for any
vocabulary the manifest already has.
Filling the provenance types¶
A lifted Ticket is filled like any other type: a resource writes the ticket,
the record it came from and its author, and the two edges between them.
ingestion_model:
resources:
- name: tickets
pipeline:
- vertex: Ticket
- vertex: SourceRecord
- vertex: Agent
from: {agent_id: reporter_email, label: reporter_name}
- edge: {from: Ticket, to: SourceRecord, relation: wasDerivedFrom}
- edge: {from: SourceRecord, to: Agent, relation: wasAttributedTo}
Each row carries the ticket, its record's address and the reporter:
{"ticket_id": "SUP-42", "title": "Pump P-200 loses pressure",
"source_record_id": "tracker:SUP-42@3", "source_uri": "https://tracker.example/SUP-42",
"content_hash": "sha256:9f2c", "retrieved_at": "2026-10-18T09:00:00Z",
"reporter_email": "ann@example.com", "reporter_name": "Ann Lee", "role": "reporter"}
Ticket and SourceRecord take the fields named like their properties;
Agent maps its fields with from. role lands on wasAttributedTo, because that edge
declares it. Give source_record_id a value that changes when the record
does (here the ticket and its revision), so an edited record is a new
SourceRecord rather than an overwrite.
The state aspect¶
For each type named in the spec's stateful, a <Type>State takes the
properties that change over time:
| Type | Key | Properties |
|---|---|---|
<Type>State |
the type's key + valid_from |
the type's key, the moved properties, valid_from, valid_to |
<Type>Observation |
the type's key + observed_property + result_time |
the type's key, observed_property, result_value, result_unit, result_time |
| Edge | Grounded as |
|---|---|
<Type>State -specializationOf-> <Type> |
prov:specializationOf |
<Type>Observation -hasFeatureOfInterest-> <Type> |
sosa:hasFeatureOfInterest |
A state is keyed on its subject and valid_from, so each value is a separate
fact: a new value closes the old interval instead of overwriting it. An
observation is one measurement at one time; its unit travels in each row
(result_unit, a UCUM code), because one observation type holds temperatures
and pressures alike. Types named in observed gain an observation type.
The state aspect also makes the manifest say what it left implicit: it grounds
the types you list, states directed on every edge, sets the units you declare
on existing properties, and pins an identity for types that only had one by
fallback.
By default the moved properties are removed from their type (retire: move);
retire: keep leaves them in place as the current value beside the history.
The access aspect¶
The lift adds one field, access_zones: LIST<INT>, to each vertex type in the
spec's access_for and each edge relation in access_edges. Each type and
relation names how an upsert combines its zones: replace (the element takes the
zones of its latest write) or union (it keeps every zone it was written with).
A union is written by FalkorDB, Neo4j and the file backend; another backend
refuses the write.
The field is reserved: no document supplies it, and a type sheet, element text or instance schema never shows it. The writer sets it from a stamp, and refuses to write a lifted type without one:
A traversal refuses a filter or anchor key that names access_zones. For
every other query, a service calls query.refuse_reserved(schema.reserved_fields())
before reading, and leaves the field out of what it returns. To read only what a
reader may see, pass a guard that matches the zones that reader opens; every
vertex and edge of a traversal must pass it, so a path through a hidden element
reaches nothing:
from graflo.filter.onto import ComparisonOperator, FilterExpression
guard = FilterExpression(
kind="leaf",
field="access_zones",
cmp_operator=ComparisonOperator.ANY_IN,
value=[sorted(reader_zones)],
)
found = conn.graph_neighbors("Ticket", "SUP-42", hops=2, schema=schema, guard=guard)
A write's own lookups (an edge endpoint found by a secondary identity, an attached record, an extra weight) take the same guard, so a write never links to, attaches onto or copies from a vertex the guard hides:
An ingest stamps and guards per record: the caller passes
IngestionParams(stamp_for=..., guard_for=...), each called with the
resource name and a record, and what a record casts to is written with that
record's stamp and guard only (see
Parallelism):
params = IngestionParams(
stamp_for=lambda resource, record: {"access_zones": [zone_for(record)]},
guard_for=lambda resource, record: guard_for_zone(zone_for(record)),
)
engine.ingest(manifest=manifest, target_db_config=conn_conf, ingestion_params=params)
What a zone is and which zones a reader opens are not part of the graph; your
service decides both. Combine several conditions with conjoin(). Guards and
union labels are supported on Neo4j, FalkorDB and the file backend; other
backends refuse them. The guard
names the field as stored, so do not rename access_zones in db_profile. A
reserved field cannot be part of an identity.
The sync aspect¶
The lift adds sync_hash: STRING to each vertex type a resource's sync block
owns (owns:). The field is reserved and set_by: sync: no document and no
stamp sets it. A bulk load and a sync run write a digest of what the owner
record wrote, its vertex properties and its owned edges, and a sync run skips a
record whose digest is unchanged. An upsert writes null, so the next sync
rewrites that record.
The lift spec¶
A lift can see structure, not meaning: nothing in a schema says that temp_c
is in degrees Celsius or that status changes while serial_number does not.
The spec declares that. The state and access aspects require one;
provenance reads provenance_for and provenance from it when one is given.
| Key | Aspect | Declares |
|---|---|---|
grounding |
state | per type, the concept it denotes: {Machine: {iri: …, exact_match: […], synonyms: […]}} |
edge_grounding |
state | the same per edge, addressed by source, target and relation |
identity |
state | a key for a type that declares none |
stateful |
state | per type, the properties that change over time: {Machine: [status]} |
observed |
state | the types that are measured at a time |
measured |
state | units of existing properties: {Machine.operating_temp: Cel} (UCUM; currency as ISO 4217, USD) |
retire |
state | move (default) or keep the properties named in stateful |
provenance_for |
provenance | the types that gain wasDerivedFrom |
mentions_for |
provenance | the types SourceRecord gains mentions to |
provenance |
provenance | false leaves the aspect out, like not naming it |
access_for |
access | per vertex type, the merge mode of its zones: {Ticket: replace, Product: union} |
access_edges |
access | per edge relation, the merge mode of its zones: {about: union} |
When a lift refuses¶
A lift never guesses. It stops with a LiftError that names what is missing
when:
- an aspect name is unknown, or the manifest has no schema block;
- the manifest declares part of the vocabulary differently: another key, IRI,
property type,
reservedormerge(SourceRecordkeyed byid, zones that merge byreplacewhere the spec saysunion). Every difference is named in one error; rename or change those first; - the spec names a type or property the manifest does not have;
- a
statefulorobservedtype has no key to point back to, or astatefulproperty is part of the key; - a type relies on the identity fallback and the spec gives it no
identity; accessis named with nothing to label, or an edge is givenshared.
See also¶
- Track state and measurements (example 23): a manifest lifted with both aspects, before and after.
- Conformance profiles: the checks a lifted manifest passes.
- Manifest evolution: applying, recording and undoing the ops a lift plans.