Ontology & data sources
This is where you model your own operation. You do two things:
- Declare connections — the source systems henosis should read from.
- Describe your assets as
EntityTypes — and bind each part of them onto those connections.
The word to hold onto is binding. A binding is a route to where a value already lives — never a copy of it. henosis stores your schema and the routes; your systems of record stay authoritative, and values are resolved at query time.
The examples below are drawn from the worked Cooper Basin project
(henosis init cooper-basin).
Author your own the same way, then compile → sync → serve (see the
Quickstart).
The project manifest
A henosis project is a directory with a henosis.yaml manifest. It names the
connections and points at the model files. Everything else is optional:
type: Project
name: cooper_basin_demo # required, snake_case
version: 0.3.0 # required
description: Synthetic upstream gas operation in the Cooper Basin.
connections: # the systems you read from (see below)
entities_db:
type: sqlite
path: data/entities.db
scada:
type: parquet_directory
path: data/observations/
pi_historian:
type: pi_web_api
url: http://127.0.0.1:8765
ontology:
use_builtin: true # include the built-in base classes (default)
model_files: ["models/*.yaml"] # globs allowed, e.g. "models/**/*.yaml"
# Optional blocks:
security:
secrets_backend: env # "env" (default) or "vault"
components: # project-local logic for routed actions
work_order_dispatch: components.work_order_dispatch:WorkOrderDispatch
serve:
explorer: { enabled: true, port: 8000 }
Conventions used throughout: snake_case for connection names, properties, and
files; PascalCase for EntityType names; IDs are prefix:lowercase-with-hyphens
(e.g. well:moomba-12).
Connections — the systems you read from
A connection is a named handle to a source system, declared once and referenced by every binding that reads from it. Federation means henosis reads from these at call time and never copies or moves the data.
What matters when authoring is how a connection binds — that determines which keys an EntityType uses to reach it. There are five families:
| Family | type values | Binds by |
|---|---|---|
| Tabular | sqlite, postgres, mysql, snowflake, bigquery, mssql, csv, adbc | a table + id/timestamp/value columns |
| File / lake | parquet_directory, arrow_flight | a path_template per entity |
| Tag / stream | pi_web_api, mqtt | a tag_template, AF mapping, or topic_template |
| Service / API | rest, arcgis, wfs | a rest: sub-block or spatial layer |
| Document store | file | a byte-store path_template (never queried as a table) |
Note
The tabular family is federated into DuckDB by ATTACH, so any DuckDB-backed
source works the same way. Run henosis sources to list your connections and
their health.
Credentials
Connection configs only ever hold ${ENV_VAR} templates — never a resolved
secret. Values are read from the environment (or Vault, if secrets_backend: vault) at connect time, and are never logged or persisted:
connections:
warehouse:
type: postgres
host: db.internal
database: assets
auth: basic
username: ${PG_USER}
password: ${PG_PASSWORD}
A rest connection picks its auth mechanism the same way — auth: bearer with
token: ${TOKEN}, auth: api_key with api_key_header + api_key: ${KEY},
auth: basic with username/password, or auth: none.
Anatomy of an EntityType
An EntityType describes one kind of thing. Its properties are grouped by
semantic role, and the binding that fills them sits at the bottom of the same
file. Every EntityType extends one of the built-in base classes (directly or via
another user type); the base signals what kind of thing it is:
| Base class | For |
|---|---|
PhysicalAsset | a thing in the world with physical presence — a well, a pump |
Location | a place: a point, an area, or a hierarchical container |
Actor | a person, team, organisation, or autonomous agent |
Event | something that happened at a time (reserves occurred_at) |
Document | content rather than physical presence |
AbstractConcept | non-physical things: a FailureMode, a MaintenanceStrategy |
Here is Well, trimmed to show the shape:
type: EntityType
name: Well
extends: PhysicalAsset
description: A petroleum or geothermal well.
identity:
name: { type: string, required: true, unique: true }
uwi: { type: string, unique: true }
total_depth_m: { type: float, unit: metre }
current_status:
type: enum
values: [drilling, suspended, production, shut_in, plugged_abandoned]
spatial: { geometry: point, crs: EPSG:4326 }
relationships:
feeds: { target: Pipeline, cardinality: one, kind: network, inverse: fed_by }
field: { target: Field, cardinality: one }
defects: { target: Defect, cardinality: many }
observations:
gas_rate:
value_type: float
unit: m3/d
data_source: { connection: pi_historian, tag_template: "SCADA.WELL_{entity_id}.GAS_RATE" }
state:
operational_status:
value_type: enum
values: [flowing, constrained, shut_in, offline]
documents:
completion_report: { media_type: application/pdf, cardinality: one }
Properties fall into six roles. Each is its own section below:
| Role | What it captures | Example |
|---|---|---|
identity | slow-changing intrinsic fields | name, UWI, status |
spatial | where it is | a wellhead point |
observations | time-series signals | gas rate, pressure |
state | current condition | operational status |
documents | attached media | completion report, 3D model |
relationships | links to other entities | feeds, field, defects |
Once the properties are declared, you connect them to a source in the binding block.
Identity
Slow-changing intrinsic properties. Every EntityType carries a reserved, required
identity.name (the human-readable label surfaces show). Each field:
| Key | Options |
|---|---|
type | string, integer, float, boolean, date, datetime, enum |
required | true / false (default false) |
unique | true / false (default false) |
unit | a UCUM unit, for numeric fields (e.g. metre, kPa) |
values | the allowed list — required when type: enum |
description | free text — surfaced to agents via the MCP get_type tool |
Spatial
How the entity occupies space, as of its current (or point-in-time) state:
| Key | Options |
|---|---|
geometry | point, linestring, polygon, trajectory, volume, none |
frame | geographic (default) or local |
crs | an EPSG code (geographic only; default EPSG:4326) |
frame_of | names a cardinality-one relationship (local frame only) — coordinates are in that target's model space, e.g. a defect placed on its asset's 3D model |
For a continuously streamed position (a moving vehicle), use an
observation with value_type: geometry instead — spatial
models an occasional/versioned footprint, observations model an arriving stream.
Relationships
Typed links to other entities:
| Key | Options |
|---|---|
target | an EntityType name, or a list [A, B] for a polymorphic link |
cardinality | one (default) or many |
temporal | true (default) / false — whether links carry validity intervals |
inverse | names the reverse link (enables inbound traversal) |
kind | free string; network marks a flow/conveyance path a Schematic widget can trace directionally |
pose | true / false — the link also carries the target's position + rotation in this entity's local model frame |
description | free text — what the link means; surfaced to agents via get_type |
How each link resolves to a foreign key or join table is covered under Binding an EntityType to its source.
Observations
Time-series measurements. value_type is one of float, integer, boolean,
string, or geometry. Optional unit, and range: [min, max] for validation.
Each observation carries its own data_source, because signals often live in
a different system from the entity's descriptive data — see
Observation bindings for the binding shapes.
State
Current values that aren't a time-series. value_type is enum, float,
integer, boolean, or string. A state property is either sourced from a
column (via the binding's columns) or derived at query time — see
Derived state.
Documents
Attached media, named by media_type (e.g. application/pdf,
model/gltf-binary) with cardinality: one (default) or many. Bind bytes
through a file connection with a path_template, or omit the binding to declare
a document that an application supplies:
documents:
asset_model:
media_type: model/gltf-binary
cardinality: one
data_source:
connection: doc_store
path_template: "models/wellhead.glb" # constant path → one shared model
Lifecycle & actions
lifecycle: { tracked: true, states: [...] } records an ordered set of lifecycle
states. actions: declares routed write verbs — how henosis closes the loop
by writing back to a source system. Actions are a topic of their own; the short
version is that each declares typed parameters, engine-resolved inputs, an
approval policy, and either an execute or rest binding.
Binding an EntityType to its source
The data_source block maps the entity's identity, spatial, and relationship
fields onto one row in one table (design principle: a single authoritative
source per EntityType):
data_source:
connection: entities_db
table: wells # a table, view, or SQL query
id_column: id # raw id...
id_prefix: "well:" # ...prefixed to form well:<id> (globally unique)
columns:
identity.name: name # direct passthrough: field path → column
identity.uwi: uwi
# Anything beyond a passthrough uses an expr — DuckDB scalar SQL, per row:
identity.total_depth_m:
expr: "total_depth_ft * 0.3048" # unit conversion
identity.current_status:
expr: > # enum/lookup mapping
CASE status_code
WHEN 'PRODN' THEN 'production'
WHEN 'SI' THEN 'shut_in'
ELSE NULL
END
spatial.geometry: geometry_json
relationships:
feeds: { column: feeds_pipeline_id, id_prefix: "pipeline:" }
field: { column: field_id, id_prefix: "field:" }
Things worth knowing:
id_column+id_prefixbuild each entity's stable id. The prefix must be unique across the project — the engine resolves a type from an id alone by it.columnsmaps a field path (identity.name) either to a source column (passthrough) or to anexpr— a DuckDB scalar SQL expression evaluated per row, with source columns available as identifiers. Everyexpris validated against the live connection at compile time, so a typo fails the build, not a query. There is no bespoke expression language to learn.relationshipsmaps each declared link to the foreign-key that holds the target's raw id.
Relationships that need a join table
When neither side holds the other's key (or it's many-to-many), bind through a
join table instead of a column:
relationships:
defects:
via_table: defects
via_from_column: well_id # matches this entity's raw id
via_to_column: id # matches the target's raw id
via_from_prefix: "well:"
via_to_prefix: "defect:"
Two more binding options
- Point-in-time (SCD2). Add
valid_from_column/valid_to_columnto the binding and the table is treated as versioned — the current row hasvalid_to_column IS NULL, andget_entity(at=…)/get_entity_history()resolve historical state (spatial included, for free). - Per-section overrides. When
spatialorstategenuinely comes from a different system than identity (GIS vs. an asset register), give that section its owndata_sourceblock with ajoin_onkey correlating its rows back toid_column. Omit for the common case where one row covers everything.
Observation bindings
Each observation binds independently. The binding shape depends on the connection family:
observations:
# TAG / stream (pi_web_api, mqtt) — a tag name templated per entity:
gas_rate:
value_type: float
unit: m3/d
data_source:
connection: pi_historian
tag_template: "SCADA.WELL_{entity_id}.GAS_RATE"
# FILE / lake (parquet_directory, csv) — a path templated per entity + property:
oil_rate:
value_type: float
unit: m3/d
data_source:
connection: scada
path_template: "{entity_id}/{property}.parquet"
timestamp_column: t
value_column: v
# TABULAR (sqlite, postgres, …) — one table for all entities, scoped by id:
allocated_gas:
value_type: float
unit: m3/d
data_source:
connection: entities_db
table: allocated_production
entity_id_column: well_id
timestamp_column: production_date
value_column: allocated_gas_m3
{entity_id} and {property} are substituted per binding, so a single
declaration covers every entity of the type.
| Connection family | Use | Plus |
|---|---|---|
| Tag / stream | tag_template | or a PI Asset-Framework mapping (af_database / af_element_template / af_attribute), or topic_template for MQTT |
| File / lake | path_template + timestamp_column + value_column | — |
| Tabular | table + entity_id_column + timestamp_column + value_column | table may be a view or query |
Derived state
A state property can be derived — computed at query time from live
data rather than read from a column. It declares named inputs (each resolving to
a single scalar for one entity) and an expr that combines them. There are three
input forms:
| Form | inputs key | Resolves to |
|---|---|---|
| A — observation aggregate | observation: + window: + agg: | an aggregate over one of this entity's own observations (e.g. avg water_cut over 30d); agg: latest reads the single most-recent value |
| B — related-event count | from: + filter: + window: | a filtered count over a reverse relationship (e.g. faults in the last 90d) |
| C — static attribute | attribute: | one of this entity's own sourced identity/state columns — lets a metric mix a live reading against a fixed limit |
Every input declares a default (substituted when the aggregate is NULL), so the
expr never needs a manual COALESCE:
state:
health_index:
value_type: float
description: Composite well-integrity index 0-100. Higher is healthier.
derived_from:
inputs:
water_cut_avg_30d:
observation: water_cut
window: 30d
agg: avg
default: 0
fault_count_90d:
from: well_events
filter: "identity.event_type = 'fault'"
window: 90d
agg: count
default: 0
expr: >
100
- LEAST(water_cut_avg_30d, 100) * 0.3
- LEAST(fault_count_90d, 10) * 5
Windows are trailing from query time ("now"). Because health_index is derived,
it's always live — no source system stores it, and nothing has to recompute a
stale copy. agg accepts any DuckDB aggregate function (avg, min, max,
sum, stddev, regr_slope, …) for Form A; Form B currently supports count.
Form C is how you express the most common operational metric — an operating
point against a static limit (pressure ÷ MAWP, flow ÷ design capacity), e.g.
expr: "latest_pressure / mawp_kpa".
Hand-authored instances
When there's no live system to bind to — reference data, seed fixtures, one-off
records — omit the data_source and supply Entity documents instead:
type: Well # the type name, not the literal "Entity"
id: well:moomba-12 # required, prefix:identifier
identity:
name: Moomba 12
spatial:
geometry: { type: Point, coordinates: [140.20, -28.10] }
An EntityType is populated either by a binding or by Entity documents — not
normally both.
Compile and serve
Once the model is authored:
henosis validate -p . # catch shape and expr errors early
henosis compile -p . # build the ontology store from YAML
henosis sync -p . # populate the graph from relationship bindings
henosis serve -p . --mcp
compile drops and recreates the ontology tables every run — the store is a
disposable build artifact, always regenerated from YAML and never hand-edited. See
the Architecture for how that compiled model is then
served over one federating engine.