The CeQL Book

Centauri Query Language — where time, cause, and trust are syntax.
One language. Three speakers: humans, AI agents, and legacy systems.
CONTENTS 1. Why a new language 2. The mental model 3. Ten queries in two minutes 4. Reading: FACTS 5. Time travel 6. Causality: WHY & EFFECTS 6½. Causal patterns: MATCH 7. Writing: PUT, CORRECT, RETIRE 7½. Reversible transactions: SNAPSHOT, ROLLBACK, DIFF 8. Aggregation 9. Schemas 10. Standing queries: WATCH 11. AI: SIMILAR & CONTEXT 11¾. Full-text search: SEARCH (BM25 + hybrid) 11⅞. The self-learning assistant: ASK 11.95. AI inside the query: ENRICH 12. Operations: PENDING, DISAGREE… 12½. Topology: SHAPE, CONSISTENCY, CYCLES, DRIFT 13. The Rosetta Stone 14. For agents: the JSON AST 14½. Connect your LLM (MCP & function calling) 15. Grammar reference 16½. Procedures (CePL) 16. Best practices

1. Why a new language

SQL was invented for a world where data is a grid of current values and the asker is a human. Both assumptions broke. Today the askers are humans and AI agents and other programs — and the questions that matter are about time (“what was true on March 1?”), belief (“what did we know on March 1?”), cause (“why did this change?”), and trust (“who says so, and how confident are we?”). In SQL those are heroic JOINs over audit tables. In CeQL they are single clauses.

CeQL's design rule: one semantics, three surfaces. Humans write text. Agents send the same query as a typed JSON AST (no string parsing, no injection — see chapter 14). Legacy systems call a plain REST endpoint and get JSON back. Nobody translates for anybody.

2. The mental model (read this once)

Centauri has no tables, rows, or documents. It has three things:

ThingWhat it isExample
SubjectSomething facts can be about. Created automatically the first time a fact mentions it.item:100001/store:4001, toy:robot
Fact (event)An immutable statement: “about this subject, these values are true from this moment.” Each fact carries two clocks (when it became true / when we learned it), a provenance, a confidence, and causal links.price_cents: 763
FacetWhich view of reality the fact belongs to — different systems can hold different beliefs about the same subject.source, register, pdt, shelf

Writing a new fact about the same subject+facet supersedes the old one — but the old one is kept forever. That is the whole trick: current state is just the newest layer of an ever-growing history.

3. Ten queries in two minutes

-- 1. What's true right now?
FACTS OF toy:robot

-- 2. Save a fact (insert and update are the same thing)
PUT toy:robot SET price_cents=500, color='silver'

-- 3. The whole story
HISTORY OF toy:robot

-- 4. Time travel
FACTS OF toy:robot AS OF '2026-03-15'

-- 5. Double time travel (what did we BELIEVE then?)
FACTS OF toy:robot AS OF '2026-03-15' AS KNOWN AT '2026-03-01'

-- 6. Why did it change?
FACTS OF toy:robot WHY DEPTH 3

-- 7. Filter with trust
FACTS OF item:*/store:4001 WHERE trust >= 0.8 AND price_cents > 700

-- 8. Find work that never finished
PENDING pdt OLDER THAN 21 DAYS

-- 9. Where do systems disagree?
DISAGREE ON price_cents

-- 10. Everything an AI needs, one call
CONTEXT FOR toy:robot

4. Reading: FACTS

FACTS [fields] OF subject-pattern
  [FACET f] [AS OF t] [AS KNOWN AT t] [WHERE expr]
  [GROUP BY key] [ORDER BY key [DESC]] [LIMIT n] [OFFSET n]
  [WHY [DEPTH n]]

Subject patterns use *: item:*/store:4001 = every item in store 4001. Projection is optional — omit it (or use *) for full facts, or list fields:

FACTS subject, price_cents, trust OF item:*/store:4001 ORDER BY price_cents DESC LIMIT 10
-- page 2: OFFSET skips rows (project rank for stable row numbers)
FACTS rank, subject, price_cents OF item:*/store:4001 ORDER BY price_cents DESC LIMIT 10 OFFSET 10

WHERE sees both your value fields (price_cents, color…) and the built-in metadata:

FieldMeaning
subject, facet, type, provenancewhat/where/how the fact entered
namespacethe subject's first segment (acme:order/42 → acme) — shared-schema multitenancy: WHERE namespace = 'acme', GROUP BY namespace
trust (alias confidence)0..1
effective, recordedthe two clocks (UnixMicro)
pendingtrue if distributed but never activated
supersededtrue if a newer fact replaced this one

Operators: = != > >= < <= IN (a, b) LIKE 'pat*tern' MATCHES 'text' EXISTS field AND OR NOT ( )

MATCHES is case-insensitive text search; any MATCHES 'penny' scans the subject and every text value on the fact. EXISTS field matches when the field is present (e.g. WHERE EXISTS discount). Fields may use dotted paths into nested values — WHERE address.city = 'EU'.

Indexed filters. A string-equality filter on a value field (WHERE region = 'EU') uses a secondary equality index — built automatically per string field, with a fall-back to a scan for very high-cardinality fields. EXPLAIN shows when the index is probed.

5. Time travel

Two independent clauses, because Centauri keeps two clocks per fact:

ClauseQuestion it answers
AS OF tWhat was true at t? (effective-time travel)
AS KNOWN AT tWhat did the database believe at t? Facts learned after t are invisible — even if they describe times before t.
both togetherThe audit query: “on March 1 (knowledge), what did we think March 15 (reality) would be?”
FACTS OF item:100001/store:4001 AS OF '2026-03-15' AS KNOWN AT '2026-03-01'

Times are deliberately forgiving. All of these work anywhere a time is expected:

AS OF YESTERDAY                  AS OF TODAY        AS OF NOW-7d
AS OF 10 DAYS AGO                AS OF 3 hours before
AS OF 'yesterday 2pm CST'      AS OF 'today at noon'
AS OF 'Mar 15 2026 9:15am EST' AS OF '2026-03-15'
AS OF '2026-03-15T10:00:00Z'   AS OF 1773532800000000  -- UnixMicro

Multi-word phrases with clock times or timezones go in quotes. Timezone abbreviations follow the common North-American readings (CST = UTC-6, EST, PST, …) plus UTC/GMT, IST, BST, CET, JST, AEST.

💬 Or skip the syntax entirely. The dashboard's query bar accepts plain English — “what was the price of item:100001/store:4001 yesterday at 2pm CST” — and the ✨ Helper translates it to CeQL (deterministic rules, no model, no API key; it shows you the CeQL so you learn as you go). When a query doesn't parse, the same helper offers a “did you mean” suggestion. The same endpoint is POST /v1/assist {"text": "..."}.

6. Causality: WHY and EFFECTS

Add WHY to any FACTS query and every result carries its causal chain (what triggered it, what it superseded). Or trace one event directly:

WHY 0193fa2e-77c1… DEPTH 6      -- what led to this event
EFFECTS 0193fa2e-77c1…           -- what this event led to
No other query language has this operator, because no other database stores causes. In SQL, “why is the price wrong?” is a war room. Here it's seven characters.

6½. Causal patterns — MATCH

WHY and EFFECTS start from one known event. MATCH works at the set level: find every causal path between two patterns. It walks the lineage graph outbound (CAUSES) or inbound (CAUSED BY), optionally restricted to one link type.

-- which intents triggered a register flip?
MATCH item:* CAUSES register:* VIA TRIGGERED DEPTH 3

-- what corrected this item?
MATCH item:100001/store:4001 CAUSED BY * VIA CORRECTS

-- any lineage between two namespaces, up to 5 hops
MATCH intent:* CAUSES pdt:* DEPTH 5 LIMIT 50

Each row is a path: from / to subjects, their event ids, the number of hops, and the connecting via link type. VIA filters the edge type (TRIGGERED, SUPERSEDES, CORRECTS, DISTRIBUTED_AS, ACTIVATED_BY, ENRICHED_FROM); omit it for any type.

This is graph pattern-matching over causation — the kind of question a graph database can ask about references, but here it's about why things happened, and it's bi-temporal like everything else.

7. Writing: PUT, CORRECT, RETIRE

PUT subject [FACET f] [TYPE t] SET k=v, k=v, …
  [EFFECTIVE t] [CONFIDENCE c] [SCHEMA id] [PROVENANCE p] [REF r]
PUT toy:robot SET price_cents=450, color='red'
PUT toy:robot SET price_cents=400 EFFECTIVE '2026-07-01'  -- future-dated
CORRECT toy:robot SET price_cents=445   -- same, but typed CORRECTION (audit-friendly)
RETIRE toy:robot                          -- supersedes with {retired: true}
There is no DELETE, on purpose. An immutable database can't pretend the past didn't happen. RETIRE marks the current fact as no longer applicable — but history stays auditable. (For legal erasure: centauri retention bulk-RETIREs matching facts, with legal holds that exempt subjects; the segment layer ships an AES-GCM crypto-erasure primitive for sealed payloads — destroy the key, not history. End-to-end per-subject encryption of the live log is still on the roadmap.)

7½. Reversible transactions — SNAPSHOT, ROLLBACK, DIFF

Because nothing is ever erased, "rollback" here means something no SQL database can do. A rollback doesn't throw work away — it appends superseding reversion facts that restore each subject to a chosen past point, and records the rollback itself as an auditable event. So you can rewind to any past commit, the revert is itself reversible, and AS OF still sees the pre-rollback world.

SNAPSHOT 'before-import'                 -- name the current point

DIFF OF toy:* BETWEEN '2026-03-01' AND '2026-03-15'  -- preview what changed

ROLLBACK                                  -- undo the last commit (default)
ROLLBACK TO SNAPSHOT 'before-import'       -- return to a named point
ROLLBACK OF toy:* TO '2026-03-15'          -- rewind matching subjects to a time
How it stays honest. A ROLLBACK emits one rollback:<ts> marker plus a CORRECTION per changed fact, linked back to the marker — so WHY on a restored fact shows it came from a rollback, and HISTORY grows rather than shrinks. To undo a rollback, roll back to a snapshot taken just before it.
SQLCeQLThe difference
SAVEPOINT sSNAPSHOT 's'survives the session; it's a durable fact
ROLLBACKROLLBACKrewinds committed history, not just an open txn
ROLLBACK TO sROLLBACK TO SNAPSHOT 's'auditable & itself reversible — nothing erased
—DIFF … BETWEEN … AND …see the delta between any two moments first

8. Aggregation

FACTS facet, COUNT(*), AVG(price_cents), MAX(price_cents)
  OF item:*/store:4001 GROUP BY facet
  HAVING COUNT(*) > 5 AND AVG(price_cents) > 300

Aggregates: COUNT(*) COUNT(field) COUNT(DISTINCT field) APPROX_COUNT_DISTINCT(field) SUM AVG MIN MAX MEDIAN STDDEV LISTAGG. Group by any metadata or value field; HAVING filters the groups by their aggregates. Aggregation respects time travel — “average price per facet as known last Tuesday” is just … AS KNOWN AT NOW-2d GROUP BY facet.

Distinct counts. COUNT(DISTINCT region) is exact; APPROX_COUNT_DISTINCT(region) uses HyperLogLog — fixed ~16 KB of memory and ≈0.8% error regardless of cardinality, the technique OLAP engines use to count uniques over billions of rows.

The Oracle trio, same names: MEDIAN (the robust middle value), STDDEV (population standard deviation), LISTAGG (each group's distinct values as one sorted, comma-separated string):

FACTS MEDIAN(price_cents) OF item:*/store:4001
FACTS AVG(price_cents), STDDEV(price_cents) OF item:*/store:4001
FACTS facet, LISTAGG(kind) OF item:*/store:4001 GROUP BY facet

Ranked lists: project the built-in rank column —

FACTS rank, subject, price_cents OF item:*/store:4001
  ORDER BY price_cents DESC LIMIT 10

9. Schemas

DEFINE SCHEMA price (
  price_cents number REQUIRED MIN 1 UNIT 'cents',
  kind string
) TITLE 'A retail price'

PUT toy:car SET price_cents=300 SCHEMA price   -- validated!
SCHEMAS            -- list all (latest versions)
SCHEMA price       -- every version of one

Schemas are optional, versioned, and append-only: redefining creates v2, and facts written under v1 stay valid against v1. Types: number string bool any; constraints: REQUIRED MIN MAX UNIT.

10. Standing queries: WATCH

WATCH ALL
WATCH toy:robot
WATCH ALL FACET pdt TYPE DISTRIBUTED

A WATCH is a query that never finishes: it streams every matching fact the moment it commits (in the dashboard it feeds the LIVE panel; over the API it's the /v1/watch SSE stream; in the Python SDK it's for e in db.watch(...)). Agents subscribe instead of polling.

11. AI: SIMILAR and CONTEXT

SIMILAR TO 0193fa2e-77c1… TOP 5 MIN 0.7   -- embedding search
CONTEXT FOR toy:robot                       -- the reasoning bundle
CONTEXT FOR toy:robot AS KNOWN AT '2026-03-01'  -- decision replay

CONTEXT returns in one call what an agent needs to reason: current facts, recent history, causal chains, cross-facet disagreements with a trust-ranked winner, pending wedges, AI enrichments, schemas, and a confidence summary. With AS KNOWN AT it reconstructs the bundle as it stood at any past moment — the fair way to audit a past (human or AI) decision.

Beyond MATCHES (a plain substring scan), SEARCH ranks results by relevance using BM25 — the same scoring family that powers search engines — computed in a single pass, in pure Go, with no inverted index to maintain. It is bi-temporal like everything else, and it can blend with vector similarity for hybrid retrieval.

-- ranked keyword search across subjects and text values
SEARCH 'late markdown' OF item:* LIMIT 10

-- hybrid: blend BM25 with embedding similarity to a reference event
-- ALPHA 1.0 = pure keyword … 0.0 = pure semantic (default 0.5)
SEARCH 'markdown' OF item:* SIMILAR TO 0193fa2e-77c1 ALPHA 0.5

-- search the corpus as it was known at a past moment
SEARCH 'penny' OF item:* AS KNOWN AT '2026-03-01'
BM25 rewards documents where your terms are frequent (with saturation) and rare across the corpus (high IDF), normalized for document length. Pair it with SIMILAR's vectors and you get hybrid retrieval — the recall of semantics with the precision of keywords, which is exactly what good RAG pipelines want.

Multi-signal ranking. Relevance is the base, but the final order also folds in three things a plain inverted index (Postgres FTS, Elasticsearch) has no way to know: recency (how fresh the fact is), trust (confidence × provenance — a scan-verified fact outranks an AI guess), and causal centrality (how connected the fact is in the WHY graph). Keyword/semantic relevance stays dominant; these mostly settle near-ties. Every hit returns its signal breakdown (relevance, recency, trust, centrality) so the ranking is explainable, not a black box.

Query embedding. With a local embedder registered (the dashboard AI panel's one-click setup, or any model:embed-style config fact), SEARCH also embeds the query text itself and blends vector similarity into the ranking — so it finds matches by meaning even with no shared keywords, no SIMILAR TO reference event needed. Without an embedder it is pure BM25 keyword search; either way it never errors.

11⅞. The self-learning assistant — ASK

Centauri can be its own knowledge base. Q&A live as ordinary facts (kb:<slug>, facet knowledge); ASK retrieves the best answer with the same BM25 ranker as SEARCH. When it can't answer confidently, it records the question as a kb_gap:<slug> fact — which an AI agent can later answer over MCP by appending a new kb: fact, so the same question is answered from the database forever after. The gateway and the memory are the same system.

ASK 'does it scale?'          -- answers from kb:* facts, or…
                              -- logs kb_gap:does-it-scale on a miss

-- the learning loop, all in Centauri:
FACTS OF kb_gap:*                -- 1. what couldn't we answer?
PUT kb:does-it-scale FACET knowledge   -- 2. an agent teaches the answer
  SET question='does it scale?', answer='Single-node, in-RAM working set…'
ASK 'does it scale?'          -- 3. now answered from the database
Agents reach this through the normal centauri_ceql MCP tool — no special endpoint. Because the knowledge is just facts, it's versioned and bi-temporal: you can ask what the assistant would have answered last month with FACTS OF kb:* AS KNOWN AT ….

Grounded answers (RAG). With a chat model registered, a question the knowledge base can't answer doesn't stop at the gap fact: ASK retrieves your most relevant facts (hybrid BM25 + vector search), has the model answer using only those, and returns grounded: true plus sources — the event ids it used, so every answer is checkable. Without a chat model, ASK answers only from kb:* facts and logs gaps, as above.

11.95. AI inside the query — ENRICH

Run a model over matching events from the query itself, and store the result as an enrichment fact. Centauri embeds no model — ENRICH calls an external HTTP endpoint (OpenAI-compatible or Ollama) over the standard library — but because the result lands in the log, inference is cached for free: re-running skips events already enriched, and the result is bi-temporal, provenanced, and queryable. Embeddings flow straight into the vector index, so this is how you light up SIMILAR and hybrid SEARCH.

-- 1. describe a model (the secret is read from $OLLAMA_KEY, never stored)
PUT model:embed FACET config SET
  endpoint='http://localhost:11434/v1/embeddings', kind='embedding', model='nomic-embed-text'
PUT model:summarize FACET config SET
  endpoint='https://api.openai.com/v1/chat/completions', kind='chat',
  model='gpt-4o-mini', prompt='Summarize in one line:', auth_env='OPENAI_KEY'

-- 2. enrich (idempotent — already-enriched events are skipped)
ENRICH item:* USING embed                       -- embeddings → vector index
ENRICH ticket:* USING summarize ON body AS summary -- text annotation

-- 3. query what the model produced
SEARCH 'refund loop' OF ticket:* SIMILAR TO <event> ALPHA 0.3
Inference as a cached fact. Most "AI database" tools recompute or bolt on a pipeline; here a model result is just another superseding, audit-stamped fact. kind is embedding (stored as a vector) or chat (stored as text under AS). It runs server-side, so it also works straight from an agent over MCP.
Secrets stay out of the log. A model config fact never carries a key: auth_env='OPENAI_KEY' names an environment variable, and auth_file='/path/to/key' names a file to read it from (the dashboard's cloud opt-in writes a mode-0600 key file next to the data file). Either way the log stores only the reference; the secret is read at call time and never echoed back.
Zero-setup local AI. You don't have to hand-register models: centauri desktop, the dashboard's AI panel, or POST /v1/ai/enable provisions a local trio via Ollama, sized to the machine — chat gemma3:4b (small, ~8 GB RAM) / qwen3:14b (balanced) / glm-4.7-flash (max, 24 GB+ GPU; a 30B MoE, MIT-licensed, 200K context), embedders nomic-embed-text / bge-m3, and a gemma3 vision model. With local AI enabled, new facts auto-embed in the background, so SIMILAR and hybrid SEARCH light up without ever running ENRICH by hand. Optional cloud boost: POST /v1/ai/cloud with a z.ai key switches chat to GLM-5.2 — honest notes: it sends prompts off-machine and costs money, and GLM-5.2 cannot run locally (its weights are 200+ GB); POST /v1/ai/local switches back, and GET /v1/ai/status reports where answers are computed at any time.

12. Operations

PROFILE OF item:*                -- what does my data look like? fields, types,
                                 -- coverage, ranges, top values, distributions
PROFILE OF item:* AS KNOWN AT '2026-03-01'  -- the shape we believed then
PENDING pdt OLDER THAN 21 DAYS  -- distributed, never activated (wedges)
DISAGREE ON price_cents          -- subjects whose facets disagree
SUBJECTS LIKE item:*4001 LIMIT 50
STATS
EXPLAIN FACTS OF toy:robot WHY   -- access path + JSON AST, without running
EXPLAIN ANALYZE FACTS OF item:*  -- …and run it: rows + timing (reads only)
Scoped tokens (row-level security). Beyond the admin and read tokens, register a token confined to subject prefixes: POST /v1/acl {"token":"…","prefixes":["item:"],"write":false}. Only the token's SHA-256 hash is stored (as an acl:<hash> fact — the secret never lands in the log). A scoped token may run CeQL only over subjects within its prefixes, only via /v1/query, and may write only if granted — deny-by-default for anything broader.
The shell. centauri shell is a psql-style CeQL REPL: type statements, get tables; \d lists subjects, \timing, \x (expanded), \slots, \h for help. It holds the writer lock, so it won't race a running server.

12½. Topology — the shape of your data

Centauri's differentiator: operators that answer questions about the shape of your data, not just its values. They read the same in-memory state as everything else, in pure stdlib Go — no dependencies, no model, just mathematics. Four lenses:

-- SHAPE: persistent homology of a value cloud.
-- Betti-0 = clusters, Betti-1 = loops, Betti-2 = voids.
SHAPE OF item:*/store:4001 ON price_cents
SHAPE OF item:* ON price_cents, trust, av_cost   -- N-D; axes auto-normalized (RAW to disable)
SHAPE OF item:* ON price_cents, trust, av_cost MAXDIM 2  -- include voids (coverage holes)
-- Periodicity/seasonality: a time-delay (sliding-window) embedding turns a
-- periodic signal into a loop, so Betti-1 > 0 means "this repeats" (SW1PerS).
SHAPE OF item:100001/store:4001 ON price_cents WINDOW 12 STRIDE 2
-- Semantic structure of stored embeddings, under the cosine metric.
SHAPE OF item:* ON EMBEDDING METRIC cosine

-- CONSISTENCY: model the facets observing a subject as a sheaf.
-- A global section exists iff they agree; otherwise you get the
-- number of disagreeing clusters, a score, and the outlier facet.
CONSISTENCY OF item:100001/store:4001 ON price_cents
CONSISTENCY OF item:100001/store:4001 ON price_cents EPS 5  -- within 5 = agree

-- CYCLES: H1 of the causal graph. Lineage must be acyclic, so any
-- cycle found is a data-integrity alarm.
CYCLES IN CAUSES OF item:100001/store:4001
CYCLES                                -- scan the whole lineage graph

-- DRIFT: how a field's distribution changes across time buckets
-- (a regime-change / concept-drift detector built on persistence).
DRIFT OF item:*/store:4001 ON price_cents BUCKETS 6
Why this matters: SHAPE sees structure (clusters, loops) that averages and histograms miss; CONSISTENCY turns "do my systems agree?" from a yes/no into a measured, localized answer; CYCLES catches lineage corruption the hash chain can't; DRIFT flags when the world your data describes has changed shape. All bi-temporal — add AS OF / AS KNOWN AT to ask them about any past moment.

13. The Rosetta Stone — every language you know, in CeQL

From SQL

You don't have to translate by hand. A lean read-only SQL SELECT runs directly — paste it into Studio (anything starting with SELECT is auto-routed) or POST it to /v1/sql. It transpiles to CeQL and supports WHERE / GROUP BY / HAVING / ORDER BY / LIMIT, plus AS OF and SQL:2011 FOR SYSTEM_TIME AS OF. Need a real socket? Start the server with -pg-addr :5432 and the same read-only subset is also served over the PostgreSQL wire protocol (simple + extended, so psql, JDBC/ODBC and BI tools connect directly; every column comes back as text). Writes still use CeQL. FROM name means the name:* namespace; FROM facts means all subjects.
SELECT category, AVG(price_cents) FROM sku GROUP BY category HAVING COUNT(*) > 3

The conceptual mapping (what each SQL idea becomes in native CeQL):

SQLCeQLNotes
SELECT * FROM t WHERE …FACTS OF subj WHERE …subjects+facets replace tables
SELECT a, b FROM tFACTS a, b OF subjprojection
ORDER BY / LIMIT / OFFSETsame words—
GROUP BY + COUNT/SUM/AVG/MIN/MAXsame wordsch. 8
HAVING… GROUP BY facet HAVING COUNT(*) > 5 AND AVG(price_cents) > 300—
INSERT INTOPUT subj SET …no table to create first
UPDATE … SETPUT subj SET …same verb! old value kept in history
DELETE FROMRETIRE subjsupersede, never erase (ch. 7)
MERGE / UPSERTPUTinsert-vs-update doesn't exist here
CREATE TABLE / ALTER / DROPnothing — subjects appear on first writeDEFINE SCHEMA is optional validation, not structure
CREATE INDEXnothing — the engine indexes subjects/facets/refs automatically—
BEGIN…COMMITone PUT is atomic; multi-event batches via the API/SDK (add_many)batch = transaction
JOINCONTEXT FOR subj (everything about one subject) or WHY (the causal join)cross-subject analytical joins: export or v0.4
CREATE VIEWsave the CeQL text; or a WATCH for live views—
CREATE TRIGGERWATCH + an agent acting on the streamtriggers become subscribers
GRANTadmin, read-only, and prefix-scoped tokens with per-field masking (POST /v1/acl)full SQL-style named roles remain a gap
temporal AS OF SYSTEM TIME (SQL:2011)AS OF / AS KNOWN ATCeQL has both clocks, not one

Oracle — the full catalogue

Everything beyond core SQL that Oracle people actually use, honestly mapped. ✓ = direct CeQL, ⟳ = different (often better) mechanism, ⚙ = SDK/agent layer, ✗ = gap.

OracleIn CeQL / Centauri
ANALYTIC / WINDOW FUNCTIONS
RANK() OVER (ORDER BY x)✓FACTS rank, subject, x OF … ORDER BY x DESC — rank is a built-in column that numbers the ordered result (OFFSET-aware for paging)
OVER (PARTITION BY facet)⟳GROUP BY facet for aggregates per partition; per-row partition windows are client-side
DENSE_RANK, ROW_NUMBER, NTILE⚙client-side over an ORDER BY result — Centauri returns ordered rows, the SDK numbers them
LAG / LEAD (value, 1)⟳built into the data model: the “previous value” IS the superseded fact — HISTORY OF subj is the whole LAG chain, and WHY links each fact to its predecessor
FIRST_VALUE / LAST_VALUE over time⟳FACTS … AS OF t at the window's two ends
ROWS / RANGE BETWEEN … PRECEDING⚙fetch HISTORY, window client-side; time-range windows are natural because every fact carries its effective time
HIERARCHY & PATTERNS
CONNECT BY PRIOR / START WITH✓WHY event DEPTH n / EFFECTS event DEPTH n — the graph is first-class, no self-join gymnastics
MATCH_RECOGNIZE (row patterns)⚙WATCH + an agent recognizing the pattern on the stream (which can then write its finding back as an AI_INFERRED fact with lineage); fuzzy “moments like this one” → SIMILAR TO
MODEL clause (spreadsheet rules)⚙compute in the SDK/agent, PUT results back as facts — unlike MODEL, every computed cell then knows its inputs (WHY)
PIVOT / UNPIVOT⚙reshape client-side; facets already are the long format
TIME TRAVEL (Oracle Flashback)
AS OF TIMESTAMP (Flashback Query)✓AS OF — but unlimited retention, not undo-segment lottery
VERSIONS BETWEEN✓HISTORY OF subj (optionally WHERE on effective/recorded)
Flashback can't ask “as we believed then”✓AS KNOWN AT — Centauri's second clock; Oracle has no equivalent
DML & SET OPERATIONS
MERGE … WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT✓the entire statement collapses to PUT — matched/not-matched is meaningless when insert and update are the same act
TRUNCATE⟳create a fresh environment (+ in the dashboard); truncating history contradicts the database's purpose
UNION / UNION ALL⟳wildcard subjects (OF item:*) cover the common case; otherwise two queries, concat client-side
INTERSECT / MINUS⚙client-side set ops on event ids
EXISTS / IN / ANY / ALL subqueries⟳IN (list) is native; correlated subqueries become two queries (agents chain them naturally)
WITH (CTE)⚙agents/SDK hold intermediate results; no monolithic statement needed
FUNCTIONS & ODDITIES
DECODE / NVL / NVL2 / COALESCE / CASE⚙value transforms live client-side; WHERE handles the filtering forms
ROWNUM / FETCH FIRST n ROWS ONLY✓LIMIT n OFFSET m
DUAL⟳not needed — nothing requires a FROM
SEQUENCE.NEXTVAL⟳event ids are time-ordered and auto-assigned; no sequences to manage
LISTAGG, MEDIAN, STDDEV✓same names: FACTS facet, MEDIAN(price_cents), STDDEV(price_cents), LISTAGG(kind) OF … GROUP BY facet
optimizer hints /*+ … */⟳none; the v0.3 intent contract (freshness/confidence/budget) replaces hinting with negotiation
PL/SQL (the procedural half)
IF / WHILE / FOR / LOOP / EXIT⚙deliberately not in CeQL — same split as SQL vs PL/SQL. The Python SDK or an agent is the procedural layer
CURSOR open/fetch/close, %NOTFOUND⟳queries return complete results; streaming = WATCH. The boilerplate simply disappears
EXCEPTION WHEN OTHERS, RAISE⟳typed errors over the API; SDK exceptions explain the fix
PACKAGE / PROCEDURE / FUNCTION⚙SDK modules or MCP agents calling CeQL (see the duty-calc conversion pattern)
TRIGGER✓WATCH + subscriber — and unlike triggers, watchers are visible, auditable facts
%TYPE / %ROWTYPE⟳SCHEMAS are introspectable; EXPLAIN returns typed ASTs

From IBM DB2 — including its bi-temporal tables

DB2 is the most honest comparison: it's the flagship implementation of SQL:2011 temporal tables — the closest any mainstream database gets to Centauri's two clocks.

DB2In CeQL / Centauri
FOR BUSINESS_TIME AS OF t (application period)✓AS OF t
FOR SYSTEM_TIME AS OF t (system period)✓AS KNOWN AT t
bitemporal = both clauses combined✓AS OF t1 AS KNOWN AT t2 — same power, two words
the setup: PERIOD SYSTEM_TIME, PERIOD BUSINESS_TIME, separate history table, ADD VERSIONING USE HISTORY TABLE …, per-table opt-in DDL⟳nothing — every fact in Centauri is bi-temporal automatically, no DDL, no history-table plumbing, no opt-in
UPDATE … FOR PORTION OF BUSINESS_TIME⟳PUT … EFFECTIVE t writes a fact valid from t; supersession closes the prior period (mid-period splits: two PUTs)
what DB2 temporal still can't do✓causality (WHY), provenance/trust, cross-facet DISAGREE, wedges (PENDING) — temporal tables version rows, they don't explain them
MERGE … WHEN MATCHED / NOT MATCHED✓PUT
SELECT FROM FINAL TABLE (INSERT …)✓PUT returns the stored event (with id) directly
FETCH FIRST n ROWS ONLY / OFFSET✓LIMIT / OFFSET
DECLARE GLOBAL TEMPORARY TABLE⟳scratch environments (one click, one file) or client memory
isolation: WITH UR / CS / RS / RR⟳readers always see a consistent committed snapshot; writers serialize per store — no isolation-level matrix to reason about
CALL procedure⚙SDK functions / MCP tools
LOAD / IMPORT / EXPORT utilities✓db.pump(...) (CSV/JSON/JSONL) in; the API/log out; /v1/log for byte-exact replication
RUNSTATS / REORG⟳checkpoints happen on shutdown automatically; no stats to gather, no reorgs
CREATE TABLESPACE / bufferpools⟳one log file per environment; the OS page cache is the bufferpool
XML/JSON functions (XMLQUERY, JSON_VALUE)⟳values are JSON natively; nested access is client-side today

From PostgreSQL

PostgreSQLIn CeQL / Centauri
schemas (shared-schema multitenancy)✓the namespace field: prefix subjects per tenant (acme:…), then WHERE namespace='acme' / GROUP BY namespace — cross-tenant analytics stay one query
database-per-tenant✓environments — one file each, created/cloned from the dashboard (no pg_dump+restore dance)
JSONB✓fact values are JSON natively; fields query directly in WHERE
pgvector✓SIMILAR TO over embedding enrichments
LISTEN / NOTIFY✓WATCH — with filters, and the notifications are full facts
tsvector / tsquery full-text✓SEARCH 'penny' OF item:* — BM25-ranked, hybrid with vectors, bi-temporal; MATCHES for plain substring scans (honest: no stemming)
pg_dump / pg_restore✓centauri backup -data x.log -to backup.log — consistent snapshot, chain-verified; restore = point the server at the file
logical replication✓centauri follow — byte-exact log shipping with integrity chain
extensions (PostGIS, etc.)⚙the extension surface is MCP + the SDK; spatial is an honest gap
CREATE ROLE / GRANT±admin + read-only tokens, plus prefix-scoped tokens with field masking; full named roles still open
window functions, CTEs±rank/aggregates/HAVING native; frames and CTEs client-side

From MongoDB

MongoCeQL
db.c.find({price: {$gt: 700}})FACTS OF subjpat WHERE price > 700
insertOne / updateOne / replaceOnePUT subj SET …
deleteOneRETIRE subj
aggregate([$group])FACTS key, COUNT(*) OF … GROUP BY key
$lookupCONTEXT FOR subj / WHY
watch() change streamsWATCH …
$vectorSearchSIMILAR TO event TOP k

From Cypher / graph databases

CypherCeQL
MATCH (a)-[:CAUSED]->(b)WHY event / EFFECTS event — the graph is built in
variable-length paths [*1..6]DEPTH 6

From KSQL / streaming SQL

KSQLCeQL
CREATE STREAM s AS SELECT … EMIT CHANGESWATCH … [FACET f] [TYPE t]
stream/table dualitynative: HISTORY is the stream, FACTS is the table

From GraphQL

GraphQLCeQL
one query, a shaped object graphCONTEXT FOR subj — one call, the whole bundle
typed schema introspectionSCHEMAS, EXPLAIN (returns the typed AST)

From vector databases

Pinecone-styleCeQL
index.query(vector, top_k=5)SIMILAR TO event TOP 5 (vectors stored as enrichments via API/SDK)

14. For agents: the JSON AST

Agents shouldn't build strings. Every CeQL statement has a canonical JSON form — the same structure EXPLAIN prints. POST either form to /v1/query:

POST /v1/query
{"q": "FACTS OF toy:robot AS OF '2026-03-15' WHY"}

-- or, exactly equivalent, no parsing involved:
POST /v1/query
{"ast": {"kind":"facts", "subject":"toy:robot",
         "as_of": 1773532800000000, "why": true}}

Responses always carry a "kind" field to switch on: events, rows, trace, context, put, watch… Through MCP, the centauri_ceql tool takes {"q": "…"} — so any MCP-speaking agent (Claude included) can query Centauri natively. The workflow that makes this future-proof: call EXPLAIN <query> once to learn the AST shape, then emit ASTs directly.

14½. Connect your LLM

Every model reaches Centauri the same way: one tool that POSTs CeQL to the HTTP API, or — for MCP-native clients — the built-in MCP server. Write the tool once; only the registration changes per provider.

The universal tool

# Python — the same function for every provider below
import requests
def centauri_query(ceql):
    return requests.post("http://localhost:7771/v1/query",
        headers={"Authorization": "Bearer TOKEN"},
        json={"q": ceql}).text

Claude / Anthropic — zero code (native MCP)

// add to your MCP client config (Claude Desktop / Code)
{ "mcpServers": { "centauri": { "command": "centauri", "args": ["mcp"] } } }

Claude then has all 20 Centauri tools — no glue code at all. Any MCP host works the same way.

OpenAI · Gemini · Grok · Mistral · local runners

# OpenAI and anything OpenAI-compatible — only base_url changes
from openai import OpenAI
OpenAI()                                         # OpenAI (gpt-4o)
OpenAI(base_url="https://api.x.ai/v1")         # Grok / xAI (grok-2)
OpenAI(base_url="http://localhost:11434/v1")   # Ollama, local (llama3.1)
# …Mistral, Together, Groq, Fireworks, OpenRouter, LM Studio, vLLM too.
# register centauri_query in tools=[...] and route the tool call to it.

# Gemini (function calling)
genai.GenerativeModel("gemini-2.0-pro", tools=[centauri_query])
MCP-native clients (Claude, and any MCP host) get the richest path — 20 typed tools, no code. Everything else calls the single centauri_query tool over HTTP; Grok, Mistral, Together, Groq, OpenRouter and local runners (Ollama, LM Studio, vLLM, llama.cpp) are all OpenAI-compatible, so only the base_url differs.

15. Grammar reference (EBNF-ish)

statement   = facts | history | subjects | profile | put | correct | retire
            | pending | disagree | why | effects | match | similar | search
            | ask | enrich | context | snapshot | rollback | diff | shape
            | consistency | cycles | drift | stats | schemas | schema
            | define | watch | run | explain ;
facts       = "FACTS" [proj] "OF" pattern tail ;
history     = "HISTORY" "OF" pattern tail ;
tail        = { "FACET" name | "AS" "OF" time | "AS" "KNOWN" "AT" time
              | "WHERE" expr | "GROUP" "BY" name
              | "HAVING" havingcond { "AND" havingcond }
              | "ORDER" "BY" name ["DESC"|"ASC"]
              | "LIMIT" int | "OFFSET" int | "WHY" ["DEPTH" int] } ;
havingcond  = agg "(" (name | "*") ")" compareop number ;
proj        = field { "," field } ;     (* "rank" projects result position *)
field       = name | agg "(" (name | "*") ")" ;
agg         = "COUNT" | "SUM" | "AVG" | "MIN" | "MAX" | "MEDIAN" | "STDDEV" | "LISTAGG" ;
put         = ("PUT"|"CORRECT"|"RETIRE") subject [opts] ["SET" kv {"," kv}] [opts] ;
opts        = { "FACET" name | "TYPE" name | "EFFECTIVE" time | "CONFIDENCE" num
              | "SCHEMA" name | "PROVENANCE" name | "REF" name } ;
pending     = "PENDING" facet ["OLDER" "THAN" int ["DAYS"|"D"]] ;
disagree    = "DISAGREE" "ON" name ;
why         = "WHY" event_id ["DEPTH" int] ;          effects = "EFFECTS" … ;
similar     = "SIMILAR" "TO" event_id ["TOP" int] ["MIN" num] ;
context     = "CONTEXT" "FOR" subject ["AS" "KNOWN" "AT" time] ["LIMIT" int] ;
define      = "DEFINE" "SCHEMA" id "(" fielddef {"," fielddef} ")" ["TITLE" str] ;
fielddef    = name type ["REQUIRED"] ["MIN" num] ["MAX" num] ["UNIT" str] ;
watch       = "WATCH" ("ALL" | subject) { "FACET" name | "TYPE" name } ;
match       = "MATCH" pattern ("CAUSES" | "CAUSED" "BY") pattern
              ["VIA" name] ["DEPTH" int] ["LIMIT" int] ;
search      = "SEARCH" str "OF" pattern ["SIMILAR" "TO" event_id ["ALPHA" num]]
              ["AS" "KNOWN" "AT" time] ["LIMIT" int] ;
ask         = "ASK" str ;
enrich      = "ENRICH" pattern ["FACET" name] "USING" name
              ["ON" name] ["AS" name] ["LIMIT" int] ;
snapshot    = "SNAPSHOT" str ;
rollback    = "ROLLBACK" ["TO" "LAST"] | "ROLLBACK" "TO" "SNAPSHOT" str
            | "ROLLBACK" ["OF" pattern] "TO" time ;
diff        = "DIFF" ["OF" pattern] "BETWEEN" time "AND" time ;
shape       = "SHAPE" "OF" pattern "ON" (name {"," name} | "EMBEDDING")
              ["METRIC" name] ["WINDOW" int ["STRIDE" int]]
              ["MAXDIM" int] ["SCALE" int] ["RAW"] ;
consistency = "CONSISTENCY" "OF" subject "ON" name ["EPS" num] ;
cycles      = "CYCLES" ["IN" "CAUSES" ["OF" subject]] ;
drift       = "DRIFT" "OF" pattern "ON" name ["BUCKETS" int] ;
run         = "RUN" name ["WITH" name "=" value {"," name "=" value}] ;
profile     = "PROFILE" "OF" pattern ["AS" "OF" time] ["AS" "KNOWN" "AT" time] ;
explain     = "EXPLAIN" ["ANALYZE"] statement ;
expr        = and { "OR" and } ;     and = unary { "AND" unary } ;
unary       = "NOT" unary | "(" expr ")" | comparison ;
comparison  = name ( ("="|"!="|">"|">="|"<"|"<=") value
                   | "IN" "(" value {"," value} ")" | "LIKE" str
                   | "MATCHES" str )
            | "EXISTS" name ;
time        = "'date'" | "'rfc3339'" | unixmicro | "NOW" | "NOW-"n("d"|"h"|"m") ;
pattern     = subject with optional "*" wildcards ;

16½. Procedures: CePL — PL/SQL for the agent era

When a few steps belong together — look up, guard, compute, write, return — store them as a procedure. CePL is deliberately tiny and reads like instructions to a careful intern:

CePL is not Python. It's a small near-English language that lives inside the database (stored as a versioned proc:<name> fact, run by name, every run returning a step trace). For general-purpose code, use a client SDK — Python, Go, and JavaScript clients live in /sdk, and any language can call POST /v1/query or connect an agent over MCP. Reach for an SDK when logic belongs in your app; reach for CePL when it belongs next to the data.
PROCEDURE duty_estimate(item, units)
  LET rate = FIRST FACTS OF hts:${item} FACET assess
  WHEN rate IS MISSING: FAIL 'no duty rate on file for ${item}'
  LET cost = FIRST FACTS OF cost:${item}
  WHEN cost IS MISSING: FAIL 'no average cost for ${item}'
  LET duty = cost.av_cost * units * rate.comp_rate
  PUT duty:${item} SET duty_amt=${duty}, units=${units} REF 'proc:duty_estimate'
  RETURN duty
END

Define it once (POST /v1/proc, db.define_procedure(...), or the centauri_define_procedure MCP tool), then call it from anywhere: the query bar (RUN), POST /v1/proc/run {"name":"duty_estimate","args":{"item":"100001","units":3}}, the Python SDK's db.run_procedure(...), or the procedure's auto-generated MCP tool. Every run returns the value plus a step-by-step trace.

RUN duty_estimate WITH item='100001', units=3
Procedures are agent tools. Every stored procedure is also auto-exposed over MCP as its own typed tool — proc_duty_estimate(item, units) — so an agent sees a curated, parameterized operation instead of raw query access. Pair that with a scoped token and you've defined a safe, production-ready surface for an agent: named tools, fixed logic, prefix-limited reach.

The complete statement set

StatementFormNotes
PROCEDUREPROCEDURE name(p1, p2) … ENDthe header declares the parameters; every call must supply them all
LET (query)LET x = FIRST FACTS OF hts:${item} FACET assessbinds a CeQL read. FIRST takes the first fact as a record (x.price_cents, x.trust, x.effective…); without it, x is the whole result list
LET (expression)LET duty = cost.av_cost * units * rate.comp_ratearithmetic + - * / ( ), comparisons, AND/OR over params and bound variables
WHENWHEN rate IS MISSING: FAIL 'no rate for ${item}'a guard: IS MISSING / IS PRESENT, comparisons, AND/OR; the action after the colon is any other step. Guards stay flat — no WHEN inside WHEN
PUT / CORRECT / RETIREPUT duty:${item} SET duty_amt=${duty} REF 'proc:x'any CeQL write, with ${var} holes filled under the safety rules below
FAILFAIL 'message with ${var}'abort the run with an error (the message is plain text, not re-parsed CeQL)
RETURNRETURN dutyfinish, optionally with a value — returned alongside the full trace

No loops, no nesting — that's the point. Heavier logic belongs in Python (the SDK) or an agent.

Substitution safety — how ${holes} are filled

A procedure's query templates are re-parsed as CeQL after substitution, so splicing raw strings would let an argument like x' , retired=true reshape the generated statement — textual injection, in the very feature pitched as confinement for scoped agents. So the renderer applies rules by where the hole sits:

HoleRule
numbers and booleansalways render bare
a string that is itself one literal token ("320", "true")splices bare, so SET n=${n} works when a number parameter arrives as a JSON string — safe because the value is provably a single CeQL token (a strict number: no exponents, hex, underscores, or leading +; or true/false)
inside a quoted literal — '… ${x} …'spliced raw, but rejected if the value contains the surrounding quote character
glued to a word — hts:${item}accepts only characters that stay inside one token: letters, digits, and :/_-.@. Never * — a spliced wildcard would silently widen a scoped read
anywhere elsethe string becomes a quoted CeQL literal. A value containing both quote characters is rejected outright — CeQL strings have no escapes, so it cannot be embedded in query text; send such values via the JSON AST instead

FAIL messages are the one exception: they are never re-parsed as CeQL, so their ${holes} fill with the raw formatted value.

Three things PL/SQL never had: procedures are facts (HISTORY OF proc:duty_estimate shows every version ever deployed, and AS KNOWN AT tells you which version ran last March); every run returns a step-by-step trace — procedures explain themselves; and the writes they make carry REF lineage back to the procedure. The 1,600-line Oracle duty package this was modeled on becomes ~10 lines plus the engine doing the bookkeeping.

16. Best practices

Name subjects like paths: kind:id or kind:id/kind:id (item:100001/store:4001) — wildcards then work like directories. Let facets carry perspective: don't store “system X's belief” as a field; make X a facet, and DISAGREE becomes free. Backdate honestly: use EFFECTIVE for when it was true in the world; the database stamps when it learned it, and the gap between the two clocks is where the interesting questions live. Prefer CORRECT over PUT for fixing mistakes — same effect, but the CORRECTION type makes audits self-explanatory. Agents: EXPLAIN once, then send ASTs. Humans: when lost, ask the dashboard — every error message links back to this book.

That's the whole language. Open the dashboard, type a query in the CeQL› bar, and press Run. The fastest way to learn is to break things — which, in a database that never erases anything, is impressively safe.