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.
Centauri has no tables, rows, or documents. It has three things:
| Thing | What it is | Example |
|---|---|---|
| Subject | Something 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 |
| Facet | Which 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.
-- 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
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:
| Field | Meaning |
|---|---|
subject, facet, type, provenance | what/where/how the fact entered |
namespace | the subject's first segment (acme:order/42 → acme) — shared-schema multitenancy: WHERE namespace = 'acme', GROUP BY namespace |
trust (alias confidence) | 0..1 |
effective, recorded | the two clocks (UnixMicro) |
pending | true if distributed but never activated |
superseded | true 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'.
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.Two independent clauses, because Centauri keeps two clocks per fact:
| Clause | Question it answers |
|---|---|
AS OF t | What was true at t? (effective-time travel) |
AS KNOWN AT t | What did the database believe at t? Facts learned after t are invisible — even if they describe times before t. |
| both together | The 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.
POST /v1/assist {"text": "..."}.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
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.
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}
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.)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
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.| SQL | CeQL | The difference |
|---|---|---|
SAVEPOINT s | SNAPSHOT 's' | survives the session; it's a durable fact |
ROLLBACK | ROLLBACK | rewinds committed history, not just an open txn |
ROLLBACK TO s | ROLLBACK TO SNAPSHOT 's' | auditable & itself reversible — nothing erased |
| — | DIFF … BETWEEN … AND … | see the delta between any two moments first |
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.
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
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.
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.
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'
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.
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
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.
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
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.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.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.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)
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.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.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
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):
| SQL | CeQL | Notes |
|---|---|---|
SELECT * FROM t WHERE … | FACTS OF subj WHERE … | subjects+facets replace tables |
SELECT a, b FROM t | FACTS a, b OF subj | projection |
ORDER BY / LIMIT / OFFSET | same words | — |
GROUP BY + COUNT/SUM/AVG/MIN/MAX | same words | ch. 8 |
HAVING | … GROUP BY facet HAVING COUNT(*) > 5 AND AVG(price_cents) > 300 | — |
INSERT INTO | PUT subj SET … | no table to create first |
UPDATE … SET | PUT subj SET … | same verb! old value kept in history |
DELETE FROM | RETIRE subj | supersede, never erase (ch. 7) |
MERGE / UPSERT | PUT | insert-vs-update doesn't exist here |
CREATE TABLE / ALTER / DROP | nothing — subjects appear on first write | DEFINE SCHEMA is optional validation, not structure |
CREATE INDEX | nothing — the engine indexes subjects/facets/refs automatically | — |
BEGIN…COMMIT | one PUT is atomic; multi-event batches via the API/SDK (add_many) | batch = transaction |
JOIN | CONTEXT FOR subj (everything about one subject) or WHY (the causal join) | cross-subject analytical joins: export or v0.4 |
CREATE VIEW | save the CeQL text; or a WATCH for live views | — |
CREATE TRIGGER | WATCH + an agent acting on the stream | triggers become subscribers |
GRANT | admin, 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 AT | CeQL has both clocks, not one |
Everything beyond core SQL that Oracle people actually use, honestly mapped. ✓ = direct CeQL, ⟳ = different (often better) mechanism, ⚙ = SDK/agent layer, ✗ = gap.
| Oracle | In 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 |
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.
| DB2 | In 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 |
| PostgreSQL | In 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 |
| Mongo | CeQL |
|---|---|
db.c.find({price: {$gt: 700}}) | FACTS OF subjpat WHERE price > 700 |
insertOne / updateOne / replaceOne | PUT subj SET … |
deleteOne | RETIRE subj |
aggregate([$group]) | FACTS key, COUNT(*) OF … GROUP BY key |
$lookup | CONTEXT FOR subj / WHY |
watch() change streams | WATCH … |
$vectorSearch | SIMILAR TO event TOP k |
| Cypher | CeQL |
|---|---|
MATCH (a)-[:CAUSED]->(b) | WHY event / EFFECTS event — the graph is built in |
variable-length paths [*1..6] | DEPTH 6 |
| KSQL | CeQL |
|---|---|
CREATE STREAM s AS SELECT … EMIT CHANGES | WATCH … [FACET f] [TYPE t] |
| stream/table duality | native: HISTORY is the stream, FACTS is the table |
| GraphQL | CeQL |
|---|---|
| one query, a shaped object graph | CONTEXT FOR subj — one call, the whole bundle |
| typed schema introspection | SCHEMAS, EXPLAIN (returns the typed AST) |
| Pinecone-style | CeQL |
|---|---|
index.query(vector, top_k=5) | SIMILAR TO event TOP 5 (vectors stored as enrichments via API/SDK) |
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.
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.
# 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
// 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 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])
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.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 ;
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:
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
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.| Statement | Form | Notes |
|---|---|---|
PROCEDURE | PROCEDURE name(p1, p2) … END | the header declares the parameters; every call must supply them all |
LET (query) | LET x = FIRST FACTS OF hts:${item} FACET assess | binds 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_rate | arithmetic + - * / ( ), comparisons, AND/OR over params and bound variables |
WHEN | WHEN 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 / RETIRE | PUT duty:${item} SET duty_amt=${duty} REF 'proc:x' | any CeQL write, with ${var} holes filled under the safety rules below |
FAIL | FAIL 'message with ${var}' | abort the run with an error (the message is plain text, not re-parsed CeQL) |
RETURN | RETURN duty | finish, 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.
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:
| Hole | Rule |
|---|---|
| numbers and booleans | always 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 else | the 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.
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.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.