Concepts

Six ideas, and how they fit together.

Object

An object is a thing your business operates on: a customer, an order, a contract. It is not a table — it is a view of a table through a lens that knows what the rows mean.

Each object binds to exactly one source table and adds four things a table does not have: a declared identity, named properties, a computed state, and the actions it accepts.

object: customer
source:
  table: main.crm.customers
identity: [customer_id]

The source block is deliberately the first thing in the file. An ontology that does not say where the data physically lives is a diagram, not a compiler input.

Identity

Identity is what makes “customer #123” a meaningful phrase. It is one or more source columns that stably identify a row:

identity: [customer_id]          # or [tenant_id, customer_id]

Identity columns are always selected first in the compiled view, and they are what every get_, traverse_ and action call takes as input. They are never hidden: a property marked sensitive cannot be bound to an identity column, because identity is required for the object to be addressable at all — validate rejects that combination rather than pretending to hide it.

If two rows share an identity, Ontologiq refuses to answer rather than picking one. “Who is customer #123” must not be a coin flip.

Properties

Properties are the named fields of an object. Each is either bound to a column or derived from an expression — exactly one of the two:

properties:
  - name: email
    type: string
    column: email_address     # bound
    sensitive: true
  - name: lifetime_value
    type: decimal
    sql: coalesce(ltv_eur, 0) # derived

A property can reference another property, and the compiler inlines it. So with lifetime_value declared above, vip: lifetime_value > 1000 compiles to CASE WHEN COALESCE(t.ltv_eur, 0) > 1000 …. Circular definitions are rejected at validate time.

sensitive: true means the value is never selected when serving an AI agent. Not masked after fetching — never read. The tool result names the withheld fields so the model knows they exist.

State

State is the idea that makes an object operational rather than descriptive. It is an ordered map of predicates, first match wins:

state:
  disputed: dispute_opened_at is not null
  open: fulfilled_at is null
  else: fulfilled

Two things follow from “ordered”.

Overlap is a feature. A disputed order that has not shipped matches both predicates; declaration order decides, and disputed wins because it is declared first. That is priority encoding, not ambiguity.

Reordering the lines changes meaning. Treat the order as significant in review, the way you would treat a CASE.

The optional else: names the fallback state and must be last. Without it, rows matching nothing get NULL, and a precondition like state == 'open' silently fails for them — so validate warns when it is missing.

State is computed at query time from live data. Nothing is stored, so nothing goes stale; the flip side is that a customer becomes churned with no event, simply because time passed. If you need transitions with history, that is deferred, not dropped.

The computed column is always called state. If your source table has its own state column — addresses often do — bind it to a differently-named property; expressions referencing it resolve to the physical column, because the compiler qualifies every reference and never relies on dialect-specific alias resolution.

Relations

Relations make the ontology a graph:

relations:
  orders:
    type: has_many
    target: order
    # join: customer_id     # optional

join is a single column present on both sides, or an explicit {left: …, right: …} pair. Omitted, the default follows where the foreign key lives: has_many and has_one use this object’s identity (the key sits on the target), belongs_to uses the target’s identity (the key sits here).

Each relation becomes a traverse_ tool, so an agent can walk from a customer to their orders without composing a join.

Actions

An action is something that can be done to an object, and it is the reason Ontologiq exists.

actions:
  - name: issue_refund
    requires: state == 'active'
    params:
      - name: amount
        type: decimal
        required: true
    policy:
      roles: [support, finance]
    approval:
      required: true
      when: amount > 500
    audit: true
    effect:
      type: webhook
      url: "{{ env.OPS_API }}/customers/{customer_id}/refund"

Read it as five separate questions, each answered in one line:

FieldThe question it answers
policy.rolesWho may call this at all?
requiresWhat must be true of the record right now?
approvalMust a human sign off — always, or above a threshold?
auditReserved. Every governed action is recorded; validate rejects false.
effectWhat actually happens?

approval.when is evaluated against the action’s parameters, in process, never by interpolating values into SQL. A refund of 20 goes straight through; a refund of 900 waits for a signature.

Effects never write SQL

An effect is a webhook or a Python handler. It is never a database write. This is a hard constraint of the format, and it is the reason Ontologiq can be adopted without granting it write access to anything: it governs the decision, your systems perform the change.

How they compose

The pieces are designed so one definition serves three consumers:

objects/*.yml

      ├──→ target/views/*.sql      the warehouse, for humans and BI
      ├──→ target/catalog.json     the graph, for tooling
      └──→ target/mcp/manifest.json  typed tools, for agents

An agent reading list_customer(state: "churned") and an analyst writing SELECT * FROM customer WHERE state = 'churned' are answering the same question with the same definition. That is the whole point.

Next