The Ontologiq YAML format (v0)
This document is the contract: every feature here has a pydantic schema, a parsing test, a compilation test with a golden SQL file, and an error message that names the file and line. It was reviewed by three expert lenses (analytics engineering, semantic-layer compilers, AI governance) before implementation; the decisions and their reasons are recorded below.
Project layout
my-ontology/
├── ontologiq.yml # project config
├── objects/ # one file per business object
│ ├── customer.yml
│ └── order.yml
├── seeds/ # optional: demo/dev SQL fixtures (see Seeds)
│ └── ecommerce.sql
└── target/ # generated by `compile`; never edited, gitignored
Object files may use .yml or .yaml.
YAML dialect: booleans are YAML 1.2 (true/false only — on, yes, off
stay strings), and duplicate mapping keys are an error, never a silent
overwrite.
ontologiq.yml
name: ecommerce_demo
format: 0 # format version; the migration hook for v1
adapter: duckdb # default adapter for every object
connection:
path: ecommerce.duckdb # adapter-specific; values support {{ env.VAR }}
seeds:
- seeds/ecommerce.sql
connection values interpolate {{ env.VAR }} at load time — credentials
never belong in git. (The word is connection, not profile: to a dbt user
“profile” means an entry in ~/.dbt/profiles.yml, which this is not.)
Multi-target (dev/prod) configs are reserved for a later version.
Objects
object: customer
description: A customer of the demo e-commerce shop.
# PHYSICAL BINDING — designed first, not as an afterthought.
source:
table: main.crm.customers
# adapter: databricks # reserved; multi-adapter projects are rejected in v0
identity: [customer_id] # source columns forming the stable key
properties:
- name: email
type: string
column: email_address # bound to a column…
sensitive: true # surfaced in catalog + MCP manifest
- name: lifetime_value
type: decimal
sql: coalesce(ltv_eur, 0) # …or derived (exactly one of the two)
# STATE — ordered predicates, first match wins. Compiles to one CASE WHEN.
state:
active: last_seen >= now() - 90d
churned: last_seen < now() - 90d
else: prospect # optional fallback; bare state name, must be last
relations:
orders:
type: has_many # has_many | has_one | belongs_to
target: order
# join: customer_id # optional; see Relations for the defaulting rule
actions:
- name: issue_refund
description: Refund an amount to the customer.
requires: state == 'active'
params:
- name: amount
type: decimal
required: true
policy:
roles: [support, finance]
approval:
required: true
when: amount > 500 # references params only; 'none'/'required' shorthands exist
audit: true # default true
dry_run: true # default true: agents can preview consequences
effect:
type: webhook # webhook | handler — NEVER a direct SQL write
url: "{{ env.OPS_API }}/customers/{customer_id}/refund"
Names (objects, properties, states, relations, actions) are snake_case ASCII: they become SQL identifiers and MCP tool names.
Semantics
identity — source columns forming the stable key; composite allowed. Identity columns are selected first in the compiled view.
properties — exactly one of column or sql. Types: string,
integer, decimal, boolean, date, timestamp, json (advisory in
v0: no CAST is generated). sensitive: true flows into the catalog and the
MCP manifest so serving layers can redact.
state — ordered map state_name: predicate; declaration order is
evaluation order, first match wins (overlap is a feature: priority
encoding). The optional else: must be last and names the fallback state.
Without it, unmatched rows get NULL — validate warns, because a NULL
state makes requires: state == '…' fail closed. The computed column is
named state; that name is reserved, and a physical column named state
referenced in a predicate always resolves to the source column (the
compiler qualifies every column reference — no dialect-dependent
lateral-alias ambiguity).
expressions (predicates, derived properties, requires,
approval.when) — a portable SQL subset parsed with sqlglot and transpiled
to the adapter dialect. Sugar on top of ANSI SQL:
==means equality (plain=also accepted)- duration literals:
90d,12h,30m,45s;2wnormalizes to14d now()is the current timestamp, rendered per dialect
Everything else is standard SQL: string literals use single quotes,
double-quoted text is an identifier ("Customer ID"). An expression is a
condition or a value — statements (SELECT, DROP, INSERT, …) are
rejected at validate time.
A bare name resolves to a declared property when one exists, and its
definition is inlined; otherwise it is a source column. Properties may
reference other properties (cycles are rejected). So with
lifetime_value: coalesce(ltv_eur, 0) declared, the predicate
vip: lifetime_value > 1000 compiles to
CASE WHEN COALESCE(t.ltv_eur, 0) > 1000 …. Unknown columns surface when
the view is applied (schema introspection at validate time is on the
roadmap as validate --with-db). Timezone note: now() follows the
warehouse session; pin your warehouse to UTC or the same row can be
active in one environment and churned in another.
approval.when may reference only the action’s params — it is
evaluated in-process by the governance layer at proposal time, never by
interpolating values into SQL.
relations — structured, one entry per name. join is a single column
name present on both sides, or an explicit pair
join: {left: customer_id, right: cust_id}. When omitted, the default
follows where the foreign key lives: has_many and has_one use this
object’s single identity column (the key sits on the target), belongs_to
uses the target’s single identity column (the key sits here). Composite
identities require an explicit pair.
actions — first-class and governed:
requires— precondition, evaluated against the object’s view (so it may referencestate; literals compared withstateare checked against the declared state names at validate time). Preconditions are re-evaluated at execution time, not only at proposal time.policy.roles— who may invoke. Role authentication is the serving layer’s job; the catalog marks every control asenforced_by: ontologiq-serveso consumers know declared ≠ enforced until Fase 3.approval—none/requiredshorthands, or{required: true, when: <params expr>}.audit,dry_run— booleans, default true.effect—webhook(url,{{ env.* }}templating) orhandler(user-provided Python module). v0 safety constraint: actions never write to the database. In webhook URLs,{<identity_column>}placeholders are the record’s identity values.
Seeds
Seeds are demo/dev fixtures: dialect-specific SQL, idempotency is the
author’s job (write CREATE OR REPLACE). They are explicitly not a
migration or transformation layer. They run only on ontologiq seed or
inside ontologiq build — plain run never executes them, so routine
commands execute no user write SQL. Statements are split and executed one
by one so an error names the file and the statement.
CLI
ontologiq init [dir] # scaffold the e-commerce example (DuckDB, zero creds)
ontologiq new object <name> # scaffold objects/<name>.yml
ontologiq validate # parse + resolve references + check every expression
ontologiq compile # generate target/: views SQL, catalog.json, MCP manifest
ontologiq seed # apply seed fixtures (explicit, never implicit)
ontologiq run # create/replace the compiled views via the adapter
ontologiq build # validate + compile + seed + run (+ test, once it lands)
ontologiq show <obj> --key <v> # one record: properties, computed state, actions
ontologiq debug # config + adapter connectivity check
ontologiq serve # MCP server over stdio (see Serving, below)
ontologiq approvals list # proposals awaiting a human decision
ontologiq approvals approve <id> # approve and execute; re-checks the precondition
ontologiq approvals reject <id>
ontologiq audit # the audit trail
ontologiq test # declared checks (later)
compile writes to target/:
views/<object>.sql— the SELECT per object, in the adapter dialect, every identifier quoted (the example project has an object namedorderon purpose)catalog.json— objects, properties (withsensitive), states, resolved relations, actions with governancemcp/manifest.json—get_<object>,list_<object>(with state filter and limit),traverse_<object>_<relation>,propose_<object>_<action>. MCP tool names are global, so every tool is namespaced by object.
Serving: what actually enforces what
ontologiq serve exposes the ontology to an AI agent over MCP (stdio) and is
the only stateful part of Ontologiq. It is optional; the compiler never needs
it. Install it with pip install "ontologiq[serve]".
ontologiq serve # read tools only
ontologiq serve --role support # + governed actions for that role
Identity. MCP over stdio has no authentication: the caller is whoever
launched the process. So the role is asserted once, at start, from
--role or ONTOLOGIQ_ROLE, and never read from a tool argument or a
database row. Without a role, propose_ tools are not even listed — an
agent cannot call what it cannot see. Every audit row records the role, its
source and the OS user, so “who claimed to be support” stays answerable.
Reads. Sensitive properties are not selected at all; the values never leave the warehouse, and the tool result names what was withheld so the agent does not hallucinate around a missing field. Every read is limited (default 100, max 1000) and ordered, and truncation is reported rather than silent. Values are stripped of control characters, ANSI escapes and Unicode tag/bidi characters before reaching the model — data from your warehouse is untrusted text.
The propose flow. propose_<object>_<action> never executes:
- the role is checked, before the warehouse is touched;
- the record and the precondition are fetched in one query, requiring exactly one row;
dry_run: truereports what would happen and changes nothing;- otherwise a proposal is stored and the agent is told it must be approved by a human — and that it cannot do so itself.
Approval lives in a different process (ontologiq approvals approve <id>), deliberately off the MCP surface. At approval the precondition is
re-evaluated, because hours pass and records move; the arguments are
re-verified against the digest taken when the proposal was made; and only
then does the effect fire, with an Idempotency-Key and no redirects
followed. A timeout is reported as unknown, never as failure — the call
may have landed, and “failed” invites a second refund.
Actions that declare no approval requirement are refused unless the server
is started with --allow-auto-effects, and validate warns about them.
What is enforced and what is declared. Role checks, preconditions,
approval, redaction, limits and the audit log are enforced by serve.
Nothing stops someone with warehouse credentials from querying the tables
directly — Ontologiq governs the path through it, not the database. The
catalog marks every control enforced_by: ontologiq-serve so downstream
consumers cannot mistake a declaration for a guarantee.
Explicitly deferred (not dropped)
- State transitions (
from/to/via) — the v0 predicates compute state; the transition graph returns with the action runtime (Fase 3). - Multi-adapter projects —
source.adapteris reserved in the schema but an override that differs from the project adapter is a validate error: one project, one dialect, one connection in v0. - OSI export and packages.yml (git+semver ontology modules) — Fase 5.
- Target schema/naming for views (today: adapter default schema, view
name = object name) and stale-view reconciliation (
runonly upserts). - Read-side policy (row limits, redaction enforcement) —
sensitiveis declared now, enforced byserve. - Audit log storage contract — Fase 3 (workbench-local SQLite).
- Cross-object expressions in predicates (“churned = no orders in 90 days”) — requires join planning; out of scope for v0.