Docs/Developer/Instance Maps Programming Guide
DeveloperReference

Instance Maps Programming Guide

An instance map is a template. You write a definition; Atlas expands it into the concrete instances your queries return.

Updated 5 Nov 2026Timebase Docs

Overview#

An instance map is a template. You write a definition; Atlas expands it into the concrete instances your queries return. This guide is about authoring those definitions as data: the exact shape of a .map.json file, the expansion arithmetic that decides how many instances you get, the six property strategies, and the write-then-compile workflow an import pipeline has to follow.

It is written for a developer generating instance maps from a script, an ETL job, an asset register export, or an agent tool - anyone who will never open the Atlas drawer.

Three companion articles own material this guide does not repeat:

Important

read Six shapes to get right before you write against this API. Five of the six return a success response with a graph that is not what you intended.

Tip

loading more than a handful of items? Write one template per type rather than one singleton per item. See Loading many instances: templates, not singletons.

Six shapes to get right#

Each of these is accepted without an error and then does not do what you expect.

Mistake Correct form What actually happens
relationships[] inside an entity definition A separate definition with top-level source and target Over the API: 201 Created, zero edges. In a file: the whole file fails to parse
Cartesian expansion over map entries Linear expansion over the longest entry Fewer instances than you counted on, with the wrong identities
bindingPath: "historian://..." Slash-delimited from the connector instance id Matches no advertised path, so nothing is subscribed
{i} placeholders [Name] placeholders The text is left verbatim, the definition has no variables, you get one instance called pump-{i}
Short property keys ("speed") Full urn:atlas:ontology: URIs Accepted on write, flagged I-A4, dropped at compile
"strategy": "Manual" / "Bound" "type": "StaticStrategy" / "BoundStrategy" Rejected by I-A5. There is no Manual strategy
POST .../compile PUT /api/v1/branches/{branch}/domains/{domain}/world The route does not exist, and a dead route returns the SPA shell as 200 text/html
PUT .../instance-maps/{file}/instances/{id} PATCH .../instance-maps/instances/{id} Same: 200 text/html, nothing updated
instanceBinding sources Ignored over the API; fatal to the whole file on disk
Bindings, BindKey properties, keyed by full URI Atlas has no field by either name

The inline Relationship-Instance edge sub-resource wire bodies are superseded along with the inline relationships[] authoring form. Author each connection as its own instance map definition.

The file#

Definitions live in the domain's Git repository at instances/{stem}.map.json. One file holds many definitions. The root must be a JSON array.

<domain>/
  ontology.jsonld
  instances/
    line-a.map.json
    topology.map.json

The file format, and the asymmetry that will bite you#

Atlas reads a .map.json file with these rules:

Rule Consequence for an author
Unknown property names are refused One unrecognised property name fails the entire file
camelCase names Field names on disk are camelCase
Nulls omitted Atlas omits null members when it rewrites the file
Indented Atlas reformats files it writes

The API uses different rules. It also uses camelCase names, omits nulls and writes enum values as camelCase strings, but it does not refuse unknown property names: an unrecognised JSON property on a request body is ignored.

Important

The two halves of Atlas disagree about unknown members, and neither behaviour is safe on its own.

  • On disk, one stale field (instanceBinding, relationships, Bindings, BindKey, a typo) fails the parse and is reported as I-A1 against the whole file. Every definition in that file disappears from validation and from compile, not just the bad one.
  • Over the API, the same stale field is discarded without comment. POST a definition carrying relationships[] and you get 201 Created, a persisted definition with no endpoints, and zero edges at the next compile.

A generator that round-trips (POST, then GET, then diff) catches the API half. Nothing catches it at write time.

Because the API drops unknown members before persisting, a definition written through the API always lands on disk as a parseable file. Round-tripping is therefore not a test that your intent survived - only that your JSON was accepted.

The two I-A1 messages you will see when a hand-written file is wrong:

Instance map file root must be a JSON array; got Object.

Instance map file is not parseable JSON at line 14, column 7: ...

The definition record#

A definition accepts exactly eleven members. The file on disk and the API request body carry the same field set.

Field Type Required Notes
id string Yes Identifies the definition, not the instances. Must be globally unique across every .map.json file in the domain (I-A3, and a 409 at write time). PATCH cannot change it
name string Yes Metadata about the definition, shown on definition-oriented rows only. This is not the per-instance display name
type string Yes Full URI of the entity type or relationship archetype. A short local name is stored verbatim and then fails I-B1
instanceId string Yes Identity template for each expanded instance. Supports [Name] placeholders. The interpolated result becomes the identity verbatim - no namespace is prepended
label string No Per-instance display-name template, same placeholder syntax and per-index resolution as instanceId. Omit it and instances display their identity's local name
description string No Free text
sources object No Named connector rowset declarations. Presence selects RowsetTemplate mode
map array No The named variables everything else interpolates
properties object No Full property URI to a list of strategy entries
source string No Relationship only. Instance-URN template of the source endpoint
target string No Relationship only. Instance-URN template of the target endpoint

id, name, type and instanceId are positional constructor parameters on the request record. Persisted-file validation is narrower: I-A2 enforces only id, type and instanceId. There is no rule for a missing name in a file.

Note

name versus label is the single most common authoring mistake. name names the recipe. label names each thing the recipe produces. Generators that set name per instance produce N definitions where one would do, and still show raw identifiers in every instance view.

Singleton definition#

No sources, and no map entry that produces more than one value. Produces exactly one instance.

[
  {
    "id": "brewery-site",
    "name": "Brewery Site",
    "type": "urn:atlas:ontology:brewing:Site",
    "instanceId": "urn:atlas:instance:brewing:site-riverside",
    "label": "Riverside Brewery",
    "properties": {
      "urn:atlas:ontology:brewing:siteCode": [
        { "type": "StaticStrategy", "value": "RVS" }
      ]
    }
  }
]

Mode detection is mechanical: sources non-empty wins and selects RowsetTemplate; otherwise any map entry whose data parses to more than one value selects StaticTemplate; otherwise Singleton. There is no mode field to set.

Expansion is linear, not Cartesian#

Atlas counts the values each map entry produces and takes the largest count as the number of instances. For instance i it reads the i-th value of every entry at the same time.

Read map entries as parallel columns of a table, not as nested loops. The longest column sets the row count. Every other column is read at the same row index. Two entries of three values each give you three instances, not nine.

Past the end of a shorter column, wrap decides:

wrap Behaviour past the end
false (default) Repeats the last value
true Cycles modulo the column length

An example that does not do what it looks like#

{
  "id": "cooling-towers-broken",
  "name": "Cooling Towers",
  "type": "urn:atlas:ontology:plant:CoolingTower",
  "instanceId": "urn:atlas:instance:plant:CT-[Area]-[Num, 00]",
  "map": [
    { "name": "Area", "data": "North, South" },
    { "name": "Num",  "data": "1-3" }
  ]
}

The old guide claimed six instances. Here is the actual arithmetic. Area parses to two values, Num to three, so instanceCount is 3:

Index Area Num instanceId
0 North 1 urn:atlas:instance:plant:CT-North-01
1 South 2 urn:atlas:instance:plant:CT-South-02
2 South (past the end, wrap: false repeats the last) 3 urn:atlas:instance:plant:CT-South-03

Three instances, two of them in the South, and no CT-North-02 anywhere. Setting "wrap": true on Area changes index 2 to North and still yields three. ### The corrected form that produces what you wanted

Make every column the same length. Six cooling towers, three per area:

{
  "id": "cooling-towers",
  "name": "Cooling Towers",
  "type": "urn:atlas:ontology:plant:CoolingTower",
  "instanceId": "urn:atlas:instance:plant:CT-[Area]-[Num, 00]",
  "label": "[Area] Cooling Tower [Num]",
  "map": [
    { "name": "Area", "data": "North*3, South*3", "wrap": false },
    { "name": "Num",  "data": "1, 2, 3, 1, 2, 3", "wrap": false }
  ],
  "properties": {
    "urn:atlas:ontology:plant:fanCount": [
      { "type": "StaticStrategy", "value": 4 }
    ]
  }
}

North*3, South*3 expands to six values (North, North, North, South, South, South); 1, 2, 3, 1, 2, 3 expands to six. instanceCount is 6, and you get CT-North-01, CT-North-02, CT-North-03, CT-South-01, CT-South-02, CT-South-03.

For a generator, the rule is: emit fully materialised parallel columns of equal length. wrap exists for the deliberate cycling case rather than as padding.

The alternative, and usually the more readable one, is one definition per group:

[
  {
    "id": "cooling-towers-north",
    "name": "Cooling Towers - North",
    "type": "urn:atlas:ontology:plant:CoolingTower",
    "instanceId": "urn:atlas:instance:plant:CT-North-[Num, 00]",
    "label": "North Cooling Tower [Num]",
    "map": [ { "name": "Num", "data": "1-3", "wrap": false } ]
  },
  {
    "id": "cooling-towers-south",
    "name": "Cooling Towers - South",
    "type": "urn:atlas:ontology:plant:CoolingTower",
    "instanceId": "urn:atlas:instance:plant:CT-South-[Num, 00]",
    "label": "South Cooling Tower [Num]",
    "map": [ { "name": "Num", "data": "1-3", "wrap": false } ]
  }
]

Three instances each, six in total, and the identities are obvious from the file.

The map array#

Field Type Required Notes
name string Yes Referenced as [name]. Case-sensitive
data string Yes Count-encoded grammar in singleton and static-template mode; a [sourceName.columnName] reference in rowset mode
wrap boolean No Default false

The data grammar#

Atlas tries the four patterns below in order, then falls back to a comma split, then to a single value. A data value never fails to parse.

Form Example Values produced
Single value North North
String CSV a, b, c a, b, c
Numeric range 1-5 1, 2, 3, 4, 5
Padded range 01-05 01, 02, 03, 04, 05
Range with step 0-8/2 0, 2, 4, 6, 8
Multi-range 1-3, 10-12 1, 2, 3, 10, 11, 12
String multiplier High*3, Low*1 High, High, High, Low
Number multiplier 500*2, 750*2 500, 500, 750, 750

Two rules a generator has to encode explicitly:

Important

In the multiplier form, a string value must match [a-zA-Z][a-zA-Z0-9]* - letters and digits only, and no space before the *. Row 1*8 does not match the multiplier regex. It falls through to the plain comma split and produces two literal values, Row 1*8 and Row 2*8, instead of sixteen instances. Emit Row1*8. A hyphen breaks it the same way.

Note

A range written with leading zeros keeps the padding. 01-05 yields 01 through 05 with no format specifier needed. This is often simpler than [Num, 00] when the padding is uniform.

Parse being total is why the validator does not reuse it: I-A7 duplicates the grammar precisely so it can reject garbage that Parse would happily treat as a single value.

Placeholders#

The syntax is [Name], optionally [Name, Format]. The regex is \[(\w+)(?:,\s*([^\]]+))?\].

Important

{i} is not a placeholder. Atlas does not recognise it, leaves it in the text verbatim, and - because the definition then has no multi-value map entry - falls through to singleton mode. You get exactly one instance whose identity literally contains {i}. Nothing warns you. The same applies to $name, %name% and ${name}.

Template Value 1 resolves to
[Num] 1
[Num, 00] 01
[Num, 000] 001
[Num, FL00] FL01

The format specifier is a leading alpha prefix plus a .NET numeric format. A non-numeric value is returned unchanged rather than erroring.

Placeholders are interpolated into:

Location Interpolated Checked when
instanceId Yes Save (I-A8)
label Yes Save (I-A8)
source, target Yes Save (I-A8)
properties[].bindingPath Yes Compile only
properties[].value (strings, and string leaves inside objects and arrays) Yes Compile only
properties[].instanceUri (ReferenceStrategy) Yes Compile only
properties[].propertyUri, functionUri, member URIs No n/a
properties[].arguments[].value No (harvested for I-A8 only) n/a

An unknown placeholder in a binding path or a static value is not a save-time violation. It throws during expansion:

Placeholder references undefined map entry: [Cap] in template: [Cap]

Every variable must be referenced#

I-A8 runs in both directions. An unresolved placeholder is a violation, and so is a declared variable nothing uses:

map[] entry name 'Area' is an orphan — no instanceId, label, property strategy, or relationship template references it.

References from instanceId, label, source, target, any strategy bindingPath or string value, an argument value, or anything nested inside a CompositeStrategy's members all count.

Property strategies#

properties maps a full property URI to a list of strategy entries. Both halves matter.

The key must be a full URI, or the instance map reports rule I-A4:

Property key 'Site' is not a full URI. Property keys must start with 'urn:atlas:ontology:' (short names are dropped at compile).

The message means what it says. A short key is accepted by the API, reported as a violation, and then dropped at compile - the property simply does not exist on the instance, and no error mentions it again. There is one URN namespace for all six ontology kinds: urn:atlas:ontology:{scope}:{LocalName}. There is no urn:atlas:fn:, urn:atlas:prop:, urn:atlas:class: or urn:atlas:rel:. Instance identities use the separate urn:atlas:instance: base.

The value is a list because strategies are time-intervalled. Each entry may carry validFrom (an ISO-8601 instant); omitted means from the beginning of time. If a property has one source for its whole life, the list has one entry.

The six strategy types#

The discriminator is type, and the allowlist has exactly six members:

type Required fields (I-A6) What it does
StaticStrategy value A fixed value: string, number, boolean, or a nested object for a compound type
BoundStrategy bindingPath Live values from a connector
CalculatedStrategy functionUri, arguments Computed by an ontology function
NullStrategy none Explicitly unset
CompositeStrategy members (non-empty) Composed: a data-type property whose fields each get their own strategy list. members is keyed by the field's property URI
ReferenceStrategy instanceUri, propertyUri Live alias of another instance's property. No copy, no edge

Anything else is I-A5:

Strategy type 'Manual' is not recognised. Allowed values: StaticStrategy, BoundStrategy, CalculatedStrategy, NullStrategy, CompositeStrategy, ReferenceStrategy.

Note

There is no Manual strategy and never has been. The place to write a value in by hand is StaticStrategy. The UI label for StaticStrategy is Static.

I-A5 and I-A6 recurse into CompositeStrategy.members, so a nested member entry is held to the same allowlist and the same required-field checks.

Full entry shape:

Field Applies to
type All
validFrom All
value StaticStrategy
bindingPath BoundStrategy
functionUri, arguments CalculatedStrategy
members CompositeStrategy
instanceUri, propertyUri ReferenceStrategy

bindingPath is not a URI#

states the format on the record itself:

BindingPath is a connector binding path — slash-delimited segments starting with the connector's instance id, never a 'scheme://' URI — and supports placeholder interpolation (e.g. 'historian-juice-factory/The Juice Factory/131-TT-[Num].PV').

"urn:atlas:ontology:juice-factory:temperature": [
  { "type": "BoundStrategy",
    "bindingPath": "historian-juice-factory/The Juice Factory/[Tag]" }
]
Important

historian://, opc:// and sql:// are not schemes Atlas understands - they are not schemes at all. A path written that way is accepted on write, passes every validation rule, compiles cleanly, and then produces no value for as long as it stays in the file. There is no error, no warning, and no visible mark anywhere. Every historian://... sample in circulation is wrong.

After interpolation, the concrete path must match a path a connected connector has advertised, or match one of that connector's templates. An unresolvable path is skipped at subscription time.

Two documented exceptions to the leading-instance-id rule: the HTTP/REST plugin advertises each endpoint's configured BindingPath verbatim with no instance-id prefix, and the Timebase Reference plugin advertises fixed fake/... paths. For those two, use the path exactly as the plugin advertises it.

Per-connector path shapes are in Property Sourcing Strategies.

Important

Unlike sources[].bindingPath, a BoundStrategy.bindingPath is not validated at author time. Do not generate one. Read it from a working definition in the same domain, or from the source system.

Calculated arguments#

"urn:atlas:ontology:juice-factory:temperatureCelsius": [
  {
    "type": "CalculatedStrategy",
    "functionUri": "urn:atlas:ontology:juice-factory:FahrenheitToCelsius",
    "arguments": [
      { "type": "propertyRef",
        "parameter": "urn:atlas:ontology:juice-factory:param/fahrenheit",
        "propertyUri": "urn:atlas:ontology:juice-factory:temperature",
        "order": 0 }
    ]
  }
]
Field Notes
type "literal" or "propertyRef"
parameter Either the bare parameter name or the full parameter predicate URI. Both resolve to the same parameter; the bare name is rejected only when the function declares two parameters with that name (I-B22)
value The literal's value
propertyUri The property a propertyRef reads
instanceUri Optional, propertyRef only. Reads the input from another instance instead of this one. Checked by I-B19 (instance exists) and I-B20 (property declared on that instance's type)
order Argument position. Defaults to 0 and is omitted in the shipped fixtures

functionUri is an ordinary ontology URI: urn:atlas:ontology:{scope}:{FunctionName}. urn:fn:... does not resolve and fails I-B9.

Note

A declared parameter you leave unbound is I-B22 at Warning severity - the only Warning-severity instance rule. It never blocks a save or a compile, and the message tells you the symptom: ...defaults to null/0 at runtime, which commonly makes the calculated value 0.

Static values, including compounds#

Placeholder interpolation recurses into object and array leaves, which is how a compound property gets a per-instance static value. From a shipped example:

"urn:atlas:ontology:juice-factory:temperature": [
  {
    "type": "StaticStrategy",
    "value": {
      "Timestamp": "2026-04-22T08:30:00Z",
      "Value": "[Temp]",
      "Quality": "Good"
    }
  }
]

Relationships are their own instance maps#

A relationship is not a sub-list inside an entity definition. It is an ordinary instance map whose type is a relationship archetype, carrying top-level source and target.

Taken verbatim from tests/.../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 }
    ]
  }
}

Four rules govern the endpoints.

1. Both, or neither. The implementation is three lines:

var hasSource = !string.IsNullOrEmpty(def.Source);
var hasTarget = !string.IsNullOrEmpty(def.Target);
if (hasSource == hasTarget) return; // both present (relationship) or both absent (entity)

Declaring one without the other is I-A9:

A relationship instance map must declare both 'source' and 'target'; 'target' is missing.

Important

The XML class comment at the top of that same file still reads "I-A9 - Each relationship has type AND exactly one of source/target". It contradicts the implementation 620 lines below it and it is wrong. Follow the implementation: both are required. Anything written against that comment trips the exact rule it was trying to satisfy.

2. They carry instance identities, not definition ids. Atlas matches each endpoint against the expanded instance URNs across the whole domain. Those come from the interpolated instanceId, not the definition id. Putting a definition id there is a dangling endpoint, I-B6:

Relationship instance 'feeds-pump01-tank01' references target 'urn:atlas:instance:juice-factory:tank-1', which does not match any expanded instance id in the domain.

Watch the padding. tank-01 and tank-1 are different strings.

3. They are templates. Both accept the same [Name] syntax as instanceId and are interpolated once per expanded row, so one definition creates many edges. Resolution runs over the full instance set, independent of any one entity's row, which is what lets the two ends come from different definitions and different files. The class summary calls that decoupling "the load-bearing half" of the design.

{
  "id": "area-contains-ct",
  "name": "Area Contains Cooling Tower",
  "type": "urn:atlas:ontology:plant:AreaContainsCoolingTower",
  "instanceId": "urn:atlas:instance:plant:contains-CT-[Area]-[Num, 00]",
  "map": [
    { "name": "Area", "data": "North*3, South*3", "wrap": false },
    { "name": "Num",  "data": "1, 2, 3, 1, 2, 3", "wrap": false }
  ],
  "source": "urn:atlas:instance:plant:area-[Area]",
  "target": "urn:atlas:instance:plant:CT-[Area]-[Num, 00]"
}

Six edges, matching the six cooling towers above. Both area-North and area-South have to exist as expanded instances somewhere in the domain, or every row raises I-B6.

4. Edge properties are ordinary properties. A relationship's properties block works exactly like an entity's, as the fixture shows. There is no separate edge-property surface; the inline edge sub-resource wire bodies were retired alongside relationships[].

Two more semantic rules apply: I-B5 requires the type to be a relationship archetype (a class transitively under RelationshipBase or ContainsBase), and I-B7 checks each resolved endpoint's type against the archetype's SourceType / TargetType constraints.

Important

Migrating a generator away from relationships[] is not a rename. Each element of the old array becomes its own top-level definition, in the same file or a different one, with its own globally unique id, its own instanceId, and full instance URNs in source and target where the old form held definition ids. Sending the old array to the API returns 201 Created and creates nothing.

Building instances from a connector: sources#

sources turns one definition into one instance per row a connector returns. This is how you build a domain from an asset register or a tag catalog rather than enumerating assets in the file.

{
  "id": "bound-machines",
  "name": "Bound Test Machines",
  "type": "urn:atlas:ontology:juice-factory:Machine",
  "instanceId": "BoundMachine-[Num, 00]",
  "sources": {
    "fillers": {
      "bindingPath": "historian-juice-factory/The Juice Factory/FillerTemps",
      "transforms": {
        "name": { "regex": "(?<Area>\\d+)\\-(?<Instrument>\\w+)\\-(?<FillerNumber>\\d+)\\.PV" }
      }
    }
  },
  "map": [
    { "name": "Num", "data": "[fillers.FillerNumber]", "wrap": false },
    { "name": "Tag", "data": "[fillers.name]", "wrap": false }
  ],
  "properties": {
    "urn:atlas:ontology:juice-factory:temperature": [
      { "type": "BoundStrategy",
        "bindingPath": "historian-juice-factory/The Juice Factory/[Tag]" }
    ]
  }
}
Member Notes
sources.{name}.bindingPath The rowset whose rows drive the count. Same slash-delimited format as a property binding path
sources.{name}.transforms.{column}.regex A regex with named capture groups, matched against that column on each row. Captures become new columns, referenced as [sourceName.CaptureName]
map[].data In this mode, a [sourceName.columnName] reference (optionally with surrounding literal text)

Behaviour worth encoding in a generator:

  • A row whose transform regex does not match is dropped, not passed through. A slightly-too-strict regex produces fewer instances than the source has rows.
  • An empty rowset, or a rowset where every row is dropped, produces zero instances and is logged as a warning. The compile still reports success.
  • A definition may declare several sources, but map may reference only one of them. Referencing two throws; I-B14 should have caught it first.
  • Rowset expansion needs a connector round-trip, so it only runs on the async path. A synchronous expansion of a RowsetTemplate definition returns empty by design.
Note

sources[].bindingPath is validated when you save, through the connector registry (I-B10). This is the one binding path Atlas checks for you. A BoundStrategy.bindingPath in properties is not. That asymmetry is why a rowset producing nothing usually tells you, and a property binding nothing never does.

Validation rules a map author trips#

Instance rules split into I-A* (format, checked against the persisted file) and I-B* (semantics, checked against the ontology and the expanded instance set). All of them live under Infrastructure/Persistence/Validation/.

Note

The old path Infrastructure/Validation/ no longer exists. Format and semantic instance rules are in Infrastructure/Persistence/Validation/Rules/Instance/; the two transform rule families are one directory up in Infrastructure/Persistence/Validation/.

Rule Owning file Check
I-A1 Parseable JSON, root is an array. An unrecognised member lands here and fails the whole file
I-A2 same Every entry has id, type, instanceId
I-A3 same id is globally unique across every .map.json in the domain
I-A4 same Every property key starts with urn:atlas:ontology:
I-A5 same Strategy type is one of the six
I-A6 same Strategy has the fields its type requires
I-A7 same map[] entries have name and data, and data parses as a count-encoded expression
I-A8 same Placeholders resolve to map[] names, and no map[] entry is an orphan
I-A9 same A relationship map declares both source and target
I-A10 Every transforms.{column}.regex compiles
I-A11 same In rowset mode, map[].data is a [source.column] reference
I-B1 type is a known ontology type
I-B2, I-B3, I-B4 Property exists; is attached to the type (inheritance honoured); a static literal matches the property's range
I-B5, I-B6, I-B7 Relationship archetype; endpoint resolution; endpoint typing
I-B8 Containment edges form a DAG. Multiple parents are allowed; a cycle, including a self-loop, is not
I-B9 functionUri resolves in the function registry
I-B10 .. I-B14 Source binding path resolves; transform target column exists; capture-name collisions; duplicate captures; map[] references a declared source column
I-B15 .. I-B18 CompositeStrategy member keys are fields of the data type, the strategy needs a data-type value, one composite entry per property, overlapping member intervals
I-B17 and Used by two rules: one for "more than one CompositeStrategy entry on a property", one for global URN uniqueness across the node and edge namespace
I-B19, I-B20, I-B21 same Cross-node reference target exists; its property is declared on the target's type; a ReferenceStrategy target range is assignable
I-B22 same An argument parameter resolves (Error), or a declared parameter is unbound (Warning)

GET /api/v1/branches/{branch}/domains/{domain}/validation returns the authoritative current list for a domain.

Writing by code#

The full route table, request and response bodies and status codes belong to Atlas API - Instance Maps. What a generator needs to know:

POST   /api/v1/branches/{branch}/domains/{domain}/instance-maps/                       201
GET    /api/v1/branches/{branch}/domains/{domain}/instance-maps/                       200
DELETE /api/v1/branches/{branch}/domains/{domain}/instance-maps/{fileName}             204
GET    /api/v1/branches/{branch}/domains/{domain}/instance-maps/{fileName}/instances   200
POST   /api/v1/branches/{branch}/domains/{domain}/instance-maps/{fileName}/instances   201
GET    /api/v1/branches/{branch}/domains/{domain}/instance-maps/instances/{id}         200
PATCH  /api/v1/branches/{branch}/domains/{domain}/instance-maps/instances/{id}         200
DELETE /api/v1/branches/{branch}/domains/{domain}/instance-maps/instances/{id}         204

Three things that break naive ports:

  • The by-id routes carry no {fileName}. Definition ids are globally unique, so the server finds the owning file itself.
  • Update is PATCH, not PUT.
  • The file must exist before you add definitions to it. Otherwise the add returns 404 with InstanceMapFile.NotFound. It returns 409 with InstanceMap.AlreadyExists when the id is taken anywhere in the domain.
Note

Atlas serves its web interface for any URL outside the API surface, returning 200 text/html rather than a 404, so PUT .../instance-maps/{file}/instances/{id} returns a success status and an HTML body. Check Content-Type before parsing, or your client will report a successful update that never happened.

Every route on this group requires authentication, and Pulse:Auth:Enabled=true is the shipped default. Send a Pulse bearer on every call. Commit authorship comes from the authenticated principal, defaulting to Anonymous.

Partial update and the typed clear#

On PATCH, an omitted field means keep it. That leaves no way to erase a collection, which is what clear is for:

{ "clear": ["source", "target"] }

Accepted tokens: "sources", "map", "properties", "label", "source", "target". id is not on the update body at all and cannot be changed. Sending {"properties": {}} is not an erase - an empty collection is indistinguishable from "unchanged" in the merge.

One commit for N definitions#

A batch is one unit of work: one validation of the whole domain and one commit, however many definitions it carries. fileName is set per item, so one call can populate definitions across every file at once.

Note

That batch surface is not on /api/v1. The four HTTP endpoints keep single-object bodies and wrap each into a one-item array before dispatch. The array-accepting entry points are the agent tools - CreateInstanceMapFiles, AddInstanceMapDefinitions, UpdateInstanceMapDefinitions, RemoveInstanceMapDefinitions. There is no bulk REST route; do not go looking for one. To create many instances per request, write a template: see Loading many instances. Agent-tool batches are all-or-nothing, and a rejected item is named by its array index.

Save, then compile#

Nothing you author exists as an instance until the domain is compiled.

PUT /api/v1/branches/{branch}/domains/{domain}/world

200 OK returns CompileResult { entityCount, edgeCount, archetypeCount, compiledAt, functionErrors[] }. There is no warnings member, and unresolved endpoints never appear here - they are validation violations (I-B6 / I-B7) raised before the commit. GET .../world reports isStale and compiledAtSha without recompiling.

Compilation is idempotent and not incremental: each call is a full recompile that replaces the cached world. Compile once after all definitions are in place, not between individual writes.

Compile aborts; it does not degrade. Error-severity violations produce 422 with a violations[] array and no world is built. It does not skip the offending definitions and build the rest.

Save and compile fail differently#

This is worth building into an import pipeline from the start.

Operation On error-severity violations
Save (POST / PATCH an instance map) Returns 2xx. The commit lands in the caller's private draft session and the target branch does not advance. The only signal is the Atlas-Validation-Violations response header
Compile (PUT .../world) Returns 422 RFC-9457 with violations[], and no world is built

A save that was valid all along runs in a throwaway worktree, fast-forwards the target branch, and leaves no session behind. So "you have a draft session" means "you have unpublished invalid work" - it is not the normal path for every write.

Important

Once a draft session exists, every subsequent write on that branch stays in it, valid or not, until you publish. Worse, a draft-routed compile does not storing the world in the cache. The result is two successful responses and no visible change at all. If your pipeline reports success and the graph is unchanged, this is why.

The pipeline contract:

  1. POST or PATCH the definition.
  2. Read the Atlas-Validation-Violations response header. It is absent on a clean save, not 0.
  3. If it is present, GET /api/v1/branches/{branch}/domains/{domain}/validation for the rule ids, fix, and write again.
  4. GET /api/v1/branches/{branch}/draft to check for a session; POST /api/v1/branches/{branch}/draft/publish to publish it. Publish merges the target branch into the draft on a scratch ref, re-validates the merged result across every domain and library, and fast-forwards the target only when that comes back clean. 409 with conflictFiles on a conflict, 409 when there is nothing to publish, 422 with violations[] when the merged result is invalid.
  5. PUT .../world.

Restart behaviour#

Atlas recompiles every recorded branch and domain pair after it starts, in the background, without delaying the API. Domains flip to queryable one at a time as each recompile lands.

Note

Atlas recompiles every recorded branch and domain pair at startup, so a manual PUT .../world after a restart is not needed. The one case that needs a manual compile is narrower: a connector-dependent domain is not compiled at boot. Its rowset binding paths are captured once and the domain waits for a connector to advertise them; a domain whose connector never advertises them stays not compiled for as long as Atlas keeps running. So a RowsetTemplate domain can be queryable one boot and return 400 Query.NotCompiled the next, purely on connector timing.

Loading many instances: templates, not singletons#

A pipeline that writes one Singleton definition per asset works, and it scales badly. Every definition is one request and one Git commit, so a site of 2,000 assets and 2,500 links is 4,500 requests and 4,500 commits, and the domain's history is 4,500 entries long. There is no bulk REST route to fall back on (see One commit for N definitions).

Write templates instead. One template definition expands into as many instances as it has rows or values, in one request and one commit. A site then comes down to about one definition per entity type and one per relationship type: tens of requests rather than thousands.

You have Write Instances come from
The items in a table or API a connector can read A RowsetTemplate: a definition with sources One instance per row, read at compile
The items in your loader, as lists of identifiers and codes A StaticTemplate: map entries holding comma lists One instance per position in the lists
One item that is genuinely one of a kind: a site, a control system A Singleton Exactly one instance

Prefer a source when the data lives in a table#

A RowsetTemplate is the better shape for anything you will load more than once. Point sources at a connector rowset, for example a SQL connector over a staging table your pipeline fills, and reference its columns as [source.Column] in instanceId, label and property values. Atlas reads the rows at compile and makes one instance per row. A re-sync is then "update the table, recompile": no per-item writes and no per-item commits. Free text is safe here, because each value arrives in its own column. See Building instances from a connector for the shape and the rules.

Lists in map when the data lives in your loader#

A StaticTemplate carries the values in the definition itself. Each map entry holds one comma list, the lists are read in parallel by position, and property values pick them up through placeholders:

{
  "id": "pumps-area-100",
  "name": "Area 100 pumps",
  "type": "urn:atlas:ontology:plant:Pump",
  "instanceId": "urn:atlas:instance:plant:[Tag]",
  "label": "[Tag]",
  "map": [
    { "name": "Tag",    "data": "P-101, P-102, P-103", "wrap": false },
    { "name": "Serial", "data": "SN4471, SN4472, SN4480", "wrap": false }
  ],
  "properties": {
    "urn:atlas:ontology:plant:SerialNumber": [
      { "type": "StaticStrategy", "value": "[Serial]" }
    ]
  }
}

Three pumps from one definition: P-101 with serial SN4471, P-102 with SN4472, P-103 with SN4480.

Rules a generator has to encode:

  • Give every list the same length. Expansion is linear, not a cross product: position n of every list makes instance n, and a shorter list repeats its last value (or starts again, with wrap on). See Expansion is linear, not Cartesian.
  • A value cannot contain a comma. There is no quoting or escaping; a comma always starts the next value.
  • A list made entirely of ranges or multipliers is expanded. The whole data string is tried against the range and multiplier patterns before it is split (see The data grammar), so "100-102, 200-201" is five values, not two, and "Spare*2, Duty*1" is three. A list that mixes in anything else is split as literal text.
  • Reference every variable. An unused one fails the save (I-A8).

That makes lists a good fit for identifiers, codes and numbers, and a poor one for names and descriptions. Put free text in a source, or set it per instance afterwards.

Relationships the same way#

A relationship definition is a template too: source and target take the same placeholders, so one definition makes every edge of a kind. With the pumps above and an area instance per pump:

{
  "id": "area-100-contains-pumps",
  "name": "Area 100 contains its pumps",
  "type": "urn:atlas:ontology:plant:AreaContainsPump",
  "instanceId": "urn:atlas:instance:plant:contains-[Tag]",
  "map": [
    { "name": "Tag", "data": "P-101, P-102, P-103", "wrap": false }
  ],
  "source": "urn:atlas:instance:plant:area-100",
  "target": "urn:atlas:instance:plant:[Tag]"
}

A relationship definition can carry sources as well, so parent and child identifiers can come from the same table as the items. See Relationships are their own instance maps.

Note

a template's instances count against the licensed Instances entitlement at compile exactly as singletons do. Templates save requests and commits, not licence. See Licensing and Entitlements.

  1. Create the type layer first. type, every property URI and every relationship archetype must already exist, or you will collect I-B1, I-B3 and I-B5 on the first save.
  2. Create the instance-map files: POST .../instance-maps/ with { "name": "line-a" }. Stems collide case-insensitively.
  3. Write entity definitions before relationship definitions, as templates rather than one singleton per item (see Loading many instances). Endpoints resolve against expanded instances, so a relationship written first dangles until its ends exist. Ordering does not affect the final result, but it keeps the violation list readable.
  4. Write relationship definitions, using full instance URNs in source and target.
  5. After every write, read Atlas-Validation-Violations. Do not proceed while it is present.
  6. Publish the draft session if one exists.
  7. PUT .../world once, at the end.
  8. Read entityCount and edgeCount from the compile result and assert them against what you expected. Given linear expansion, an off-by-a-factor count is the fastest way to catch a map column you got wrong.

Complete worked example#

One file, three definitions: six cooling towers, two areas, and the six containment edges that connect them. Every field is one of the eleven accepted members; every strategy type is one of the six; the arithmetic is stated per definition.

[
  {
    "id": "plant-areas",
    "name": "Plant Areas",
    "type": "urn:atlas:ontology:plant:Area",
    "instanceId": "urn:atlas:instance:plant:area-[Area]",
    "label": "[Area] Area",
    "map": [
      { "name": "Area", "data": "North, South", "wrap": false }
    ]
  },
  {
    "id": "cooling-towers",
    "name": "Cooling Towers",
    "type": "urn:atlas:ontology:plant:CoolingTower",
    "instanceId": "urn:atlas:instance:plant:CT-[Area]-[Num, 00]",
    "label": "[Area] Cooling Tower [Num]",
    "description": "Three cooling towers per area",
    "map": [
      { "name": "Area", "data": "North*3, South*3", "wrap": false },
      { "name": "Num",  "data": "1, 2, 3, 1, 2, 3", "wrap": false }
    ],
    "properties": {
      "urn:atlas:ontology:plant:fanCount": [
        { "type": "StaticStrategy", "value": 4 }
      ],
      "urn:atlas:ontology:plant:supplyTemp": [
        { "type": "BoundStrategy",
          "bindingPath": "historian-plant/Utilities/CT-[Area]-[Num, 00]-TT01.PV" }
      ],
      "urn:atlas:ontology:plant:returnTemp": [
        { "type": "BoundStrategy",
          "bindingPath": "historian-plant/Utilities/CT-[Area]-[Num, 00]-TT02.PV" }
      ],
      "urn:atlas:ontology:plant:commissionedBy": [
        { "type": "NullStrategy" }
      ]
    }
  },
  {
    "id": "area-contains-ct",
    "name": "Area Contains Cooling Tower",
    "type": "urn:atlas:ontology:plant:AreaContainsCoolingTower",
    "instanceId": "urn:atlas:instance:plant:contains-CT-[Area]-[Num, 00]",
    "map": [
      { "name": "Area", "data": "North*3, South*3", "wrap": false },
      { "name": "Num",  "data": "1, 2, 3, 1, 2, 3", "wrap": false }
    ],
    "source": "urn:atlas:instance:plant:area-[Area]",
    "target": "urn:atlas:instance:plant:CT-[Area]-[Num, 00]",
    "properties": {
      "urn:atlas:ontology:plant:installDate": [
        { "type": "StaticStrategy", "value": "2026-01-15T00:00:00Z" }
      ]
    }
  }
]

What it produces:

Definition Longest column Instances Identities
plant-areas Area, 2 values 2 area-North, area-South
cooling-towers both, 6 values 6 CT-North-01 .. CT-North-03, CT-South-01 .. CT-South-03
area-contains-ct both, 6 values 6 edges contains-CT-North-01 .. contains-CT-South-03

A compile of this file reports entityCount: 8 and edgeCount: 6, assuming the ontology declares Area, CoolingTower, AreaContainsCoolingTower with matching endpoint constraints, and attaches fanCount, supplyTemp, returnTemp, commissionedBy and installDate to the right types.

Note

The binding paths in this example are illustrative shapes, not paths from a live deployment. Substitute the exact paths your connector advertises - they are not validated at author time, so a wrong one binds nothing and reports nothing. A real Historian path looks like historian-juice-factory/The Juice Factory/131-TT-01.PV.

Porting checklist#

Run this over any generator written before May 2026.

  • [ ] Every route carries /api/v1/...; by-id routes have no {fileName}; update is PATCH.
  • [ ] Every response is checked for Content-Type: application/json before parsing, so an SPA-shell 200 text/html from a dead route is caught.
  • [ ] relationships[] is gone. Each edge is its own definition with a unique id, an instanceId, and full instance URNs in both source and target.
  • [ ] No scheme:// appears in any bindingPath.
  • [ ] Every placeholder is [Name]; no {i}, $name or ${name} survives.
  • [ ] Every map column is materialised to the same length. Instance counts are asserted, not assumed.
  • [ ] Every property key is a full urn:atlas:ontology: URI.
  • [ ] Every strategy uses type with one of the six values.
  • [ ] Every functionUri and propertyUri uses urn:atlas:ontology:, not urn:fn: or urn:atlas:prop:.
  • [ ] Compile is PUT .../world, called once, and nothing reads a warnings field.
  • [ ] Every write reads Atlas-Validation-Violations, and the pipeline publishes a draft session before compiling.
  • [ ] No step assumes it must recompile after a restart.
  • [ ] Every request sends a Pulse bearer; nothing sends X-Atlas-Author.
esc
↑↓ navigate · ↵ open · ⌘K anywhere