contextdb's query language is built on three standards:
- PostgreSQL-compatible SQL — DDL, DML, expressions, operators, JOINs, CTEs,
ON CONFLICT DO UPDATE,$parambinding - pgvector conventions —
<=>operator for cosine similarity inORDER BY - SQL/PGQ-style graph queries —
GRAPH_TABLE(... MATCH ...)following SQL/PGQ conventions for bounded graph traversal (not a full standard implementation)
On top of these, contextdb adds declarative policy primitives: IMMUTABLE, STATE MACHINE, DAG, RETAIN, HISTORY, SYNC, and PROPAGATE. These are contextdb-specific extensions — everything else should feel familiar if you've used PostgreSQL.
contextdb ships with no built-in tables or schema. You define your own tables and attach these primitives to whichever columns need them. The example tables in this reference (documents, tasks, items, media, and so on) are illustrative only — stand-ins for whatever schema you design, not something contextdb provides.
All examples work in the Rust API via db.execute(sql, ¶ms) where parameters are passed as HashMap<String, Value>. The CLI REPL does not support parameter binding ($param) — use literal values directly. Vector search works in the CLI using vector literals: ORDER BY embedding <=> [0.1, 0.2, 0.3] LIMIT 5.
documents here is an example table you might define — name your own tables and columns (see the README):
CREATE TABLE documents (
id UUID PRIMARY KEY,
data JSON,
embedding VECTOR(384),
bucket TEXT,
recorded_at TIMESTAMP DEFAULT NOW()
) IMMUTABLESee Table Options for IMMUTABLE, STATE MACHINE, DAG, RETAIN, HISTORY, MAINTAIN COUNT, SYNC, and PROPAGATE.
CREATE TABLE never redefines a table that already exists. A second
CREATE TABLE documents (...) is refused, naming the table, and the existing
declaration and every row in it are left exactly as they were — nothing is
replaced and no column's values are dropped. To re-run a schema script
harmlessly, write the idempotent spelling:
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY,
data JSON
)IF NOT EXISTS creates the table when the name is free and does nothing at all
when it is taken — the existing table keeps its own columns and rows even when
the skipped declaration describes a different shape. Use ALTER TABLE to change
a live table's shape, or DROP TABLE first to start over.
Later examples in this reference also use a second illustrative table, items:
CREATE TABLE items (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
item_type TEXT,
context_id UUID,
is_deprecated BOOLEAN DEFAULT FALSE,
embedding VECTOR(384),
created_at TIMESTAMP DEFAULT NOW()
)ALTER TABLE t ADD [COLUMN] col TYPE
ALTER TABLE t DROP [COLUMN] col
ALTER TABLE t RENAME COLUMN old TO new
ALTER TABLE t SET RETAIN 7 DAYS [SYNC SAFE]
ALTER TABLE t DROP RETAIN
ALTER TABLE t SET HISTORY ALL | CURRENT ONLY
ALTER TABLE t SET SYNC OFF | SYNC PUSH ONLY | SYNC PULL ONLY | SYNC TWO WAY
ALTER TABLE t SET SYNC CONFLICT KEEP FIRST | KEEP LATEST
ALTER TABLE t SET MAINTAIN COUNT (col [ASC|DESC], ...), (...) | ALL PREFIXES | NONE | DEFAULT
ALTER TABLE t ALTER COLUMN vector_col SET MAX_PARTITIONS positive_integer
ALTER TABLE t ALTER COLUMN vector_col SET SEARCH_MODE AUTO | EXACT | INDEXED
ALTER TABLE t ALTER COLUMN vector_col SET AUTO_INDEX_AT positive_integer | DEFAULT
ALTER TABLE t ALTER COLUMN vector_col SET HNSW DEFAULT | (M = positive_integer | DEFAULT, EF_CONSTRUCTION = positive_integer | DEFAULT, EF_SEARCH = positive_integer | DEFAULT)COLUMN is optional on ADD and on DROP — ALTER TABLE t DROP a and ALTER TABLE t DROP COLUMN a both parse.
A table's conflict policy is declared on the table itself with the
SYNC CONFLICT KEEP FIRST | KEEP LATEST clause (see CREATE TABLE), and can
also be changed later with ALTER TABLE t SET SYNC CONFLICT ... above.
There is no DROP HISTORY: ALTER TABLE t SET HISTORY ALL is the removal,
and reads as what it does — dropping a window restores "keeps forever" (an
absence), whereas HISTORY ALL is a nameable state.
DROP TABLE tCREATE TRIGGER document_seen ON documents WHEN INSERT;
CREATE TRIGGER item_changed ON items WHEN UPDATE;
CREATE TRIGGER synced_item_seen ON items WHEN INSERT INCLUDING SYNC;
DROP TRIGGER document_seen;CREATE TRIGGER declares a host-callback Trigger. The callback is
registered through the Rust API with
Database::register_transactional_trigger_callback (the older
register_trigger_callback name registers the same kind of handler) and
activated by Database::complete_initialization. Triggers are for
transactional observation and cascade writes; they are not validation triggers.
Use STATE MACHINE, IMMUTABLE, DAG, and PROPAGATE for engine-enforced
invariants.
A trigger handler only prepares database work inside the firing transaction.
It runs before the engine's final validation of the complete write set, so it
may insert a row that the triggering write depends on, such as a referenced
parent. If final validation, a plugin, or the durable commit then refuses the
transaction, nothing the handler wrote becomes visible and no delivery is
queued. A handler must not send notifications or perform other irreversible
external work. For that, route an event to a sink registered with
Database::register_sink: a routed row written in the transaction queues a
pending delivery in the same commit, and the sink runs only after that commit is
durable, retrying with the same SinkEvent::action_id after a failure or
restart.
DROP TRIGGER requires an administrative handle and is refused inside a trigger callback,
including its transaction-bound handle, with Error::TriggerRequiresAdmin. The refusal
preserves the active trigger.
By default, a trigger fires only for local writes. INCLUDING SYNC also fires
an INSERT or UPDATE trigger for authenticated sync rows that this database
accepts. DELETE triggers, with or without INCLUDING SYNC, are refused at
declaration time with TriggerEventUnsupported. The incoming row,
callback writes, and callback outcome share one transaction: either all commit,
or callback failure rolls all of them back. The private sync image initially
stages only the received unit's affected tables and their dependencies. When a
callback accesses another existing table, the engine discards the incomplete
private attempt, stages that table and its dependencies under the same memory
budget, and retries. This discovery also runs when the callback catches
TableNotFound; the final commit still includes its related write. A genuinely
absent table retains its ordinary lookup error. Callbacks can run more than
once during this private preparation, so they must keep their work inside the
transaction. On this received-sync path the
callback may only append relational INSERT rows (plus engine-owned lineage
sidecars). Relational updates/deletes, graph or vector mutations, DDL, and
non-engine configuration are refused with a typed trigger-effect error and the
whole received transaction is rolled back. The same callback invoked by a
local write retains the full callback API. A received-sync callback may use an
active-transaction TXID placeholder in a non-identity column; the engine
canonicalizes it after final transaction assignment. The same placeholder in
any sync identity column (an explicit natural key, every primary-key column,
or the id fallback) is refused as
TXID identity placeholder; a fixed non-placeholder TXID identity remains
valid.
Concurrency: the callback runs inside the firing transaction. Same-DB
cross-thread writers wait-and-proceed, unrelated databases proceed
independently, callback-thread reentry receives CallbackReentry, callback
tx-bound handles stay isolated to the runner thread, cron same-DB contention
remains immediate, and the bounded deadlock guard emits a structured
tracing::warn! with trigger_name, waited_ms, and surface.
See Architecture for the operator
runbook.
CREATE INDEX idx_name ON t (col)In the Rust API, a missing binding for a required INSERT value returns the existing
Error::NotFound naming the parameter before the row is staged. A supplied NULL remains a
constraint violation; a missing binding is not silently converted to NULL.
INSERT INTO documents (id, data, embedding)
VALUES ($id, $data, $embedding)
-- Multiple rows
INSERT INTO items (id, name) VALUES ($id1, $name1), ($id2, $name2)
-- Upsert
INSERT INTO items (id, name) VALUES ($id, $name)
ON CONFLICT (id) DO UPDATE SET name = $nameUPDATE tasks SET status = 'superseded' WHERE id = $id
DELETE FROM scratch WHERE created_at < $cutoffSELECT [DISTINCT] columns FROM table
[INNER JOIN | LEFT JOIN other ON condition]
[WHERE condition]
[ORDER BY col [ASC|DESC]]
[USE RANK sort_key]
[LIMIT n]A SELECT-list column that does not exist on the table is ColumnNotFound { table, column } — the same class WHERE/JOIN/ORDER BY already use for an identical unknown-name failure — never Ok with a wrong or missing column:
SELECT nope FROM items;
-- Err(ColumnNotFound { table: "items", column: "nope" })A WHERE (or JOIN ... ON) predicate that fails to EVALUATE — as opposed to one that reads a NULL value — is a statement error, never a silently empty or partial result:
-- `tag` is TEXT; negating it cannot evaluate:
SELECT id FROM items WHERE -tag > 0;
-- Err(PlanError("cannot negate non-numeric value"))A real NULL value compared with =, <, etc. is unaffected — that is standard SQL's NULL-as-false comparison semantics, and still correctly excludes the row rather than erroring:
-- `score` is NULL on this row; the row is excluded, not an error:
SELECT id FROM items WHERE score = 5;A qualifier naming a table that is not in scope for the query is ColumnNotFound, even when the bare column name it qualifies is real on some other table:
-- `x` is not `items` and is not declared as an alias anywhere in this query:
SELECT id FROM items WHERE x.name = 'a';
-- Err(ColumnNotFound { table: "items", column: "name" })A qualifier naming the query's own table, alias, or a joined relation keeps working exactly as before:
SELECT t.id FROM items AS t WHERE t.name = 'a';
SELECT items.id FROM items INNER JOIN tags ON items.id = tags.item_id WHERE tags.label = 'x';A bare boolean column, its negation, a boolean $param, and a literal NULL are all standard SQL and all work as predicates on their own — no = TRUE / = FALSE needed:
SELECT id FROM items WHERE active; -- active = TRUE
SELECT id FROM items WHERE NOT active; -- active = FALSE
SELECT id FROM items WHERE $include_drafts; -- boolean parameter
SELECT id FROM items WHERE NULL; -- matches nothing, never an errorORDER BY accepts a plain column or any expression form a SELECT list already evaluates — currently COALESCE(...) and the arithmetic operators (+, -, *, /):
SELECT tag FROM readings ORDER BY COALESCE(pref, fallback);
SELECT tag FROM readings ORDER BY score + 1 DESC;An expression form the engine cannot evaluate is a typed, plan-time refusal rather than a silently unsorted result:
SELECT tag FROM readings ORDER BY UPPER(tag);
-- Err(OrderByExpressionNotSupported)WITH active AS (
SELECT id, name FROM items WHERE status = 'active'
)
SELECT * FROM active WHERE name LIKE 'sensor%'Multiple CTEs via comma separation. Non-recursive only.
Scoped handles filter ordinary reads to the rows visible to that handle. List
shapes keep this silent-filter behavior: full scans, non-unique predicates,
ranges, IN (...) predicates even on primary keys, ordered LIMIT 1, and
sort-elided index walks return Ok with only visible rows.
Explicit-anchor SELECT shapes are different. If a constrained handle names a
specific row identity through equality on a primary key, a single-column
UNIQUE, or every column of a composite UNIQUE, and the row exists but is
hidden by the handle, the query returns the typed visibility error from the
gate that hid it: ContextScopeViolation, ScopeLabelViolation, or
AclDenied. Missing rows still return an empty result.
Predicate writes keep their existing contract. UPDATE ... WHERE pk = $id and
DELETE ... WHERE pk = $id against a hidden row return
Ok(rows_affected = 0) rather than a typed read-refusal error.
BEGIN;
-- statements
COMMIT;
-- or
ROLLBACK;One transaction may declare a complete schema in authored order, including tables, indexes, vector/rank/DAG table shapes, event types, triggers, sinks, and routes. Commit publishes the declarations together; rollback publishes none. A transaction containing schema declarations may not also write rows.
SHOW SYNC_CONFLICT_POLICY;
SET MEMORY_LIMIT 512M;
SHOW MEMORY_LIMIT;
SET MEMORY_LIMIT NONE;
SET DISK_LIMIT 1G;
SET DISK_LIMIT NONE;
SHOW DISK_LIMIT;
SET MAINTENANCE_POLL_INTERVAL 250 MILLISECONDS;
SET MAINTENANCE_POLL_INTERVAL '5 SECONDS';
SHOW MAINTENANCE_POLL_INTERVAL;SHOW SYNC_CONFLICT_POLICY returns one policy column: a first row giving the bare deployment default (keep_first), then one {table}={word} row per table that declares a conflict policy, plus one such row for each built-in work-ledger table (and peer_directory) currently present in the store, suffixed (engine-owned) to distinguish it from an operator-declared row.
SHOW MEMORY_LIMIT returns limit, used, available, and startup_ceiling.
Size suffixes are binary. Both settings accept an unquoted or quoted size and NONE: SET MEMORY_LIMIT 2G,
SET MEMORY_LIMIT '2G', SET DISK_LIMIT 2G, and SET DISK_LIMIT NONE use the same forms. 2G resolves to
2147483648 bytes. CLI --memory-limit 2G uses the same value. The limit governs charged bytes,
which are separate from whole-process RSS.
SHOW DISK_LIMIT returns the same columns for file-backed storage. On :memory: databases, disk limit commands are accepted but ignored.
SHOW MAINTENANCE_POLL_INTERVAL returns one milliseconds column. The default is 60,000 ms.
SET MAINTENANCE_POLL_INTERVAL accepts a positive integer followed by MILLISECONDS or SECONDS,
either unquoted or as one quoted value. The CLI flag --maintenance-poll-ms remains an integer
millisecond value, not SQL text. If that flag is omitted, a file-backed store keeps and uses its
persisted interval.
For a file-backed database the value persists and governs later opens. Time wakes the engine-owned
worker; the declared per-column thresholds decide whether vector consolidation runs.
Schema inspection preserves authored CONTEXT_ID, SCOPE_LABEL (...), and
SCOPE_LABEL_READ (...) WRITE (...) in rendered .schema DDL. The typed schema answer also keeps
Simple and Split scope-label forms and their label sets. A synced full-schema restore retains the
same access declarations; local vector partition keys grant no additional access.
| Type | Description | Example |
|---|---|---|
INTEGER / INT |
64-bit signed integer | 42 |
REAL / FLOAT |
64-bit floating point | 3.14 |
TEXT |
UTF-8 string | 'hello' |
BOOLEAN / BOOL |
Boolean | TRUE, FALSE |
UUID |
128-bit UUID | '550e8400-e29b-41d4-a716-446655440000' |
TIMESTAMP |
Stored as a Unix timestamp (Value::Timestamp(i64)); ISO 8601 text literals are also accepted on input |
NOW() |
JSON |
JSON value | '{"key": "value"}' |
VECTOR(n) |
Fixed-dimension float vector | [0.1, 0.2, 0.3] |
| TXID | Engine-issued transaction id (Value::TxId). Populate only via the library API with a bound parameter; SQL literals are rejected. Sync-apply advances the local TxId allocator past incoming peer values. |
Value::TxId(tx.id()) |
NULL values display as NULL. Vectors display as [0.1, 0.2, ...].
Vector columns can choose per-index scalar quantization:
CREATE TABLE media (
id UUID PRIMARY KEY,
source TEXT,
vector_text VECTOR(8) WITH (quantization = 'SQ8'),
vector_vision VECTOR(4) WITH (quantization = 'SQ4')
);Valid quantization values are F32, SQ8, and SQ4; the default is F32.
A VECTOR(n) column holds embeddings and nothing else. Every write door — INSERT, UPDATE, a
conditional UPDATE, ON CONFLICT ... DO UPDATE, and a column default — accepts an embedding of
the declared dimension, the text spelling of one ('[0.1, 0.2, 0.3]'), or NULL where the column
is nullable. Anything else is refused before the statement stages a row, with
ColumnTypeMismatch { table, column, expected, actual } naming the column that rejected it and
the type of the value that was bound. A scalar, boolean, or JSON value bound into a vector column
by mistake is therefore reported at the statement that bound it, not discovered later when the
store is read back.
NOT NULL on a vector column keeps that meaning when the store is read directly — the route a
reader takes when no live process owns the file (see CLI Reference). Such a read serves
a row only when the file still holds the embedding the declaration promises; where it does not,
the read is refused with direct_read_requires_writer rather than handing back a row whose
required column has quietly vanished, and a writable open (--write) is what mends the store. The
rule follows the declaration, not the storage layout: a quantized SQ8/SQ4 column keeps its
embedding in the vector index instead of in the row, so a required quantized column is served from
that stored embedding and refused when the embedding is gone, exactly as a required F32 column is
refused when its cell holds no embedding. A nullable vector column is untouched by all of this —
an absent embedding is a value there, and the row is served with NULL.
Each vector column is a separate (table, column) index. Search routes to the
column named in ORDER BY:
SELECT id FROM media
WHERE source = 'camera-1'
ORDER BY vector_vision <=> '[0,0,1,0]' LIMIT 5;Inspect registered vector indexes with:
SHOW VECTOR_INDEXES;
SHOW VECTOR_PARTITIONS FOR media.vector_vision;SHOW VECTOR_INDEXES returns one summary row per (table, column) with its
declaration, aggregate lifecycle and accounting state. SHOW VECTOR_PARTITIONS
returns the typed-key detail for individual layouts; use its optional FOR,
LIMIT, and OFFSET clauses when a column has many layouts. Inspection is
read-only and does not build, replay, or repair an index.
A VECTOR(n) column can declare a rank policy that combines raw cosine
similarity with typed columns from the anchor row and one indexed joined row.
The policy is attached to the specific (table, vector_column) index. Because
the policy lives in schema, every caller that asks for the same SORT_KEY gets
the same ranking behavior; applications do not need to copy formula text into
each query.
Grammar shape:
<column> VECTOR(n)
[WITH (quantization = 'F32' | 'SQ8' | 'SQ4')]
RANK_POLICY (
JOIN <joined_table> ON <joined_indexed_column>,
FORMULA '<rank expression>',
SORT_KEY <identifier>
)CREATE TABLE task_results (
id UUID PRIMARY KEY,
task_id UUID NOT NULL,
success BOOLEAN NOT NULL
);
CREATE INDEX task_results_task_id_idx ON task_results(task_id);
CREATE TABLE tasks (
id UUID PRIMARY KEY,
description TEXT NOT NULL,
confidence REAL,
embedding VECTOR(384) RANK_POLICY (
JOIN task_results ON task_id,
FORMULA 'coalesce({confidence}, 1.0) * coalesce({success}, 1.0)',
SORT_KEY effective_confidence
)
);Use the policy during vector search with USE RANK <sort_key>:
SELECT id, description
FROM tasks
ORDER BY embedding <=> $query USE RANK effective_confidence
LIMIT 10;Without USE RANK, the same query returns cosine ordering:
SELECT id, description
FROM tasks
ORDER BY embedding <=> $query
LIMIT 3;
-- id description vector_score
-- d1 closest match 1.0
-- d2 near match 0.75
-- d3 weaker match 0.5With the policy, ordering uses the formula before the final LIMIT:
SELECT id, description
FROM tasks
ORDER BY embedding <=> $query USE RANK effective_confidence
LIMIT 3;
-- id description vector_score rank
-- d2 near match 0.75 1.0
-- d3 weaker match 0.5 0.5
-- d1 closest match 1.0 0.0USE RANK requires the vector ORDER BY ... <=> ... and LIMIT in the same
query. Unknown sort keys return RankPolicyNotFound; there is no silent
fallback to cosine ordering.
Formula references use {column}. Supported operands are REAL, INTEGER,
BOOLEAN, numeric literals, {vector_score}, +, *, parentheses, and
coalesce(expr, literal). BOOLEAN values coerce only inside the rank
formula (TRUE = 1.0, FALSE = 0.0). TEXT, JSON, VECTOR, dotted
refs, subqueries, CASE, subtraction, division, and arbitrary function calls
are rejected at DDL time. * binds tighter than +; parentheses override
precedence.
JOIN table ON column is a single left-outer lookup through an existing index
on the joined table. The joined column is resolved to an anchor-side join column
at DDL time and the protected joined-table index is used at search time.
Candidates with no joined row remain eligible; joined columns evaluate as
NULL, so coalesce can provide a fallback. Dropping the joined table, joined
column, resolved anchor join column, formula-referenced columns, or the
protected join index is refused while the rank policy depends on it.
Anchor-side join-column resolution is deterministic. If the joined column is
the joined table's primary key, ContextDB first looks for
<singular_joined_table>_id or <joined_table>_id on the anchor table, then
falls back to a same-named anchor column, then the anchor primary key. For other
joined columns, ContextDB uses a same-named anchor column when present,
otherwise the anchor primary key. Ambiguous inferred anchor columns are rejected
at DDL time.
min_similarity applies to raw cosine before the rank formula runs. A candidate
below the similarity floor is excluded even if its joined-row metric would have
made the formula large.
Current limits to account for in production designs:
INDEXED— andAUTOon the indexed route — ranks the rank formula over the bounded candidate set vector retrieval returns, then applies the final top-k; it does not force an exhaustive scan of every row in the corpus.USE VECTOR EXACTevaluates the rank formula over every allowed stored vector before the finalLIMIT. If a formula does not reference{vector_score}and cosine is a weak candidate generator for the metric being optimized, useUSE VECTOR EXACTrather than widening the search limit.- If more than one joined row matches a candidate, the current policy uses one
matched row, chosen by highest internal
RowId. Model joined data as a single current summary row when ranking semantics need to be stable. - Formula
{column}references resolve anchor-row columns before joined-row columns. The reserved{vector_score}name cannot be shadowed, and anidpresent on both sides is rejected as ambiguous. Avoid duplicate formula column names until table-qualified references exist. JOIN table ON columnis rank-policy lookup syntax, not arbitrary SQL join syntax. It does not support predicates, composite joins, aggregation, or ordering.- The formula language is intentionally closed for safety. Functions such as
min,max,clamp, time decay, division, and subtraction are not part of the current surface. - Sync currently round-trips rank policies through rendered DDL text. Structured policy replication is a future hardening item.
projects is another illustrative table, referenced below by a foreign key:
CREATE TABLE projects (id UUID PRIMARY KEY, name TEXT NOT NULL)CREATE TABLE tasks (
id UUID PRIMARY KEY,
description TEXT NOT NULL,
status TEXT NOT NULL,
confidence REAL DEFAULT 0.0,
email TEXT UNIQUE,
project_id UUID REFERENCES projects(id)
)Table-level composite foreign keys are supported when the referenced parent
tuple is covered by an ordered PRIMARY KEY or UNIQUE constraint:
CREATE TABLE parent (tenant INTEGER, number INTEGER, UNIQUE(tenant, number));
CREATE TABLE child (
id INTEGER PRIMARY KEY,
tenant INTEGER,
number INTEGER,
FOREIGN KEY (tenant, number) REFERENCES parent(tenant, number)
);| Constraint | Description |
|---|---|
PRIMARY KEY |
Unique row identifier |
NOT NULL |
Value required |
UNIQUE |
No duplicate values. On a single column, a duplicate plain INSERT is an idempotent no-op (returns Ok(rows_affected=0)); a table-level multi-column UNIQUE (col, ...) instead refuses loudly — see Composite Uniqueness. |
DEFAULT expr |
Default value for inserts |
REFERENCES table(col) / FOREIGN KEY (...) REFERENCES ... |
Foreign key — writes are rejected if the referenced row or tuple does not exist; in explicit transactions the error may surface at COMMIT |
IMMUTABLE |
Column is audit-frozen — INSERT sets the value once; UPDATE, ON CONFLICT DO UPDATE, sync-apply mutations, and schema-altering DDL against the column are rejected with Error::ImmutableColumn |
ACL REFERENCES table(col) |
Column carries the row's access-control id — a non-administrative handle reads the row only when its principal holds a matching grant; a handle with no grant-holding principal is refused the table with Error::PrincipalRequired |
An audit-frozen column carries data that must not be silently rewritten by anyone, through any path. Declare it with IMMUTABLE:
CREATE TABLE tasks (
id UUID PRIMARY KEY,
task_type TEXT NOT NULL IMMUTABLE,
description TEXT NOT NULL IMMUTABLE,
reasoning JSON,
confidence REAL,
status TEXT NOT NULL DEFAULT 'active'
) STATE MACHINE (status: active -> [superseded, archived])task_type and description are provenance — set once at INSERT and never rewritten. status and confidence remain mutable. An UPDATE tasks SET task_type = '…' returns Error::ImmutableColumn; the row is unchanged. Sync-apply across a synced edge enforces the same rule on the peer: incoming row-changes that mutate a flagged column are rejected and surface in ApplyResult.conflicts. ALTER TABLE ... DROP COLUMN, RENAME COLUMN, and column-type-altering ALTER against a flagged column are refused.
Correction without rewrite — the supersede pattern. When a recorded row turns out to be wrong, insert a new row with the corrected values and mark the original superseded:
-- Original (frozen)
INSERT INTO tasks (id, task_type, description, status)
VALUES ('11111111-1111-4111-8111-111111111111', 'sql-migration', 'migrate to contextdb', 'active');
-- Correction: a new row, not an update. Both rows remain queryable.
INSERT INTO tasks (id, task_type, description, status)
VALUES ('22222222-2222-4222-8222-222222222222', 'sql-migration', 'migrate to contextdb (rev 2)', 'active');
UPDATE tasks SET status = 'superseded' WHERE id = '11111111-1111-4111-8111-111111111111';Nothing disappears. The audit trail shows both the original commitment and its correction.
Row-level authorization is opt-in and declared by the application, not built into the engine's tables. A data table names the column that carries each row's access-control id, and a grant table says which principal holds which id:
CREATE TABLE acl_grants (
id UUID PRIMARY KEY,
principal_kind TEXT,
principal_id TEXT,
acl_id UUID
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
context_id UUID CONTEXT_ID,
acl_id UUID ACL REFERENCES acl_grants(acl_id),
payload TEXT
);Grant a principal one access-control id by inserting a grant row. principal_kind is 'Agent' or 'Human'; principal_id is that principal's name:
INSERT INTO acl_grants (id, principal_kind, principal_id, acl_id)
VALUES ('33333333-3333-4333-8333-333333333333', 'Agent', 'report-reader',
'44444444-4444-4444-8444-444444444444');Read through a handle that names the principal. The same handle form serves an ordinary query and a bounded read session, and both answer the same rows:
let reader = database.scoped_with_constraints(
None,
None,
Some(Principal::Agent("report-reader".to_string())),
);
let rows = reader.execute("SELECT payload FROM notes ORDER BY id", &HashMap::new())?;
let bounded = reader.read_session(ReadLimits::default())?
.execute("SELECT payload FROM notes ORDER BY id", &HashMap::new())?;Rows whose acl_id the principal was not granted are absent from both answers.
A handle that narrowed itself by context or scope but named no principal that can hold grants is refused the table with Error::PrincipalRequired. Authorization is a property the table declared, not an axis the reader opts into, so narrowing by some other axis never turns the grant filter off.
Principal::System cannot hold grants and is refused the same way.
Constraints compose. A handle that names both a context and a principal hides the rows outside its context and the rows it holds no grant for:
let reader = database.scoped_with_constraints(
Some(BTreeSet::from([ContextId::new(context)])),
None,
Some(Principal::Agent("report-reader".to_string())),
);An administrative handle — one that declared no context, no scope, and no principal — reads every row, and a table that declares no ACL REFERENCES column is unaffected: it keeps narrowing by exactly the axes it does declare.
A direct read of a CLOSED store is not a handle that declared anything. ReadSession::open on a path with no live owner, and the CLI pointed at a path, read the committed store as its owner does: no principal is declared, so every row is returned, ACL column or not. Row-level authorization therefore governs handles that declare a principal — an embedded scoped_with_constraints reader and a read served by a live owner — while who may read a closed store at all is governed by the file's permissions.
ACL is authorization, not relevance ranking. A withheld row is withheld because the reader is not entitled to it; it is never scored, ordered, or surfaced as a lower-ranked result.
Enforce uniqueness across a combination of columns using a table-level constraint:
CREATE TABLE edges (
id UUID PRIMARY KEY,
source_id UUID NOT NULL,
target_id UUID NOT NULL,
edge_type TEXT NOT NULL,
UNIQUE(source_id, target_id, edge_type)
)A duplicate (source_id, target_id, edge_type) tuple is refused with a loud, standard-SQL constraint error — Error::UniqueViolation { table, column }, with column naming every column of the constraint — never a silent Ok. Rows that share individual column values but differ in at least one constrained column are allowed. Rows with NULL in any constrained column do not participate in the composite uniqueness check.
INSERT INTO edges (id, source_id, target_id, edge_type)
VALUES ($id, $source_id, $target_id, $edge_type);
-- A second row with the identical (source_id, target_id, edge_type) tuple:
INSERT INTO edges (id, source_id, target_id, edge_type)
VALUES ($id2, $source_id, $target_id, $edge_type);
-- Err(UniqueViolation { table: "edges", column: "source_id, target_id, edge_type" })A single-column UNIQUE (declared on one column, not as a table-level tuple) keeps its existing idempotent-no-op convention on a plain INSERT: a duplicate value returns Ok(rows_affected=0) and leaves the row count unchanged. ON CONFLICT ... DO UPDATE is unaffected either way — it always updates the conflicting row rather than erroring.
When a row's identity is a combination of columns — not a single surrogate key — declare it with a table-level PRIMARY KEY (col, col, ...):
CREATE TABLE metric_windows (
machine_id TEXT NOT NULL,
sensor_id TEXT NOT NULL,
metric TEXT NOT NULL,
window_start INTEGER NOT NULL,
value INTEGER NOT NULL,
PRIMARY KEY (machine_id, sensor_id, metric, window_start)
)The whole declared tuple is the row's identity everywhere it matters, including across synchronization: two machines writing the same (machine_id, sensor_id, metric, window_start) are the same row, and two rows differing in only one key column — even a later one such as window_start — are distinct rows and both survive a sync. Re-declaring the identical tuple is a duplicate and is refused. The columns are matched in the order declared, and the declaration round-trips through .schema and travels with synced DDL to a receiving machine.
Use a table-level PRIMARY KEY (...) for a multi-column key only; a single-column key stays the column-level id UUID PRIMARY KEY form. A table-level PRIMARY KEY (...) cannot be combined with a column-level PRIMARY KEY, and its columns must exist and be exact-matchable (not REAL, JSON, or VECTOR). A table that promises cross-machine delivery (RETAIN ... SYNC SAFE) satisfies its key requirement with a multi-column primary key just as it would with a single-column one. A composite UNIQUE constraint or a multi-column index is a uniqueness or lookup rule, never the sync identity — only PRIMARY KEY is.
Trigger a state change on this row when the referenced row transitions:
CREATE TABLE tasks (
id UUID PRIMARY KEY,
status TEXT NOT NULL,
project_id UUID REFERENCES projects(id)
ON STATE archived PROPAGATE SET invalidated
) STATE MACHINE (status: active -> [invalidated, superseded])When a projects row transitions to archived, any tasks row referencing it transitions to invalidated.
Table options appear after the closing ) of the column list. Multiple options can be combined. They attach to whatever table and columns you define — contextdb has no built-in tables of its own.
Rows cannot be updated or deleted after insertion. Useful for append-only data like event logs and audit trails:
CREATE TABLE documents (
id UUID PRIMARY KEY,
data JSON,
embedding VECTOR(384)
) IMMUTABLETable-level IMMUTABLE and RETAIN are refused together at parse time.
Restrict a column's value transitions to declared edges:
CREATE TABLE tasks (
id UUID PRIMARY KEY,
status TEXT NOT NULL
) STATE MACHINE (status: draft -> [active, rejected], active -> [superseded])Inserting a row sets the initial state. Updates that violate the transition graph are rejected.
Enforce directed acyclic graph constraint on specified edge types, preventing cycles:
CREATE TABLE edges (
id UUID PRIMARY KEY,
source_id UUID NOT NULL,
target_id UUID NOT NULL,
edge_type TEXT NOT NULL
) DAG('DEPENDS_ON', 'BASED_ON')Inserting an edge that would create a cycle returns CycleDetected. Duplicate (source_id, target_id, edge_type) inserts are silently deduplicated.
Automatic row expiry. Units: SECONDS, MINUTES, HOURS, DAYS. Optional SYNC SAFE means a row is not expired here until the destination confirms it received it:
CREATE TABLE scratch (
id UUID PRIMARY KEY,
data TEXT
) RETAIN 24 HOURS SYNC SAFECan also be set via ALTER TABLE:
ALTER TABLE scratch SET RETAIN 7 DAYS;
ALTER TABLE scratch DROP RETAIN;RETAIN says only WHEN rows expire and SYNC SAFE says only that expiry waits on delivery. Neither decides where rows travel — that is the separate SYNC clause below. Writing a direction inside the retention clause (RETAIN 24 HOURS SYNC SAFE PUSH ONLY) is a parse error naming the clause to use instead.
How much of each live row's version history a table keeps — a different axis from RETAIN, which says how long a row lives. HISTORY ALL (the default) keeps every superseded version. HISTORY CURRENT ONLY declares that only a row's current version has consumer value, so superseded versions may be reclaimed by the maintenance loop:
CREATE TABLE device_status (
device_id TEXT PRIMARY KEY,
state TEXT NOT NULL
) HISTORY CURRENT ONLY SYNC CONFLICT KEEP LATESTHISTORY ALL keeps each superseded version's change-log, commit-index and vector-directory
metadata in writer memory. On a mutable table that republishes rows often, such as a search
projection that re-embeds passages or a status table, each version grows writer residency until
the finite MEMORY_LIMIT refuses writes. Declare HISTORY CURRENT ONLY for these tables so the
maintenance cycle can reclaim superseded versions and their metadata. Unchanged raw vector bodies
share memory across scalar updates, but that sharing does not remove the metadata ceiling.
Also settable with ALTER TABLE:
ALTER TABLE device_status SET HISTORY CURRENT ONLY;
ALTER TABLE device_status SET HISTORY ALL;RETAIN bounds rows, HISTORY bounds versions, and neither implies the other — RETAIN plus HISTORY CURRENT ONLY together are legal and independent: rows expire on the window, and each surviving row keeps only its current version.
The refusal. HISTORY CURRENT ONLY is refused on a table that both delivers rows to another machine (SYNC PUSH ONLY or SYNC TWO WAY, the default direction) and arbitrates conflicts non-overwriting (SYNC CONFLICT KEEP FIRST, the default when no conflict clause is written):
CREATE TABLE device_status (
device_id TEXT PRIMARY KEY,
state TEXT NOT NULL
) HISTORY CURRENT ONLY
-- refused: HISTORY CURRENT ONLY needs SYNC CONFLICT KEEP LATEST. Reclaiming
-- superseded versions leaves only the newest value, and a machine that
-- pulls this table under SYNC CONFLICT KEEP FIRST would file that newest
-- value as the FIRST value for its key.Two ways out: declare SYNC CONFLICT KEEP LATEST (the table carries current truth, so last-writer-wins is the correct arbitration), or SYNC OFF (the table never leaves this machine, so no peer can ever be handed a stale first value). The same refusal fires on ALTER TABLE ... SET HISTORY CURRENT ONLY, on ALTER TABLE ... SET SYNC CONFLICT KEEP FIRST narrowing an already-current-only table back to keep-first, on ALTER TABLE ... SET SYNC ... widening a keep-first current-only table into a delivering direction, and on an arriving definition from another machine — every door that can produce the combination refuses it, not only a local CREATE TABLE.
IMMUTABLE and HISTORY CURRENT ONLY are refused together at parse time: an immutable table never supersedes a row, so it has no version history to reclaim.
What changes and when. ALTER TABLE t SET HISTORY CURRENT ONLY on a table that has already accumulated versions is a declaration change, not an immediate data operation — the accumulated versions are reclaimed on the next maintenance cycle, not synchronously within the statement. Setting HISTORY ALL stops reclaiming versions from that point on; it does not restore versions already reclaimed under HISTORY CURRENT ONLY — the clause is not a time machine. A STATE MACHINE table that also declares HISTORY CURRENT ONLY keeps its current state and its transition constraint (enforcement is evaluated at write time against the current version), but loses the ability to read back the sequence of past states.
Which index-prefix counts the engine keeps current on every write to this table. A count is keyed by an ordered column list and the values under it — not by an index — so two indexes that share leading columns name one count, and it is maintained once.
A table that declares nothing maintains no prefix count. COUNT(*) still
answers exactly: the query opens the exact prefix range on the index and counts
the live postings, charging one index-entry touch per posting, inside the
statement's own work, memory and time budget. Declaring the column list a hot
COUNT(*) uses turns that read into a single stored number, and costs one
authenticated write per matching index write.
CREATE TABLE events (
id UUID PRIMARY KEY,
context_id UUID NOT NULL,
connector_id TEXT NOT NULL,
source_instance_ref TEXT NOT NULL,
UNIQUE (context_id, id),
UNIQUE (context_id, connector_id, source_instance_ref, id)
) MAINTAIN COUNT (context_id), (context_id, connector_id, source_instance_ref)Each list is a parenthesised column list with the same optional per-column
ASC/DESC that CREATE INDEX accepts, defaulting to ASC. One statement
replaces the whole declared set:
ALTER TABLE events SET MAINTAIN COUNT (context_id);
ALTER TABLE events SET MAINTAIN COUNT ALL PREFIXES;
ALTER TABLE events SET MAINTAIN COUNT NONE;
ALTER TABLE events SET MAINTAIN COUNT DEFAULT;ALL PREFIXES maintains every prefix length of every index the table carries,
including the ones the engine derives for foreign keys and unique constraints.
NONE is the default spelled out. DEFAULT clears the declaration and returns
the table to silence; it is accepted only on ALTER TABLE, because a fresh
CREATE TABLE has nothing to clear.
Changing the declaration on a table that already holds rows is online and atomic: the counts the new declaration names are recomputed, and the ones it drops are removed, in the same transaction that installs the declaration. The allocation is admitted by the memory accountant first, so a recompute that cannot fit is the ordinary typed budget refusal with nothing installed.
Which route a COUNT(*) took. .explain says so in one of three states,
and names the statement that would change it when a statement would.
A stored count is reachable only from a store read as a committed image — the direct-file read route. A writable handle and the owner read route count the live postings over the same exact prefix range instead. Every one of the three answers is exact; only the first is O(1):
IndexScan { index: events_by_context }
predicates_pushed: [context_id]
maintained count: used (context_id)
IndexScan { index: events_by_context }
predicates_pushed: [context_id]
maintained count: declared (context_id), counted from the index range on this read route
IndexScan { index: events_by_context }
predicates_pushed: [context_id, connector_id]
maintained count: not declared — ALTER TABLE events SET MAINTAIN COUNT (context_id), (context_id, connector_id) makes this O(1)
The middle line is not a declaration problem and no DDL changes it: the table
maintains that count and every write keeps it current, but this read route
counted postings. Only the third line names a statement, and that statement is
runnable as printed. Because SET MAINTAIN COUNT
replaces the whole declared set, the advice carries the lists the table already
declares alongside the missing one — following it never drops a count you
already asked for.
The refusals. A list no index leads with is refused, naming the list and
the indexes considered — a count nothing can read is not a thing to maintain.
A list a primary key or unique constraint already bounds at 0 or 1 is refused
too: an exact-key range over the index reads at most one posting and gives the
same answer, so the count would store a number nobody can disagree with. That
rule is not policy — ALL PREFIXES does not maintain such a count either. A
duplicate list, a repeated column and an unknown column are refused by name.
Where a table's rows travel under synchronization. It applies to retained and non-retained tables alike, persists with the table definition, renders in .schema, and travels with the definition to other machines:
CREATE TABLE windows (
id UUID PRIMARY KEY,
body TEXT
) RETAIN 48 HOURS SYNC SAFE SYNC TWO WAY| Clause | Meaning |
|---|---|
SYNC OFF |
rows stay on the machine that wrote them |
SYNC PUSH ONLY |
rows go out and never come back |
SYNC PULL ONLY |
rows arrive from other machines; nothing local goes out |
SYNC TWO WAY |
rows travel both ways |
A table that declares no direction is SYNC TWO WAY — the default, and the recovery contract: delete a machine's database, recreate it against the same tenant, and every still-live row comes back.
Changeable on an existing table — but windows is SYNC SAFE, and SYNC SAFE combined with SYNC PULL ONLY can never deliver the table outward for a destination to confirm, so this is refused (see below):
ALTER TABLE windows SET SYNC PULL ONLY;On a table without SYNC SAFE, the same direction change is accepted:
ALTER TABLE items SET SYNC PULL ONLY;SYNC SAFE with a direction that never delivers the table (SYNC OFF or SYNC PULL ONLY) is refused when it is written — at CREATE, at ALTER, and when the definition arrives from another machine. The promise could never be kept, so the rows would simply never expire. Plain RETAIN with no delivery promise may declare any direction, including SYNC OFF for a colocated installation that keeps one copy.
A table that syncs needs a sync identity — the covering-index requirement. Any direction but SYNC OFF tells apart rows arriving from another machine by that table's declared identity: a PRIMARY KEY (single-column or a table-level PRIMARY KEY (a, b, ...)), or, failing that, an indexed id column as a fallback. A table with neither — a keyless table — has no way to do that. If a keyless table declares SYNC OFF this never matters, since its rows were never eligible to leave the machine either way. But a keyless table that would otherwise sync makes a push refuse loudly (Error::SyncError, naming all three fixes) instead of silently reporting success while that table's rows never actually cross the wire.
The keyless fallback — a column named id with no declared key — requires a covering index on id. Without that index, sync apply refuses with exact sync key probe on keyless(id) has no covering index (the table name fills the first slot).
-- Keyless and would sync (the default SYNC TWO WAY) — push refuses:
CREATE TABLE events (payload TEXT NOT NULL, occurred_at INTEGER NOT NULL)
-- Fix one of:
CREATE TABLE events (id UUID PRIMARY KEY, payload TEXT NOT NULL, occurred_at INTEGER NOT NULL)
-- or
CREATE TABLE events (payload TEXT NOT NULL, occurred_at INTEGER NOT NULL, id UUID)
CREATE INDEX events_id_idx ON events (id)
-- or
CREATE TABLE events (payload TEXT NOT NULL, occurred_at INTEGER NOT NULL) SYNC OFFWhich value survives when the same row is written on more than one machine, or the same key is re-sent. Like the direction clause, it is declared on the table itself, persists with the definition, renders in .schema, and travels with the synced definition to other machines, so the durable hub honors the table's declared policy:
CREATE TABLE observations (
id UUID PRIMARY KEY,
body TEXT
) SYNC CONFLICT KEEP FIRST| Clause | Meaning |
|---|---|
SYNC CONFLICT KEEP FIRST |
write-once — the first value written for a key stays; a re-send of that key does not overwrite it. Later writes to that key lose — including deletes, which are arbitrated exactly like any other contending write. A pulling edge reports the delete as skipped and the row survives. |
SYNC CONFLICT KEEP LATEST |
last-writer-wins — a re-send of a key replaces the value already there. The last hub-accepted write wins; deletes propagate. |
A table that declares no policy is SYNC CONFLICT KEEP FIRST — the non-overwriting default, so a re-send of an existing key never silently rewrites it.
On a hub the policy is resolved in one order: a system table's baked policy wins first, then the table's own declaration, then the default. So an application table always gets exactly the policy it declared, while the engine's own distributed tables keep the policy their contract requires.
The policy composes with the retention, history, and direction clauses on one table:
CREATE TABLE windows (
id UUID PRIMARY KEY,
body TEXT
) RETAIN 48 HOURS SYNC SAFE HISTORY ALL SYNC TWO WAY SYNC CONFLICT KEEP FIRSTCascade state changes along graph edges when a row transitions:
CREATE TABLE tasks (
id UUID PRIMARY KEY,
status TEXT NOT NULL
) STATE MACHINE (status: active -> [invalidated, superseded])
PROPAGATE ON EDGE CITES INCOMING STATE invalidated SET invalidatedWhen a tasks row transitions to invalidated, rows connected via incoming CITES edges also transition to invalidated. Options: INCOMING, OUTGOING, BOTH for edge direction. MAX DEPTH n limits traversal. ABORT ON FAILURE rolls back if any propagation fails.
Remove a row's vector from similarity search results when it enters a given state, without deleting the row:
CREATE TABLE tasks (...)
PROPAGATE ON STATE invalidated EXCLUDE VECTOR
PROPAGATE ON STATE superseded EXCLUDE VECTOROptions compose — a real-world table might use several:
CREATE TABLE tasks (
id UUID PRIMARY KEY,
description TEXT NOT NULL,
status TEXT NOT NULL,
confidence REAL,
created_at TIMESTAMP DEFAULT NOW(),
project_id UUID REFERENCES projects(id)
ON STATE archived PROPAGATE SET invalidated,
embedding VECTOR(384)
) STATE MACHINE (status: active -> [invalidated, superseded])
PROPAGATE ON EDGE CITES INCOMING STATE invalidated SET invalidated
PROPAGATE ON STATE invalidated EXCLUDE VECTOR
PROPAGATE ON STATE superseded EXCLUDE VECTOR=, !=, <>, <, <=, >, >=
AND, OR, NOT
+, -, *, /
WHERE name LIKE 'sensor%' -- % matches any substring
WHERE name LIKE 'item_3' -- _ matches single character
WHERE name NOT LIKE '%draft%'WHERE confidence BETWEEN 0.5 AND 1.0
WHERE confidence NOT BETWEEN 0 AND 0.1WHERE status IN ('active', 'draft')
WHERE id IN (SELECT id FROM other_table WHERE ...)
WHERE status NOT IN ('deleted', 'archived')Subqueries in IN must select exactly one column.
WHERE superseded_at IS NULL
WHERE embedding IS NOT NULL| Function | Returns | Description |
|---|---|---|
COUNT(*) |
INTEGER | Count all rows |
COUNT(col) |
INTEGER | Count non-NULL values in column |
SUM(col) |
INTEGER or FLOAT | Add the non-NULL values in a numeric column |
COALESCE(a, b, ...) |
varies | First non-NULL argument |
NOW() |
TIMESTAMP | Current Unix timestamp |
COUNT and SUM operate over the entire result set. No GROUP BY or HAVING — use CTEs or application-level grouping for aggregation.
NOW() in an INSERT ... VALUES list is refused for a NOT NULL column. Writing it into a
nullable column is accepted, and UPDATE ... SET ts = NOW() is accepted for either; but
CREATE TABLE audits (id UUID PRIMARY KEY, ts TIMESTAMP NOT NULL);
INSERT INTO audits (id, ts) VALUES ($id, NOW());
-- Error: plan error: unsupported expression in schema enforcerThe NOT NULL check runs in the schema enforcer, which evaluates literals rather than call
expressions. Declare the column DEFAULT NOW() and omit it from the INSERT instead — the
default is applied for you and the row lands with a real timestamp:
CREATE TABLE audits (id UUID PRIMARY KEY, ts TIMESTAMP NOT NULL DEFAULT NOW());
INSERT INTO audits (id) VALUES ($id);SUM skips NULLs, returns NULL (not zero) when no row reaches it, adds an
INTEGER column as an integer and refuses an out-of-range total rather than
wrapping it, and adds a FLOAT column as a float. A non-numeric column is an
error. The output column is named after the function unless you alias it:
SELECT SUM(amount) FROM payments WHERE status = 'settled';
SELECT SUM(amount) AS settled_total FROM payments;An aggregate read through a cursor finishes across pages: each fetch adds up as much input as that fetch's work budget allows, and a fetch that runs out returns an empty page with more still to come. The total arrives on the page that reaches the end of the input.
In the Rust API, parameters are passed as HashMap<String, Value>:
let mut params = HashMap::new();
params.insert("item_id".into(), Value::Uuid(id));
params.insert("type".into(), Value::Text("sensor".into()));
let result = db.execute(
"SELECT * FROM items WHERE id = $item_id AND type = $type",
¶ms,
)?;The CLI does not support parameter binding — use literal values directly.
Graph queries use GRAPH_TABLE in the FROM clause with openCypher-subset MATCH patterns. The graph executor uses dedicated adjacency indexes and bounded BFS — graph traversal is a native operator, not recursive SQL.
SELECT columns FROM GRAPH_TABLE(
edge_table
MATCH pattern
[WHERE condition]
COLUMNS (expr AS alias, ...)
)The edge_table is a table with source_id, target_id, and edge_type columns.
-- Outgoing edges
MATCH (a)-[:DEPENDS_ON]->(b)
-- Incoming edges
MATCH (a)<-[:BASED_ON]-(b)
-- Bidirectional
MATCH (a)-[:RELATES_TO]-(b)
-- Any edge type
MATCH (a)-[]->(b)-- Between 1 and 3 hops
MATCH (a)-[:DEPENDS_ON]->{1,3}(b)
-- 1 to 10 hops (explicit upper bound required)
MATCH (a)-[:EDGE]->{1,10}(b)An explicit upper bound is always required. Maximum traversal depth enforced by the engine is 10.
Use WHERE to filter after traversal, COLUMNS to project results:
SELECT target_id FROM GRAPH_TABLE(
edges
MATCH (a)-[:DEPENDS_ON]->{1,3}(b)
WHERE a.id = '550e8400-e29b-41d4-a716-446655440000'
COLUMNS (b.id AS target_id)
)Graph results become a relational CTE for joins, filters, or vector search:
WITH deps AS (
SELECT b_id FROM GRAPH_TABLE(
edges
MATCH (a)-[:DEPENDS_ON]->{1,3}(b)
WHERE a.id = $start
COLUMNS (b.id AS b_id)
)
)
SELECT t.id, t.status FROM tasks t
INNER JOIN deps ON t.id = deps.b_id
WHERE t.status = 'active'Find semantically similar items within a graph neighborhood:
WITH neighborhood AS (
SELECT b_id FROM GRAPH_TABLE(
edges
MATCH (a)-[:RELATES_TO]->{1,2}(b)
COLUMNS (b.id AS b_id)
)
),
candidates AS (
SELECT id, name, embedding
FROM items i
INNER JOIN neighborhood n ON i.id = n.b_id
WHERE i.is_deprecated = FALSE
)
SELECT id, name FROM candidates
ORDER BY embedding <=> $query
LIMIT 5Cosine distance between two vectors. Used in ORDER BY for nearest-neighbor search:
-- Rust API with parameter binding
SELECT id, data FROM documents
ORDER BY embedding <=> $query_vector
LIMIT 10
-- CLI with vector literal (against a table with a short vector dimension,
-- so the literal can be written out in full)
CREATE TABLE evidence (id UUID PRIMARY KEY, vector_text VECTOR(4));
SELECT id FROM evidence
ORDER BY vector_text <=> [0.1, 0.2, 0.3, 0.4]
LIMIT 10Lower distance = more similar. A LIMIT clause is required — unbounded vector searches are rejected.
The query vector can also come from an existing row:
SELECT id, data FROM documents
WHERE id != $query_id
ORDER BY embedding <=> ROW_VECTOR('documents', 'embedding', $query_id)
LIMIT 10ROW_VECTOR(table, column, key) is only valid as the right side of <=> in
ORDER BY. The table and column arguments are string literals naming a
VECTOR(n) column, and key is a literal or parameter matched against the
source table's natural key, usually its primary key. The source vector is read
from the same MVCC snapshot as candidate filtering and vector scoring. Scoped
handles honor source-row visibility: a hidden source row returns the same typed
read-scope error as an explicit anchor read. Missing source tables return
TableNotFound, non-vector source columns return UnknownVectorIndex,
dimension mismatches return VectorIndexDimensionMismatch, missing source rows
return PersistedRowVectorRowMissing, and rows with NULL vector cells return
PersistedRowVectorCellNull.
Combine WHERE filters with vector ranking. The engine filters first, then scores only matching rows:
SELECT id, description FROM tasks
WHERE status = 'active'
ORDER BY embedding <=> $query
LIMIT 5Every vector column has a maintained local search layout. An unpartitioned column has one logical layout; a partitioned column has one for each declared key tuple. A partition is a local search layout, not an authorization boundary, tenant, or sync direction. Authorization still applies to the rows a caller may read, and a table may use any declared sync direction.
Declare a partitioned column in this canonical order (the same order .schema prints):
CREATE TABLE vector_items (
item_id UUID PRIMARY KEY,
scope_id UUID NOT NULL,
kind TEXT NOT NULL,
embedding VECTOR(768)
PARTITION_KEY (scope_id, kind)
MAX_PARTITIONS 256
SEARCH_MODE AUTO
AUTO_INDEX_AT 10000
HNSW (M = 24, EF_CONSTRUCTION = 400, EF_SEARCH = 128)
CONSOLIDATION (CHANGE_PERCENT = 25, TOMBSTONE_PERCENT = 5)
);WITH (quantization = 'SQ8'), when used, follows VECTOR(768) and precedes these clauses.
PARTITION_KEY is optional. Its columns must be non-null columns on the same table with an
exact identity type (UUID, TEXT, INTEGER, BOOLEAN, TIMESTAMP, or TXID); a key cannot
repeat a column or include the vector column. MAX_PARTITIONS is legal only with
PARTITION_KEY; omitting it means the effective limit is 256, and .schema prints that value.
A key value written to a row, locally or through sync, must be of its column's declared type or
the write is refused naming that column; the declaration itself is not at fault.
The limit counts layouts containing a live indexed vector or a version still needed by a held
snapshot. A NULL vector creates no layout. A write that would exceed the limit is refused as one
atomic write; it does not leave a row accepted without its vector.
AUTO_INDEX_AT, HNSW, and CONSOLIDATION are optional per-column policy clauses and follow
SEARCH_MODE in
the canonical rendered order. AUTO_INDEX_AT is the first aggregate allowed-vector count for the
whole requested answer at which AUTO requires the maintained indexed route; below it, AUTO
compares exactly only when the existing resource limits permit. It is not a per-partition
threshold. HNSW names the maintained graph's M (neighbour links per point),
EF_CONSTRUCTION (candidate work while building), and EF_SEARCH (candidate work while
searching). EF_CONSTRUCTION must be at least M.
CONSOLIDATION controls when maintenance rebuilds an otherwise healthy graph. CHANGE_PERCENT
counts pending inserts plus tombstones against the live-plus-retained partition population;
TOMBSTONE_PERCENT counts tombstones alone. Because tombstones are part of total change, the
tombstone trigger can decide first only when its percentage is lower than the change percentage.
Each value is from 1 through 100. If the clause or one member is silent, the effective defaults are
20% total change and 10% tombstones, and .schema keeps that default silent. At each maintenance
wake, the change percentage is the policy bound that decides when a healthy partition's retained
post-base journal is published into a replacement generation. Writes can take the journal past that
percentage between wakes, and the percentage is not a byte ceiling. ALTER ... SET CONSOLIDATION (...) changes the named members while retaining omitted members; ALTER ... SET CONSOLIDATION DEFAULT clears both declarations. The policy persists in schema, travels with it, and is honored by
in-memory and file-backed maintenance.
CONSOLIDATION NONE (including ALTER ... SET CONSOLIDATION NONE) disables automatic threshold
consolidation without disabling the maintained index, initial construction, topology replacement,
or repair. Because healthy-tail threshold rebuilds no longer advance the covered frontier, the
post-base change journal remains retained until a required replacement or repair publishes, or
threshold consolidation is re-enabled and becomes due; choose NONE with that disk-retention
consequence in mind. Both vector inspection surfaces show
the declared mode and members beside their effective mode and members.
declared_consolidation_mode is NULL when the declaration is silent, thresholds when either
percentage is explicit, and none for explicit CONSOLIDATION NONE; effective mode is always
thresholds or none.
On CREATE TABLE, omitting either clause preserves the compatibility profile and .schema
renders that silence. Its current defaults are: F32 uses (M, EF_CONSTRUCTION, profile EF_SEARCH) of (16, 200, 10) through 2,500 live vectors, (16, 200, 20) from 2,501 through
5,000, (24, 400, 400) from 5,001 through 50,000, and (16, 200, 200) above 50,000;
SQ8/SQ4 use (8, 32, min(96,max(32,n))) through 5,000 and (12, 64, 128) above 5,000. The undeclared
AUTO_INDEX_AT defaults are 1,000 for F32 and 5,001 for SQ8/SQ4. An undeclared EF_SEARCH uses
at least its profile and the existing 10 * k query default; an explicit value is raised only to
reach k.
The F32 search breadth default is now narrower for small layouts. An F32 vector column that
does not declare EF_SEARCH now searches a layout of up to 2,500 live vectors with a profile
breadth of 10, and a layout of 2,501 through 5,000 with 20. The query default of 10 * k
still applies, so a top-10 query searches at least 100
candidates. M and EF_CONSTRUCTION for these bands are unchanged, and the bands above 5,000 are
unchanged. A column that declares EF_SEARCH keeps exactly its declared breadth; declare one, for
example ALTER TABLE t ALTER COLUMN embedding SET HNSW (EF_SEARCH = 200), to search more widely.
Change the policy online with the ALTER forms above. DEFAULT on AUTO_INDEX_AT clears that
declaration; SET HNSW DEFAULT clears the whole group, while DEFAULT for a named HNSW member
clears only that member and omitted members retain their declarations. Each statement changes its
named HNSW members atomically. AUTO_INDEX_AT and EF_SEARCH affect newly opened queries without
rebuilding a graph. A change to the effective M or EF_CONSTRUCTION topology schedules a bounded
replacement generation; the previous complete graph continues serving, and an older policy revision
cannot publish over a newer declaration. An explicit value that resolves to the serving topology
retains its authored identity without forcing a redundant build. Explicit AUTO_INDEX_AT, HNSW,
and CONSOLIDATION values persist, participate in schema identity, and render in this canonical
order.
An operator can raise the limit online, but cannot lower it below live-plus-retained use:
ALTER TABLE vector_items ALTER COLUMN embedding SET MAX_PARTITIONS 512;
ALTER TABLE vector_items ALTER COLUMN embedding SET SEARCH_MODE INDEXED;
ALTER TABLE vector_items ALTER COLUMN embedding SET AUTO_INDEX_AT 10000;
ALTER TABLE vector_items ALTER COLUMN embedding SET HNSW (EF_SEARCH = 128);
ALTER TABLE vector_items ALTER COLUMN embedding SET CONSOLIDATION (CHANGE_PERCENT = 11);
ALTER TABLE vector_items ALTER COLUMN embedding SET CONSOLIDATION NONE;Changing a key value moves that row's non-NULL vector between local layouts in the same commit. Changing the key declaration itself, or dropping, renaming, or changing the type/nullability of a key component while the declaration remains, is refused through dependent-DDL rules. Rename the vector column through otherwise legal DDL to move its local state with it, or make a replacement table with the desired vector partition key and copy the wanted rows; the replacement vector column creates the corresponding local vector index.
SEARCH_MODE sets the column default:
AUTOdecides by count: it compares the aggregate allowed count for the whole requested answer against the column's effectiveAUTO_INDEX_AT. Below that count, the answer is exact when the active limits can pay for it and a typed refusal when they cannot; memory never silently picks the indexed route onAUTO's behalf. At or above that count,AUTOadmits indexed planning for the whole answer, then chooses work per Context: a Context whose live vector count is at or below its resolvedEF_SEARCHis compared exactly, while a larger Context traverses its maintained graph. ContextDB merges both kinds of candidates into the same global answer. For F32 the automatic switch is at 1,000 allowed vectors; for SQ8/SQ4 it is at 5,001. These counts apply to the whole requested answer, not separately to each partition.EXACTcompares every allowed stored vector, or returns a typed budget refusal. It never quietly substitutes an indexed answer. Exact means exact over the stored column values; SQ8 and SQ4 stay quantized by design.INDEXEDrequires a maintained bounded route, including for a small non-empty layout. If that route is not ready, it returns a typed unavailable-route refusal instead of a full scan. It traverses the graph even whenAUTOwould exact-compare that same small Context.
Override the declaration for one vector-nearest-neighbour query after vector ordering and before
USE RANK or LIMIT:
SELECT passage_id
FROM event_search_passages
WHERE context_id = $context
ORDER BY vector_text <=> $query
USE VECTOR EXACT
LIMIT 10;USE VECTOR is legal only with vector ordering. The selected mode covers the whole requested
scope: if any required layout cannot satisfy INDEXED, the query refuses rather than omit that
layout or return a partial list. A missing or empty authorized tuple is a complete empty result.
Initial graph construction and repair run only through maintenance. Declarations, writes, database
open, and queries do not wait for a full build. Engine-owned maintenance advances in the background;
caller-driven hosts advance it with Database::run_maintenance_cycle or CLI .maintenance run.
While a required indexed route is unavailable, INDEXED refuses and AUTO may compare exactly only
inside its existing budget. Ordinary relational/graph reads and admitted writes remain usable.
For an exhaustive AUTO query over dormant raw partitions, a refused whole-partition load can
fall back to reading one stored vector at a time, preserving the exact answer without installing
a resident partition. For the same unrestricted search, a writable handle explicitly requesting
EXACT retains whole-partition admission and can be refused at that load under the same ceiling.
Equality on every key component selects one layout. A finite IN or equivalent OR selects a
finite set; a prefix of a composite key, or no key predicate, searches every authorized matching
layout. A named tuple that does not exist returns no rows and never broadens to another tuple.
ContextDB searches selected layouts independently, then merges candidates by score before USE RANK
and LIMIT. The result is one global top-k, with the existing ascending row-id tie-break for equal
scores — never one limit per layout and never iteration-order results.
WHERE filters still decide which rows are allowed inside that scope. A small allowed set may be
compared exactly under AUTO; a large one needs a bounded filtered-index route. If ContextDB has
no bounded way to find or evaluate the allowed rows, INDEXED refuses before a table-wide predicate
scan. AUTO uses exact work only when the active budget can pay for it; EXACT remains exhaustive
within its declared budget. .explain names a supporting ordinary CREATE INDEX when one is a safe
recovery, otherwise it says to use EXACT or rewrite the filter. It never hides a full table scan
behind indexed search.
.explain reports the requested and resolved mode, declared partition key, whether the query
selects one, several, or all authorized layouts, the route used by each Context, the global
candidate merge, and any safe fallback or refusal reason. It redacts unauthorized keys and counts.
A constrained handle's explanation omits per-partition HNSW numbers, whose adaptive values could reveal
the hidden population. For an unrestricted caller, disclosed M, EF_CONSTRUCTION, and policy
revision describe the snapshot's serving graph; EF_SEARCH describes the current query policy at
its requested k. A query-only policy change therefore applies immediately even while the serving
build revision is older. Its authorized count and memory refusals use only authorized identities
and the operation's available headroom. Explaining is
metadata-only: it does not build an index, replay a journal, start repair, or fault every graph or
vector page into memory.
Both styles are stripped before parsing:
-- Line comment
SELECT * FROM items; /* Block comment */These are explicitly rejected with descriptive error messages:
| Feature | Error |
|---|---|
WITH RECURSIVE |
RecursiveCteNotSupported |
Window functions (OVER) |
WindowFunctionNotSupported |
CREATE PROCEDURE / CREATE FUNCTION |
StoredProcNotSupported |
Full-text search (WHERE col MATCH pattern) |
FullTextSearchNotSupported |
GROUP BY / HAVING |
Not supported |
UNION / INTERSECT / EXCEPT |
Not supported |
INSERT ... SELECT |
Not supported |
Subqueries outside IN |
SubqueryNotSupported |
| AVG, MIN, MAX | Not supported (COUNT and SUM only) |
Indexes accelerate filtered scans. Declared indexes maintain a sorted B-tree from the index key to the underlying row ids, with MVCC postings so every read sees the set of rows live at its snapshot.
CREATE INDEX idx_bucket ON documents (bucket);
-- Per-column direction
CREATE INDEX idx_recent ON tasks (created_at DESC, id DESC);
-- Composite index (leading-prefix equality pushdown)
CREATE INDEX idx_items ON items (context_id, item_type, created_at DESC, id DESC);Indexable column types: INTEGER, TEXT, UUID, TIMESTAMP, TXID,
BOOLEAN, REAL. JSON and VECTOR columns are rejected at DDL time
with ColumnNotIndexable; extract JSON fields into typed columns or use
HNSW for vectors.
Index names are scoped to the table. A duplicate name on the same table
returns DuplicateIndex; the same name on two different tables is allowed.
DROP INDEX idx_bucket ON documents;
DROP INDEX IF EXISTS idx_bucket ON documents;DROP INDEX without IF EXISTS on a nonexistent index returns
IndexNotFound. DROP INDEX IF EXISTS is idempotent (returns
rows_affected == 0).
ALTER TABLE t DROP a; -- COLUMN is optional
ALTER TABLE t DROP COLUMN a; -- defaults to RESTRICT
ALTER TABLE t DROP COLUMN a RESTRICT; -- explicit
ALTER TABLE t DROP COLUMN a CASCADE; -- drops dependent indexesUnder RESTRICT (the default), dropping a column referenced by any index
returns ColumnInIndex { table, column, index } naming the first dependent
index in declaration order. A vector partition-key component instead returns
VectorPartitionKeyInUse { table, column, index } with the required next step:
create a replacement table with the desired partition key and copy the wanted
rows. Under CASCADE, every index whose column list
mentions the target column is removed, and the returned QueryResult.cascade
carries a dropped_indexes list.
Indexes sort by declared direction per column. NULL sorts LAST under ASC
and FIRST under DESC, matching the engine's ORDER BY convention. Float64
values use f64::total_cmp — NaN sorts greater than any finite value,
matching the ordering test suite.
PRIMARY KEY and UNIQUE columns automatically acquire a backing index
named __pk_<col> for PRIMARY KEY, __unique_<col> for a single-column
UNIQUE, and __unique_<col1>_<col2>... for a composite
UNIQUE (col1, col2, ...) constraint. These indexes exist so PK / UNIQUE
constraint probes run in O(log n) and so SELECT ... WHERE pk_col = $v
queries pick an IndexScan without requiring a user CREATE INDEX.
Composite foreign keys also create child-side __fk_... auto-indexes so
parent deletes and tuple validation do not rely on table scans. A table whose
columns include source_id, target_id, and edge_type — the edge-row shape
graph traversal uses — additionally gets an internal
__graph_edge_source_target_type route index over that column triple, so
adjacency lookups do not scan. Because it covers the same three columns, it ties
a user-declared (source_id, target_id, edge_type) index and wins routing only
by creation order; both are fully index-driven.
Auto-indexes are elided from .schema output to keep schema printouts
focused on user-authored DDL. They remain visible in EXPLAIN <query>
output as index candidates so agents can programmatically confirm that
a query routed through the auto-index rather than a table scan.
When several user-declared or auto-indexes can route a filtered scan, contextdb
chooses the index with the longest matched leading prefix. Equality, IN, and
IS NULL on the first indexed column can push adjacent suffix equalities into
the same B-tree probe, so an index on (source_id, target_id, edge_type) is
preferred over (source_id) for WHERE source_id IN (...) AND target_id = ... AND edge_type = .... If two indexes match the same number of leading columns,
the more selective leading predicate wins (= / IN before range predicates
and !=, before IS NULL / IS NOT NULL). A remaining tie is resolved by index
creation order. This choice is intentionally based on filter selectivity, not
ORDER BY; a later sort can still be elided when the chosen index order proves
compatible with the requested ordering.
User-declared index names must not begin with __pk_, __unique_, or __fk_.
CREATE INDEX __pk_id ON t (id) returns
ReservedIndexName { table, name, prefix }.
.explain <sql> and QueryResult.trace expose the chosen route and the
rejected index candidates. A routed composite prefix renders every pushed
column in index order:
IndexScan { index: idx_edges_route }
predicates_pushed: [source_id, target_id, edge_type]
indexes_considered: [idx_edges_source: fewer predicate columns matched than chosen index]
Rejected candidates use stable reason strings so commercial products and agents can distinguish a missed index from a deliberate planner decision:
| Reason | Meaning |
|---|---|
first column not in WHERE |
The index's leading column is absent from the filter. |
function call in predicate |
The leading column is wrapped in a function such as UPPER(col). |
arithmetic in predicate |
The leading column appears inside arithmetic such as col + 1. |
non-literal rhs |
The leading predicate compares to another column or subquery rather than a literal or bound parameter. |
LIKE is residual-only |
LIKE remains a residual filter and does not drive an index scan. |
fewer predicate columns matched than chosen index |
Another index matched a longer leading prefix. |
lower selectivity than chosen index |
Another index matched the same prefix length with a more selective leading predicate. |
tied with chosen index; lost by creation order |
The candidates were otherwise equivalent and the earlier index won. |
no pinned vertex |
A graph adjacency candidate was rejected because the single-hop graph query had no pinned start or target vertex. |
Single-hop GRAPH_TABLE ... MATCH queries with a pinned start or target vertex
route through the per-vertex graph adjacency index automatically. No
CREATE INDEX statement or schema change is required. In the CLI, run
.trace on before the query to see the physical routing:
trace: AdjacencyProbe index=forward_adj pushed=[a.id] rows_examined=5
forward_adj and reverse_adj are public trace identifiers for the chosen
direction. For a single-hop query pinned directly by a.id or b.id,
rows_examined is the pinned vertex's visible degree. When the pin is resolved
from node metadata, the count also includes the start/target resolution work
before the degree walk. An unpinned single-hop query reports EdgesScan and
records the rejected adjacency candidate with reason no pinned vertex in the
programmatic trace. Multi-hop or variable-length traversals report GraphBfs.
| Error | When it fires |
|---|---|
IndexNotFound { table, index } |
DROP INDEX without IF EXISTS on a missing index |
DuplicateIndex { table, index } |
CREATE INDEX with a name already in use on the same table |
ColumnNotIndexable { table, column, column_type } |
CREATE INDEX on a JSON or VECTOR column |
ColumnInIndex { table, column, index } |
ALTER TABLE ... DROP COLUMN c RESTRICT on a column referenced by an index |
VectorPartitionKeyInUse { table, column, index } |
Dropping, renaming, or changing a vector partition-key component; create a replacement table/index and copy the wanted rows |
ColumnNotFound { table, column } |
CREATE INDEX naming a column that does not exist on the table — also the class every WHERE / JOIN ... ON / ORDER BY / SELECT-list unknown-column or unrecognized-qualifier reference uses (see Column Qualifiers and the SELECT section above) |
ReservedIndexName { table, name, prefix } |
CREATE INDEX using a name that begins with __pk_, __unique_, or __fk_ (reserved for auto-indexes) |
UniqueViolation { table, column } |
A duplicate value on a UNIQUE column, or a duplicate tuple on a table-level UNIQUE (col, ...) (column names every column of the constraint); also a PRIMARY KEY duplicate |
ColumnTypeMismatch { table, column, expected, actual } |
A value that is not an embedding, the text spelling of one, or a permitted NULL was written into a VECTOR(n) column (see Column Types) |
The authoritative hub declares application policy before an edge installs the tables:
DECLARE TENANT TABLE POLICY events
SYNC PUSH ONLY SYNC CONFLICT KEEP FIRST IMMUTABLE
DELIVERY MANIFEST OVER event_anchors
EDGE DISCARD AFTER OUTCOME;
DECLARE TENANT TABLE POLICY event_anchors
SYNC PUSH ONLY SYNC CONFLICT KEEP FIRST IMMUTABLE;
SHOW TENANT TABLE POLICY FOR events;Declarations contain existing sync, conflict, retention, and history options, plus the manifest
member-table list and edge-discard mode. They create no application table. Before the first binding,
replacement advances the declaration version and canonical digest. After binding, TenantPolicyBound
refuses replacement. A sync edge receives DeclareRequiresAuthoritativeHub naming its registered hub.
A standalone declaration is attached to the tenant when that store starts serving its hub.
The edge calls SyncClient::bind_application_table_policy with an
ApplicationTablePolicyExpectation. Matching policy returns an authenticated, persisted binding;
a missing table or differing clause is refused without installing authority. Bound names must match
at local CREATE TABLE / ALTER TABLE and at arriving DDL. An explicitly declared local policy is
preserved against a differing arriving declaration; unrelated tables continue syncing.
Create the root and member tables with the matching clauses. Each member must declare a foreign key
to the root. In the same transaction that inserts those rows, call
Database::register_delivery_manifest(tx, DeliveryManifest { root_table, root_key, members }), then
commit. The member list may be empty. A member outside the transaction, a wrong root reference, or
incomplete membership refuses the registration and aborts the transaction. The engine hashes every
committed application column, including bookkeeping columns. Keep machine-specific bookkeeping in
another table if equivalent imports must have identical content.
On a mutable manifested table, an UPDATE or DELETE retires the current manifest ownership in
the same commit. A surviving rewritten root without a new registration receives a per-unit
manifest_required outcome while other units continue; a deleted root is no longer eligible.
Registering the rewritten root again in its writing transaction creates a new pending unit.
Before registering a manifest, construct SyncClient or SyncServer on the same
Database handle in this process to load its authenticated identity. It need not be
connected. Repeat this preparation after reopening in another process. Without it,
registration returns SchemaInvalid: delivery registration requires an authenticated sync identity.
Construct the sync component first, then retry registration.
A unit's rows and terminal outcome share the hub commit. accepted means the entire unit was stored;
equivalent means its whole-unit digest matches the incumbent; refused is terminal and carries its
cause and the complete keep-first diagnostic. Fetch the registered edge's outcomes with
SyncClient::fetch_delivery_outcomes(since) to recover a lost acknowledgement. The continuation is
opaque. Database::delivery_outcome reads one root's answer; delivery_status derives eligible,
accepted, equivalent, refused, and pending counts. A watermark alone never grants delivery credit.
SYNC OFF status is disabled with zero eligible units. An observed change of hub incarnation or
registered hub returns old credit to pending and preserves local rows.
SHOW SYNC BINDINGS;
SHOW DELIVERY OUTCOMES FOR events WHERE outcome = 'refused' LIMIT 20 OFFSET 0;
DISCARD FROM events WHERE session_ref = $session,
event_anchors WHERE session_ref = $session;The three custody SHOW statements require an unconstrained admin handle. They read metadata only,
including keys, identities, digests, positions, and conflict causes; they do not include row bodies or
advance sync state.
DISCARD erases only this node's selected copies. It is legal inside BEGIN and rolls back with the
transaction, including selected rows inserted or updated earlier in that transaction. Push-only
and SYNC OFF tables are eligible; a hub must use PURGE. Only bound tables have a hub-declared discard mode;
an eligible unbound table proceeds. The persisted binding governs the answer offline:
NEVER refuses before selection, AFTER OUTCOME refuses if a selected owning unit is pending, and
ALWAYS proceeds and reports pending units under the root table. Silence means AFTER OUTCOME.
Each listed table reports table, rows_affected, survivors, and pending_units. A refusal or failed
commit leaves every table unchanged. Discard removes local custody records and leaves no tombstone and creates no fleet
frontier. Re-import starts a new local unit; a hub retaining identical content can answer equivalent.
PURGE FROM table WHERE ..., other_table WHERE ... prepares every selection before one durable
boundary and returns each table's affected count and survivor report, including zero matches.
The single-table form preserves its existing result: the affected count and, when nonempty,
blob_hash / remaining_job_id survivor rows. It
remains standalone and authoritative: it is refused in a transaction and on an enrolled edge.
For each listed SYNC OFF table, the ordinary authoritative purge unit carries that table's
self-contained WHERE predicate and bound parameter values. Each edge selects its own matching
rows, including independently generated keys, within the same atomic erasure as the other listed
tables. This instruction is delivered even when the hub matches no local row. A subquery in any
listed node-local predicate is refused with SubqueryNotSupported before any table is selected;
subqueries on the other listed tables retain the existing single-table behavior. No foreign key
or column correspondence is inferred.
The purge journal may retain these predicates and selection values as erasure instructions, just as it retains purged keys. This explicit privacy exception does not permit retaining erased row bodies. Repeating an applied instruction is idempotent and preserves fresh rows created after its first application. An all-empty purge containing only key-based selections writes nothing. Application-table survivor lists are empty: ordinary columns have no engine blob-reference value type; the work-ledger blob reference surface owns nonempty blob-survivor reports.
A purge tombstone bans the purged lineage, never the key. A row written at a purged key after the
purge is new data: a later PURGE that selects it erases it like any other row, on the hub, on a
node-local table, and on every edge that applies the hub's purges, including an edge that learns
several purges of one key in a single pull. Every purged life of the key stays permanently refused,
with its own frontier, after reopen and after a later life of the key is deleted, discarded, or
purged.
Declarations and bindings report TenantPolicyNotDeclared, TenantPolicyMismatch,
TenantPolicyBound, TableBindingMismatch, or DeclaredPolicyPreserved as appropriate.
Manifest registration reports ManifestRequired, ManifestIncomplete, or
ManifestMemberOutsideTransaction. Discard eligibility and mode failures are DiscardNotEligible,
DiscardNotOnHub, and EdgeDiscardDenied; they name tables, modes, and counts, never row content.