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:
| Field | The question it answers |
|---|---|
policy.roles | Who may call this at all? |
requires | What must be true of the record right now? |
approval | Must a human sign off — always, or above a threshold? |
audit | Reserved. Every governed action is recorded; validate rejects false. |
effect | What 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
- YAML reference — every field, precisely.
- Security model — what enforces what.