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 NULLvalidate 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:

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:

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/:

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:

  1. the role is checked, before the warehouse is touched;
  2. the record and the precondition are fetched in one query, requiring exactly one row;
  3. dry_run: true reports what would happen and changes nothing;
  4. 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)