Ontology Design Patterns
Modelling guidance for the Atlas API: inheritance versus separate types, shaping a property's value, and modelling an association.
Overview#
This chapter is modelling guidance: how to decide between inheritance and separate types, how to shape a property's value, how to model an association, when a library is the right home for a type, and which of those decisions the engine will refuse outright.
It is not a field reference. Field-by-field authoring (every request body, every value type, the editors in the user interface) lives in Defining Types and Schemas. Where a property's value comes from at runtime (Static, Bound, Calculated and the three others) lives in Property Sourcing Strategies. The complete rule catalogue with every identifier and message lives in Validation Rules Reference. This chapter links to those rather than restating them.
Relationship reification changes how you should model an association that carries data. A relationship is now a first-class instance in its own archetype, with the same property set a node gets. The old workaround for an association that carries data - promote it to an intermediate entity type with two relationships - is now strictly worse than the native shape. Do not do it in new models, and prefer collapsing it when you touch an old one. Details in Associations that carry data.
Two other things worth knowing before you read on:
- Violations block. There are no compiler warnings you can ship past. The previous version of
this chapter carried a five-row "Compiler Warnings" table. None of those five strings exists in
the code,
CompileResulthas no warnings member, and the equivalent conditions are hard errors. See Validation is a gate, not a report. - There is no cardinality support in Atlas at all. If you arrive from UML or from OWL tooling, stop looking for it. See What Atlas does not model.
- Abstract types do exist. Mark a type abstract and it becomes derivation-only: it can still be a parent, but nothing can be an instance of it. See Abstract types.
The five element kinds#
An Atlas scope - a domain or a library - holds five kinds of ontology element, each with its
own machine-plane resource under /api/v1:
| Kind | Resource | What it is |
|---|---|---|
| Entity type | .../entity-types |
A class of thing: Pump, Tank |
| Property | .../properties |
A first-class named value, attached to types |
| Relationship type | .../relationship-types |
A class of connection, itself an archetype |
| Data type | .../data-types |
A compound value shape such as TVQ |
| Function | .../functions |
A calculation body used by Calculated properties |
Every resource exists in a domain form (/api/v1/branches/{branch}/domains/{domain}/...) and a
library form (/api/v1/branches/{branch}/libraries/{library}/...). Every one of them needs a Pulse
bearer.
The older vocabulary - "node types" and "relationships", addressed at
/api/nodesand/api/relationships- is not part of the current surface, nor is the/api/v2family. A request to a URL outside the API surface is answered with200 text/html, so a script that only checks the status code will report success and build nothing.
Naming#
Two conventions and one hard rule.
Domains and libraries are lowercased and hyphenated for you. The writers apply
ToLower(InvariantCulture).Replace(' ', '-') before minting the URN prefix. So a domain created as
Plant Model lives at urn:atlas:ontology:plant-model:, not plantmodel and not Plant_Model.
Spaces become hyphens, never underscores. Name them in that form yourself so the name you type
is the name you see in URNs. The four shipped seed libraries follow it: atlas-core,
atlas-common, isa95-common.
Local names are used verbatim. Entity types, properties, relationship types, data types and
functions keep exactly the local name you give them:
UrnHelper.BuildUri(scope, localName) is a string concatenation. PascalCase is the shipped convention. Comparison is ordinal and
case-sensitive everywhere, including
property access in Cypher, so r.flowrate will not find flowRate.
The one naming rule that blocks a write#
All five kinds share a single URN namespace per scope. A property named Motor and an entity
type named Motor in the same domain cannot coexist. The second write returns a 409 with error
code {Kind}.KeyCollision:
Cannot create Property 'Motor': the key 'urn:atlas:ontology:plant-model:Motor' is already
used by a Type. All element kinds (Types, Properties, Relationships, Data Types, Functions)
share one URN namespace per domain.
The guard runs before the same-kind duplicate check, so a collision with
a different kind reports KeyCollision, while a genuine duplicate reports Property.AlreadyExists.
Practical convention: PascalCase nouns for entity types and data types (Motor, TVQ), PascalCase noun
phrases for properties (MotorPowerRating, NominalFlowRate), and camelCase verb phrases for
relationship types (contains, connectsTo, feeds). That keeps the five kinds out of each other's
way without thinking about it, because a camelCase verb cannot collide with a PascalCase noun.
Relationships are the one kind that is not PascalCase. Every relationship in every shipped library is
camelCase - contains, connectsTo, relatesTo, sameAs, enterpriseContains - which matches
ordinary RDF and OWL practice, where classes are capitalised and properties are not.
Name the verb, not the sentence: feeds, not pumpFeedsTank. A relationship type is reusable
vocabulary, and a name that embeds its endpoints cannot be reused for a second pair of types.
See Naming Conventions for Atlas Models, which is canonical on naming.
Entity type inheritance#
Inheritance is single-parent: NodeDefinition.ParentType is one nullable string. Properties attached to a parent are effective on every descendant without
redeclaring them - the compiler feeds each archetype a property set that is "already subClassOf-walked,
inherited included".
NodeBase
|- Asset
|- RotatingEquipment
| |- Pump
| |- Compressor
|- StaticEquipment
|- HeatExchanger
Use inheritance when:
- The child is genuinely a specialisation: it has everything the parent has, plus more.
- You want
MATCH (n:RotatingEquipment)to returnPumpandCompressorinstances. Label matching walks the subclass subtree, so this works with no extra modelling. - You want one attachment of a property to cover a whole family.
Do not use inheritance when two types merely share an attribute. That is what property attachment is for, and it is a many-to-many relation - see the next section. Forcing an artificial shared parent to carry one common attribute is the classic mismodelling here, and attachment covers it directly.
Multiple inheritance is not supported. There is one parent slot. Model the primary category as the parent, then attach the cross-cutting properties to both branches.
Depth. Keep chains shallow and prefer 3 to 4 levels. Nothing in the engine enforces a limit.
Abstract types: derivation-only parents#
An Entity Type or a Relationship Type can be marked abstract, which says it exists to be
derived from rather than to be instantiated. A Pump abstract, with CentrifugalPump and
PositiveDisplacementPump concrete beneath it, lets you attach the shared properties once and still
refuse an instance that is only "a pump".
Marking one abstract changes three things:
- The Instance Builder's type pickers stop offering it. Parent and base-type pickers keep offering it, which is the whole point of the marker.
- The write path rejects it. Creating an instance on an abstract type, or re-typing an existing
instance onto one, fails with
InstanceMap.AbstractTypeand the message "Cannot instantiate '' - it is marked abstract (derivation-only). Use a concrete subtype instead, or unmark the type as abstract." This applies to the API and to the agent tools alike. - Existing instances are warned about, not broken. Marking a type abstract when instances of it
already exist raises validation rule I-B23, a warning anchored on the instance's
type. It is deliberately not a blocker: it reports a model that has drifted rather than refusing to open it.
Abstraction is a marker on the type, not a structural change, so unmarking it restores instantiation with nothing to migrate.
What Atlas does not model#
| Concept | Status |
|---|---|
Cardinality (1..*, owl:minCardinality, "exactly one") |
Not applied. Cardinality restrictions written into a file are carried but do not constrain the model. Enforce a rule of this kind in how you build your instance maps |
| Multiple inheritance | One ParentType slot |
| Local override of an imported library type | No override path. Provenance is read-only via sourceLibrary |
| Cascade delete | No cascade path. Deletes are dependency-guarded instead (see below) |
Because stray cardinality restrictions are ignored rather than rejected, authoring them by hand into JSON-LD gives you a clean compile and no enforcement. If you need a "a pump has exactly one drive" constraint, it has to live in the client that writes instances.
Properties attach, they do not belong#
A property is a first-class element with its own URN and its own resource. It is not owned by a type. It is attached to types, and attachment is many-to-many.
- On write,
AddPropertyRequest.AttachToTypesis a list of entity types to attach to in the same commit. The wire key isattachToTypes. - On read, the same relation comes back as
attachedTypes, an array. - After the fact, attach or detach one at a time with
PUT/DELETE /api/v1/branches/{branch}/domains/{domain}/entity-types/{name}/properties/{property}. This sub-resource is server-atomic, idempotent and cross-scope, so a domain type can attach a property that a library declared. - Attachment persists as an
owl:Restrictionon the class, never asrdfs:domainon the property.
There is no
Domainfield on a property request, precisely because it read as "the Atlas workspace" to everyone who met it. Anything you have that sends{"name": "...", "domain": "Pump"}needs to send{"name": "...", "attachToTypes": ["Pump"]}.
This changes the remedy for shared attributes. When two unrelated types need the same attribute, do not invent a common parent and do not declare the property twice. Declare it once and attach it to both:
POST /api/v1/branches/main/domains/plant-model/properties
Authorization: Bearer <pulse-token>
{
"name": "SerialNumber",
"attachToTypes": ["Pump", "HeatExchanger"],
"range": "xsd:string"
}
Declaring it twice does not work anyway: property local names are unique per scope, so the second
POST returns 409 Property.AlreadyExists.
One property, one range, everywhere. Because the property is global to the scope, its
rdfs:range is canonical for the column type. There is no per-type redeclaration and therefore no
shadowing: you cannot give SerialNumber an xsd:string range on Asset and an xsd:integer
range on Pump. If a child genuinely needs a different value type, mint a differently named
property. A per-class owl:allValuesFrom restriction does not narrow a fixed range. The one exception is a Defer property, whose open leaf a type can pin for itself: see Pinning an open leaf on a type.
Deletes are blocked, not cascaded. Removing a property that types still attach returns a 409
RFC-9457 delete-blocked problem naming the dependents:
"Cannot remove 'SerialNumber' - 3 entity types still attach it: Pump, Tank, Valve. Detach it from those types first." The same guarded-delete shape applies to data types and functions. Nothing
cascades, and no configuration switch turns cascading on.
Pinning an open leaf on a type#
A property whose type is still open is Defer: its own range is a union of XSD scalars, or its data type has a union-typed field such as TVQ.Value. The property's rdfs:range is canonical for the column type, with one exception: a type that uses a Defer property can pin each open leaf for itself. Running can then be a boolean on Pump and a double on Tank without declaring two properties.
A type pin is persisted as a separate sibling owl:Restriction on the class, beside the attachment restriction. It is never merged into the attachment, because one restriction carries exactly one owl:allValuesFrom filler (OWL 2 DL), and the attachment restriction never carries one. A pin also needs no attachment of its own: a subtype that inherits the attachment pins with a sibling restriction alone.
The filler depends on the leaf. For a property whose own range is a union (leaf path ""), the filler is the XSD URI. For a leaf inside a data type, the filler is the inline owl:Class with an owl:intersectionOf list that a property's fieldTypeOverrides already use (see Pinning a field's type at the point of use): the base compound first, then one restriction per pinned field. The dotted path keys are the same as in fieldTypeOverrides.
Compacted, Pump attaches Running and pins its Value leaf. A subtype such as Tank that inherits the attachment from Equipment pins with a sibling restriction like the second one alone:
{
"@id": "urn:atlas:ontology:plant:Pump",
"rdfs:subClassOf": [
{"@id": "urn:atlas:ontology:plant:Equipment"},
{"@type": "owl:Restriction", "owl:onProperty": {"@id": "urn:atlas:ontology:plant:Running"}, "owl:minCardinality": {"@type": "xsd:nonNegativeInteger", "@value": "0"}},
{
"@type": "owl:Restriction",
"owl:onProperty": {"@id": "urn:atlas:ontology:plant:Running"},
"owl:allValuesFrom": {
"@type": "owl:Class",
"owl:intersectionOf": {
"@list": [
{"@id": "atlas-common:TVQ"},
{"@type": "owl:Restriction", "owl:onProperty": {"@id": "atlas-common:Value"}, "owl:allValuesFrom": {"@id": "xsd:boolean"}}
]
}
}
}
]
}
Here Running ranges over a TVQ. For a union-ranged property such as Mode the pin is shorter, {"@type": "owl:Restriction", "owl:onProperty": {"@id": "urn:atlas:ontology:plant:Mode"}, "owl:allValuesFrom": {"@id": "xsd:string"}}. The JSON-LD above is the shape the store writes, compacted by hand. It is not copied from a shipped file, because no shipped library carries a type pin.
A reader tells a pin from an attachment by its facets: owl:allValuesFrom present and owl:minCardinality absent. The atlas:Source and atlas:Target restrictions on a relationship are never pins.
Three rules hold. A property pin is final: a type cannot pin a leaf the property fixed. A subtype only pins a leaf its ancestors left open. The pinned type must be a member of the leaf's union. Rule O-B24 reports a violation found in a file, and the write API refuses it up front. Rule O-B23 reports a concrete type that leaves a leaf open. The request fields and the refusal keys are in Pin a property's leaf on a type.
A domain can pin a library property on its own type. The pin is written in the domain's file, so the library is not edited and no other domain sees it.
Choosing a property's value shape#
Three mechanisms look alike, because all three read like an "object property" when you declare them. They compile to different things.
| Shape | How you declare it | What the compiler produces | Use it when |
|---|---|---|---|
| Scalar | range is an XSD type (xsd:double, xsd:string, xsd:dateTime) |
One column on the owning archetype | The value is self-contained and has no identity: a name, a flag, a rating |
| Compound (data type) | range is a Data Type local name, for example TVQ |
A logical parent plus one leaf column per field. Read in Cypher through the field: n.Data.Value |
The value is a fixed tuple that always travels together, above all a measurement |
| Compound, sourced per field (Composed) | range is a Data Type, and the instance uses the CompositeStrategy |
The same logical parent and leaf columns as a compound, with each field carrying its own strategy, and a nested data-type field able to be Composed in turn | The fields of one value have independent sources, for example a Tag's Data bound to a historian path while its UOM is Static |
| Relationship type | A relationship type with sourceNodeType/targetNodeType |
A real graph edge in the CSR, plus its own archetype | You want to traverse it, query from either end, or hang data on the connection |
| Reference | ReferenceStrategy on the instance |
An alias that reads another instance's property | One instance should surface a value that lives on another |
The distinction that matters most: an entity is never a property's value. A property's range is a
primitive or a data type, and the write API refuses an entity type as range; validation reports rule
O-B22 for one found in existing content. If you want the parts of a value sourced independently, make it
a data type and use CompositeStrategy (the Composed strategy in the UI) on the instance; the
attached properties of a former entity type become the data type's fields. If what you want is "a Pump is
driven by a Motor, and a Motor is a thing in its own right that other queries reach", you want a
relationship type.
Three further levers on the property definition, all on AddPropertyRequest rangeUnion - two or more XSD members compile to rdfs:Datatype plus an owl:unionOf; exactly
one collapses to a plain range. Mutually exclusive with fieldTypeOverrides.
fieldTypeOverrides- pin individual fields of a compound range to a narrower XSD type. Policed by rulesO-B6toO-B9. A compound field that accepts a union (TVQ.Value) leaves the property Defer until it is pinned;O-B23flags a type that uses a property that is still open, and that type cannot compile.subPropertyOf-rdfs:subPropertyOforganisational metadata, a local name.
Common mistake, still true: modelling a lookup value as an entity type. A manufacturer name is
xsd:string unless manufacturers carry properties you need to query independently. Every entity type
gets its own archetype with a column per property, so a type that only
ever holds a label is pure overhead.
Where each property's value comes from at runtime - Static, Bound, Calculated, Null, Composed, Reference - is Property Sourcing Strategies. This chapter does not restate it.
Compound values and the time axis#
This is the pattern for modelling anything a historian or connector feeds, and it is the section the previous chapter was missing entirely.
A Data Type is a compound value shape: an owl:Class marked
subClassOf atlas:CompoundPropertyType, whose fields are ordinary first-class properties linked in
field order (NodeKind.DataType, CompoundPropertyType /
CompoundField,). The canonical one ships in atlas-common:
{
"@id": "atlas-common:TVQ",
"@type": "owl:Class",
"rdfs:label": "TVQ",
"rdfs:comment": "Timestamp-Value-Quality measurement tuple - standard measurement representation",
"rdfs:subClassOf": [{"@id": "atlas:NodeBase"}, {"@id": "atlas:CompoundPropertyType"}]
}
with three field edges carrying atlas:fieldOrder 0, 1, 2 for Timestamp, Value and Quality -
and "isTimeAxis": "true" on the Timestamp edge.
The time axis is the modelling decision#
CompoundField.IsTimeAxis marks the one field that carries each sample's timestamp. At most one per Data Type. You declare it on write with
AddDataTypeRequest.TimeAxisField, naming one of the fields you already
listed:
POST /api/v1/branches/main/domains/plant-model/data-types
{
"name": "TVQ",
"fields": ["Timestamp", "Value", "Quality"],
"timeAxisField": "Timestamp"
}
The writer enforces two things:
DataType.UnknownTimeAxisField- the named field is not one offields.DataType.TimeAxisNotDateTime- the named field's property is notdateTime-ranged.
Why it matters. A compound with a declared axis is time-segmentable; a compound without one is "atomic" and is excluded from history retrieval. The axis is what range retrieval, point normalisation across inputs, and calculated range outputs all key. Declare the axis when you mint the Data Type. Adding it later means re-emitting the field roster.
The measurement pattern#
- Import
atlas-common(or declare your own compound) soTVQresolves. - Declare a property whose range is the compound, and attach it to the type that carries the
measurement. The shipped example is
atlas-common:Data, anowl:ObjectPropertywithrdfs:rangeatlas-common:TVQ. - On the instance, give that property a
BoundStrategywith abindingPathpointing at the connector tag (Property Sourcing Strategies). - Query through the field, never the bare property:
RETURN n.Data.Value.
Two things to carry with it:
- Bind measurements at the compound, not at a scalar. A scalar-ranged property bound to a multi-column connector rowset takes column 0, which for a TVQ-shaped source is the timestamp.
- An unsampled leaf holds no value, so
Value,TimestampandQualityeach read null. The compound itself still exists, so test the field:WHERE n.Temperature.Value IS NULL.Qualityitself isatlas-common:Quality, anxsd:integerraw OPC quality code.
Compound, that is data-type-valued, properties on relationship types persist in storage but are skipped at compile time, producing no columns. Do not rely on edge-property TVQ bindings until this limitation is lifted.
Associations that carry data: the reversed recommendation#
Old advice, now wrong: when an association carries its own data (an installation date on the link, a flow rate on a feed), promote it to an intermediate entity type with two relationships pointing at the endpoints.
Do not do that any more. A relationship type is now itself an archetype and a relationship instance is itself an instance. The native shape gets you everything the intermediate entity type was a workaround for, and costs less:
| Intermediate entity type | Reified relationship | |
|---|---|---|
| Property kinds available | scalar, compound | the same two - both are fed through one property pipeline |
| Traversal cost | two hops, plus a synthetic node to skip in every query | one hop; the edge is a CSR row and the reified handle rides in EdgeIds |
| Query form | MATCH (a)-[]->(x:PumpInstallation)-[]->(b) RETURN x.InstallDate |
MATCH (a)-[r:Feeds]->(b) RETURN r.flowRate |
| Containment semantics | none; the synthetic node is just another node | inherited, transitively |
| Cost when the association carries nothing | a node per link anyway | a relationship archetype with zero edge-properties is still defined and costs two built-in columns |
The mechanism, in one paragraph: node types and relationship types are fed through one
archetype-property-set builder, which is "what gives a relationship archetype the same property
kinds as a node: scalar and compound (leaf-flattened)". Every relationship type gets an archetype, even an empty one. At instance load a relationship instance "is reified into an entity by
the loop above, exactly like an entity instance, so no separate edge-reification pass is needed", and its endpoints are flattened into the CSR, where "the reified-edge
handle in EdgeIds is the relationship instance's OWN entity id".
Authoring it: the type#
Declare the edge properties as ordinary properties first, then attach them from the relationship
side. attachToTypes on a property resolves against entity types only,
so it cannot be used for edge data.
POST /api/v1/branches/main/domains/plant-model/properties
{ "name": "flowRate", "attachToTypes": [], "range": "xsd:double" }
POST /api/v1/branches/main/domains/plant-model/relationship-types
{
"name": "Feeds",
"baseType": "RelationshipBase",
"sourceNodeType": "Equipment",
"targetNodeType": "Tank",
"properties": ["flowRate", "pressure", "temperature"]
}
AddRelationshipRequest.Properties takes local names or full URIs of existing properties and
writes each as an owl:Restriction on the relationship class - the same shape Source and Target
use, no new vocabulary. On update,
UpdateRelationshipApiRequest.Properties is a full replacement: null leaves the set alone, an
empty list clears it.
Authoring it: the instance#
A relationship instance is an ordinary instance map whose type is a relationship archetype, with
top-level source and target (AddInstanceMapRequest.Source / .Target,). Written through the ordinary instance-map path,
POST /api/v1/branches/{branch}/domains/{domain}/instance-maps/{fileName}/instances. On disk it looks like this (verbatim from the shipped test
fixture, tests/.../TestData/test-fixtures/domains/relationship-test/instances/fixtures.map.json):
{
"id": "feeds-pump01-tank01",
"name": "Feeds-Pump01-Tank01",
"type": "urn:atlas:ontology:juice-factory:Feeds",
"instanceId": "urn:atlas:instance:juice-factory:feeds-pump01-tank01",
"source": "urn:atlas:instance:juice-factory:pump-01",
"target": "urn:atlas:instance:juice-factory:tank-01",
"properties": {
"urn:atlas:ontology:juice-factory:flowRate": [
{ "type": "StaticStrategy", "value": 2.5 }
],
"urn:atlas:ontology:juice-factory:pressure": [
{ "type": "CalculatedStrategy", "functionUri": "urn:atlas:ontology:juice-factory:doubler",
"arguments": [ { "type": "literal", "parameter": "urn:atlas:ontology:juice-factory:param/input",
"order": 0, "value": 6.0 } ] }
],
"urn:atlas:ontology:juice-factory:temperature": [
{ "type": "BoundStrategy", "bindingPath": "SENSORS.PUMP01.TEMP" }
]
}
}
Note what that shows: edge properties take the same strategies as node properties, Static, Bound and Calculated alike, and they resolve end-to-end. The query is one hop:
MATCH (p:Pump)-[r:Feeds]->(t:Tank) RETURN r.flowRate
source and target are instance-URN templates, resolved per expanded instance exactly like
instanceId, then resolved against the full expanded instance set. That means a relationship can
be the endpoint of another relationship (a meta-relationship) with no ordering rules.
The superseded inline edge form#
The inline relationships[] authoring form and its AddInstanceMapEdgeRequest /
UpdateInstanceMapEdgeRequest bodies are superseded. There is no
relationships member on any current instance-map request record, and the API's JSON binding ignores
unknown members, so posting the old shape returns 201 with zero edges created. Nothing warns you.
The failure surfaces later as an empty result set from a MATCH that should have matched.
A stale instance-map file on disk behaves differently and better: the store parses instance files
with strict member handling, so a field that is not part of the current shape is reported at parse
time as I-A1. A file import therefore tells you, while an API write ignores the unknown property.
The three ways a relationship instance is rejected#
All three are I-B* errors from InstanceRelationshipRules:
| Rule | Condition |
|---|---|
I-B5 |
The map declares source/target but its type is not a relationship archetype (not a class subClassOf RelationshipBase or ContainsBase). The message suggests the closest known relationship URI |
I-B6 |
An endpoint does not resolve against the union of all expanded instance ids. Fires independently of I-B5 |
I-B7 |
A resolved endpoint's instance type does not satisfy the archetype's sourceType/targetType constraint. Declares I-B5 as a prerequisite |
Two limitations to design around#
- Reverse traversal does not carry edge properties. Traversing an incoming edge binds the
relationship variable to entity id
-1, soMATCH (t:Tank)<-[r:Feeds]-(p:Pump) RETURN r.flowRatereturns the column default rather than the edge value. Where you need edge data, write the pattern in the forward direction. - Give edge properties a scalar value type; compound types belong on the connected entity types.
Containment and association#
Two framework roots, and every relationship type descends from one of them:
ContainsBase- the target is part of the source. This is the hierarchy of the model.RelationshipBase- an association with no ownership implied.
Plant contains Area contains Unit contains Equipment (containment)
Pump -[Drives]-> Motor (association)
Sensor -[Measures]-> Tank (association)
Containment is a DAG, not a tree. Multiple containment parents are explicitly allowed. The only
containment constraint the engine enforces is acyclicity: InstanceContainmentCycleRule fires
I-B8 on a cycle, including a self-loop, "so we only fire on a cycle, not on multi-parent diamonds". If your model genuinely has a thing that lives in two
hierarchies, model it that way.
Containment is inherited transitively. IsContainment is true when the type is rooted at
ContainsBase either directly or through its own base chain, so a
VesselContainsPump type derived from a containment parent is containment too, with no extra
declaration.
Nothing cascades on delete. There is no cascade-delete path in the codebase and no configuration that enables one. Deletes are dependency-guarded and return an RFC-9457 delete-blocked problem naming the dependents.
Traversing containment in Cypher#
The alias is lowercase contains, and the comparison is ordinal:
MATCH (p:Plant)-[:contains*]->(e:Equipment) RETURN e
[:CONTAINS*] resolves to nothing and returns an empty result set with no error, because no shipped
relationship class has that local name. What the lowercase token buys you is inheritance-wide
matching: it resolves to atlas:ContainsBase and then to every descendant rel type
(ResolveRelTypes into GetRelTypeDescendants), so it matches VesselContainsPump,
PlantContainsArea and every sibling without naming them.
Variable-length traversal is capped. ExpandAllOperator defaults to 10 hops with a hard
ceiling of 50. A rollup over a hierarchy deeper than 10
truncates rather than erroring. Set explicit bounds ([:contains*1..15]) when the hierarchy is deep,
and do not model a hierarchy that needs more than 50 levels.
Rules of thumb, restated#
- Removing the source should logically remove the target, and the target lives in exactly one place in the model hierarchy: containment.
- Source and target exist independently: association.
- The connection carries data: either root, with edge properties. This is not a reason to reach for an intermediate entity type.
Relationship type hierarchies#
Relationship types have their own inheritance tree, distinct from the framework root.
RelationshipDefinition carries both BaseType (the framework root) and Parent, "the immediate
rdfs:subClassOf user-defined super-relationship-type".
This is a modelling lever worth using. Declare a general Feeds and specialise it:
Feeds (Source: Equipment, Target: Tank)
|- FeedsLiquid (Source: Pump, Target: Tank)
|- FeedsSolid (Source: Auger, Target: Silo)
Two things you get for free:
- One query spans the family. Querying the parent matches "every type that derives from it via
the World's rel-type inheritance", the same way entity label matching
spans subclasses.
MATCH (e:Equipment)-[r:Feeds]->(t:Tank)findsFeedsLiquidedges. - Edge properties are inherited. The attached property URIs reaching the compiler are "already
merged own+inherited server-side", so a property attached to
Feedsis a column on every child archetype.
Children may only narrow, never widen. If the parent declares an endpoint, the child's endpoint
must be a subtype of it, or O-B10 fires:
Relationship 'plant:ValveFeeds' narrows its Source from 'plant:Equipment' to 'plant:Valve',
which is not a subtype of the parent's Source. A child relationship type may only narrow
Source/Target to subtypes of the parent's declared types.
Endpoint constraints, including leaving one open#
Endpoints are a pluggable constraint, not a raw type name. The shipped kind is EntityClassEndpoint:
the resolved endpoint instance's type must be, transitively, a subclass of ClassUri. SourceType/TargetType are the endpoint-neutral surface;
sourceNodeType/targetNodeType are the v1 wire names that back them. O-B4 additionally requires that whatever a Source/Target
restriction targets is transitively a subclass of atlas:NodeBase.
Leaving an endpoint open is a legitimate modelling choice. Omit it and the endpoint accepts any
node - useful for a generic Annotates or DependsOn. Clearing one later is non-obvious:
UpdateRelationshipApiRequest's endpoint fields are three-state - null
or omitted leaves the endpoint unchanged, "" clears it back to "any node", a name or URI replaces
it.
Libraries#
A library is a reusable, T-Box-only collection of types, properties, relationship types, data types and functions that domains import. Reach for one when the same types appear in more than one domain, when you are enforcing a company standard, or when you are implementing an industry standard.
The four shipped seeds#
| Library | What it holds |
|---|---|
atlas-core |
The core atlas: vocabulary |
atlas-common |
TVQ, Timestamp, Value, Quality, Folder, the abstract Tag base and its four typed Tags (AnalogTag, DiscreteTag, IntegerTag, StringTag), Description, Format and the Data binding property |
isa95-common |
"OPC-10030 ISA-95 Common Object Model" |
Those are the real names. ISA95Base, IEC61850Common, isa95-common and base-equipment do not
resolve: the first two never existed, and isa95-common is not a library Atlas reads.
Do not edit atlas-core. It is force-upgraded on every startup and your edits will be overwritten. Create your own library and import
atlas-common into it.
Import order is automatic#
Libraries are never compiled. Only a domain has a World: the world resource is mounted only under
/api/v1/branches/{branch}/domains/{domain}. There is no library
compile route and nothing to sequence.
Load order is derived for you. DependencyResolver.ResolveLoadOrder walks @context references and
"returns logical file paths in topological order (dependencies first)". Your only obligation is that a library exists before a domain
imports it.
Import into an existing domain with
POST /api/v1/branches/{branch}/domains/{domain}/imports, body {"library": "atlas-common"}, or list libraries at domain creation
time (CreateV1DomainRequest.libraries). The legacy non-branch-scoped
/api/domains/{domain}/imports routes are superseded by the branch-scoped form.
Circular library dependencies are rejected hard. LibraryA importing LibraryB which imports
LibraryA throws at load, not as a validation violation:
Circular dependency detected involving: {file}. Extract the shared
types into a third library both import.
Provenance, and the absence of overrides#
Every read model carries sourceLibrary: the short name of the imported library that declared the
element, null for elements declared in the loading scope.
That is how you tell a local type from an imported one at a glance, and it is the practical answer
to "why did this type change under me".
There is no local override of a library element. A change to a library type reaches every domain that imports it. The one thing a domain does add is a type pin: a domain can pin an open leaf of a library property on its own type, which changes nothing in the library (see Pinning an open leaf on a type). That
is the point for standards enforcement and a hazard otherwise. Library views also render with strict
scope: a library shows only the types it declares, and cross-library extends references appear as
inline annotations rather than imported nodes.
Do not extract to a library prematurely. Start with domain-local types and promote when a second domain actually needs them. Promotion is cheap; unwinding a library that turned out to encode one site's assumptions is not.
Validation is a gate, not a report#
There is no soft warning channel to compile past.
CompileResult is (EntityCount, EdgeCount, ArchetypeCount, CompiledAt, FunctionErrors[]) -
no warnings member. A warnings[] response array was explicitly
rejected in review. ValidationSeverity does have a Warning member, but exactly one rule emits it
(I-B22, unbound function parameter), it never blocks anything, and it can never reach a compile
response because compile throws on errors only.
Several conditions people expect to surface as warnings are either a hard error or do not exist at all:
| Expected "warning" | Reality |
|---|---|
Unreachable type: {name} |
No such check. A type unreachable from any containment hierarchy is legal |
Unresolved property domain: {name} |
There is no property domain. The nearest real rule is O-B1, an unresolved cross-reference URI, and it is an error |
Circular subclass chain |
Real condition, wrong framing: O-B2, severity Error, message rdfs:subClassOf cycle detected: {chain}. |
Missing library dependency |
Load order is topological and a cycle throws; a missing dependency is a load failure, not a warning |
Duplicate property: {name} on {type} |
Impossible. Property names are unique per scope, so the second create is a 409 long before compile |
| "Warnings do not prevent compilation" | Compile aborts. It does not degrade or exclude affected types |
The real rule families, five of them, 59 rules:
| Family | Scope |
|---|---|
O-A1 to O-A12 |
Ontology format. O-A1 and O-A2 are fatal and halt the pipeline |
O-B1 to O-B4, O-B6 to O-B10 |
Ontology semantic. No O-B5 |
O-F1 to O-F5 |
Function save. These run in a synchronous pre-save validator, so a bad function save returns 422 and persists nothing |
I-A1 to I-A11 |
Instance format |
I-B1 to I-B22 |
Instance semantic |
Each violation carries ruleId, filePath, jsonPointer, message, often a suggestion, and for
instance rules an instanceId. The full catalogue, with every message and its fix, is
Validation Rules Reference.
Two failure paths, and they are not the same:
- Compile (
PUT .../world) with error-severity violations returns 422 RFC-9457 withviolations[], and builds no world. - Save with error-severity violations returns 2xx, commits into the caller's private draft
session, and does not advance the target branch. The
only signal is the
Atlas-Validation-Violationsresponse header.
To see the outstanding list without compiling, GET /api/v1/branches/{branch}/domains/{domain}/validation
- and, because a library has no compile at all, this is the only way to validate a library:
GET /api/v1/branches/{branch}/libraries/{library}/validation.
Recommended build order#
For an import pipeline building an ontology programmatically. Every call needs a Pulse bearer whose
aud includes Atlas's Pulse:Auth:ClientId; Pulse:Auth:Enabled=true is the shipped default.
| Step | Call |
|---|---|
| 1 | Create libraries: POST /api/v1/libraries with {name, dependencies[], description} |
| 2 | Populate library entity types, parents before children: POST /api/v1/branches/{branch}/libraries/{library}/entity-types |
| 3 | Library properties, data types, relationship types, functions: the same .../libraries/{library}/... resources |
| 4 | Create the domain: POST /api/v1/domains with {name, branch, libraries[], description} |
| 5 | Import any further libraries: POST /api/v1/branches/{branch}/domains/{domain}/imports with {"library": "..."} |
| 6 | Domain entity types, parents before children |
| 7 | Domain properties, with attachToTypes, or attach afterwards via PUT .../entity-types/{name}/properties/{property} |
| 8 | Domain data types, declaring timeAxisField where the compound is a measurement |
| 9 | Domain relationship types, with properties[] for edge data |
| 10 | Instance maps, including relationship instance maps with source/target POST .../instance-maps/{fileName}/instances |
| 11 | Check the Atlas-Validation-Violations header on every write. Present means the write went to your draft session, not the branch |
| 12 | If you have a draft: GET /api/v1/branches/{branch}/draft, then POST /api/v1/branches/{branch}/draft/publish |
| 13 | Compile: PUT /api/v1/branches/{branch}/domains/{domain}/world. A 422 with violations[] means nothing was built |
| 14 | Verify: GET .../world reports isStale and compiledAtSha; GET .../validation lists outstanding violations without recompiling |
Ordering notes:
- Steps 1 to 3 before step 4 only because a library must exist before it is imported. The load order inside the domain is resolved topologically for you.
- Properties, data types and relationship types must exist before anything references them: a Data
Type's
fieldsand a relationship'sproperties[]both take existing property names, andUrnHelper.ResolveToExistingPropertyUrisresolves them at write time. - A 201 is not proof the content is on the branch. That is step 11, and it is the single most common pipeline: two successful responses and no visible change, with the work waiting in a draft session.
- You do not need a recompile after a restart. Atlas auto-recompiles every recorded (branch, domain) pair at boot.
Anti-patterns#
Promoting a data-bearing association to an intermediate entity type. Superseded. Use a relationship type with edge properties. This is the pattern most pre-2026 models will contain, and collapsing it removes a hop from every query that crosses it.
Posting the inline relationships[] form. Returns 201 and creates no edges - author each connection as its own instance map.
Over-normalisation. A manufacturer name is a string. Every entity type gets an archetype with a column per property, so a type that carries only a label is pure overhead.
God type. One Equipment with 50 properties covering every asset. Split into a hierarchy and
attach each property to the most specific type that needs it. DefineArchetypes gives every type a
column per property, so a god type makes every instance of every subtype pay for every attribute.
Redeclaring a shared property per type. Attachment is many-to-many. The second declaration is a
409 in the same scope, and across scopes it fragments the model into near-duplicate URNs.
Naming a property the same as a type. One namespace across all five kinds. 409 KeyCollision.
Editing a shipped seed library. Force-resynced at startup. Your changes disappear at the next restart.
Premature library extraction. Promote to a library when a second domain needs the types, not before.
Modelling a measurement as a bare scalar. Bind at the compound. A scalar bound to a TVQ-shaped source takes column 0, the timestamp.
Defaults belong on the base type#
A property type can carry a default sourcing strategy, and Atlas resolves it up the type hierarchy, so declare the property and its default on the highest type that should share it. Every subtype carrying the property inherits both, and an instance map overrides the default wherever one asset differs.
That keeps a plant-wide conversion or set-point in one place instead of repeated across every instance map, and it means adding a subtype later inherits the behaviour without further authoring. Where two types in one ancestry declare a default, the nearest to the instance wins.
ReferenceStrategy is not available as a type default: an alias names one specific instance, so it
belongs on the instance map. See
Property Sourcing Strategies.
Related articles#
- Defining Types and Schemas - authoring mechanics for all five kinds, field by field
- Property Sourcing Strategies - Static, Bound, Calculated, and the three others
- Validation Rules Reference - the full
O-A*/O-B*/O-F*/I-A*/I-B*catalogue - Atlas Developer Overview - the eight surfaces and which one to build against
- JSON-LD and the Atlas Data Model - how these definitions persist
- Instance Maps Programming Guide - instance-map authoring, including Composed and Reference strategies
- openCypher Query Reference - traversal syntax, and the conventions that shape a result
- Compile & Query Workflow - compile, draft sessions, staleness