henosis
henosis docs

Ontology & data sources

This is where you model your own operation. You do two things:

  1. Declare connections — the source systems henosis should read from.
  2. 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 compilesyncserve (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:

Familytype valuesBinds by
Tabularsqlite, postgres, mysql, snowflake, bigquery, mssql, csv, adbca table + id/timestamp/value columns
File / lakeparquet_directory, arrow_flighta path_template per entity
Tag / streampi_web_api, mqtta tag_template, AF mapping, or topic_template
Service / APIrest, arcgis, wfsa rest: sub-block or spatial layer
Document storefilea 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 classFor
PhysicalAsseta thing in the world with physical presence — a well, a pump
Locationa place: a point, an area, or a hierarchical container
Actora person, team, organisation, or autonomous agent
Eventsomething that happened at a time (reserves occurred_at)
Documentcontent rather than physical presence
AbstractConceptnon-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:

RoleWhat it capturesExample
identityslow-changing intrinsic fieldsname, UWI, status
spatialwhere it isa wellhead point
observationstime-series signalsgas rate, pressure
statecurrent conditionoperational status
documentsattached mediacompletion report, 3D model
relationshipslinks to other entitiesfeeds, 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:

KeyOptions
typestring, integer, float, boolean, date, datetime, enum
requiredtrue / false (default false)
uniquetrue / false (default false)
unita UCUM unit, for numeric fields (e.g. metre, kPa)
valuesthe allowed list — required when type: enum
descriptionfree 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:

KeyOptions
geometrypoint, linestring, polygon, trajectory, volume, none
framegeographic (default) or local
crsan EPSG code (geographic only; default EPSG:4326)
frame_ofnames 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:

KeyOptions
targetan EntityType name, or a list [A, B] for a polymorphic link
cardinalityone (default) or many
temporaltrue (default) / false — whether links carry validity intervals
inversenames the reverse link (enables inbound traversal)
kindfree string; network marks a flow/conveyance path a Schematic widget can trace directionally
posetrue / false — the link also carries the target's position + rotation in this entity's local model frame
descriptionfree 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_prefix build each entity's stable id. The prefix must be unique across the project — the engine resolves a type from an id alone by it.
  • columns maps a field path (identity.name) either to a source column (passthrough) or to an expr — a DuckDB scalar SQL expression evaluated per row, with source columns available as identifiers. Every expr is 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.
  • relationships maps 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_column to the binding and the table is treated as versioned — the current row has valid_to_column IS NULL, and get_entity(at=…) / get_entity_history() resolve historical state (spatial included, for free).
  • Per-section overrides. When spatial or state genuinely comes from a different system than identity (GIS vs. an asset register), give that section its own data_source block with a join_on key correlating its rows back to id_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 familyUsePlus
Tag / streamtag_templateor a PI Asset-Framework mapping (af_database / af_element_template / af_attribute), or topic_template for MQTT
File / lakepath_template + timestamp_column + value_column
Tabulartable + entity_id_column + timestamp_column + value_columntable 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:

Forminputs keyResolves to
A — observation aggregateobservation: + 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 countfrom: + filter: + window:a filtered count over a reverse relationship (e.g. faults in the last 90d)
C — static attributeattribute: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.

On this page