Docs/Developer/Ontology Design Patterns
DeveloperConcept

Ontology Design Patterns

Modelling guidance for the Atlas API: inheritance versus separate types, shaping a property's value, and modelling an association.

Updated 9 Nov 2026Timebase Docs

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, CompileResult has 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/nodes and /api/relationships - is not part of the current surface, nor is the /api/v2 family. A request to a URL outside the API surface is answered with 200 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 return Pump and Compressor instances. 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.AbstractType and 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.AttachToTypes is a list of entity types to attach to in the same commit. The wire key is attachToTypes.
  • 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:Restriction on the class, never as rdfs:domain on the property.

There is no Domain field 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 rules O-B6 to O-B9. A compound field that accepts a union (TVQ.Value) leaves the property Defer until it is pinned; O-B23 flags a type that uses a property that is still open, and that type cannot compile.
  • subPropertyOf - rdfs:subPropertyOf organisational 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 of fields.
  • DataType.TimeAxisNotDateTime - the named field's property is not dateTime-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#

  1. Import atlas-common (or declare your own compound) so TVQ resolves.
  2. 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, an owl:ObjectProperty with rdfs:range atlas-common:TVQ.
  3. On the instance, give that property a BoundStrategy with a bindingPath pointing at the connector tag (Property Sourcing Strategies).
  4. 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, Timestamp and Quality each read null. The compound itself still exists, so test the field: WHERE n.Temperature.Value IS NULL. Quality itself is atlas-common:Quality, an xsd:integer raw 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, so MATCH (t:Tank)<-[r:Feeds]-(p:Pump) RETURN r.flowRate returns 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) finds FeedsLiquid edges.
  • Edge properties are inherited. The attached property URIs reaching the compiler are "already merged own+inherited server-side", so a property attached to Feeds is 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 with violations[], 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-Violations response 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.

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 fields and a relationship's properties[] both take existing property names, and UrnHelper.ResolveToExistingPropertyUris resolves 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.

esc
↑↓ navigate · ↵ open · ⌘K anywhere