Vex · DESIGN.md

This is DESIGN.md as @niscorp/vex 0.4.2 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/vex/DESIGN.md

Vex — Design Document

Purpose

Intent-based data access. A caller describes what data they need and the shape they want it in. Vex synthesizes a query, executes it, and reshapes the result to match. Because it caches each generation under a fingerprint, replaying that fingerprint costs zero LLM calls.

One sentence: ask for data in English, get it in any shape, from any database.

The thesis: most applications have a small, stable set of data-access patterns. Each pattern has a consistent output shape. Once the DSL (and any result mapping) is generated for a shape, it is reused for every later request with that shape — the LLM cost amortizes to zero, and the hot path is pure compile + execute.


Core principles

  1. The DSL is the source of truth. The LLM never emits SQL. It fills a constrained JSON DSL that is Zod-validated before anything else touches it. Scope, discovery, analysis, and compilation all operate on the DSL or on a value derived from it.
  2. Deterministic core, injected intelligence. The engine itself contains no LLM code. Natural-language→DSL and rows→shape are two optional function hooks (generateDsl, mapToShape). With them, Vex answers free-form requests. Without them, it is a deterministic DSL compiler + executor + cache that still serves any already-cached shape. Cortex (and Signal behind it) is an optional peer dependency, not a hard one; Prism is required, because a stored mapping is Prism IR and the engine executes it.
  3. Shape-only caching. The positive cache key is a hash of the request's shape with all values replaced by type tokens. Two requests with the same structure but different values share a cache entry. Intent is deliberately excluded from this key.
  4. Scope is server-side and LLM-invisible. Access control is injected into the DSL after generation, as ordinary filter clauses bound to server $scope values. The model cannot see or forge them, so prompt injection cannot bypass them.
  5. One-level field paths. Always entity.field, never entity.relation.field. Every entity appears explicitly in from. This keeps the resolver simple and entity discovery a pure string walk.
  6. Postgres first, adapter interface from day one. SQL generation is the only database-specific step. It lives entirely behind the DatabaseAdapter interface so other backends can follow without changing the core.
  7. Schemas teach the model. Every DSL schema field carries a Zod .describe(). The JSON Schema generated from it — not prose — is what the query agent reads to learn the DSL.

Architecture

                       ┌──────────────────────────────────────┐
   QueryRequest        │  { fingerprint?, intent?, shape?,     │
                       │    context }                          │
   + ExecuteOptions    │  options: { scope?, entities?, locked?}│
                       └────────────────┬─────────────────────┘
                                        ↓
                       ┌──────────────────────────────────────┐
                       │  Validate (Zod) + hash                │
                       │  fingerprint (positive key)           │
                       │  requestHash (negative key / single-  │
                       │  flight)                              │
                       └────────────────┬─────────────────────┘
                                        ↓
                       ┌──────────────────────────────────────┐
                       │  Cache read                           │
                       │  positive HIT → cached DSL (+ Prism IR)│
                       │  negative HIT → throw unsatisfiable   │
                       │  fingerprint alone, unknown → throw   │
                       │  cache_miss                           │
                       └────────────────┬─────────────────────┘
                            miss ↓                 ↑ hit (skip generation)
                       ┌──────────────────────────────────────┐
                       │  generateDsl hook (LLM, optional)     │
                       │  single-flighted; negative-caches a   │
                       │  "cannot satisfy" result              │
                       └────────────────┬─────────────────────┘
                                        ↓
   ── deterministic pipeline (runPipeline) ───────────────────────────
                       ┌──────────────────────────────────────┐
                       │  clamp limit → discover entities →    │
                       │  apply scope → resolve → analyze →    │
                       │  adapter.compile → CompiledQuery      │
                       └────────────────┬─────────────────────┘
                                        ↓
                       ┌──────────────────────────────────────┐
                       │  Missing-context check                │
                       │  → return empty result + meta.        │
                       │    missingContext (valid response)    │
                       └────────────────┬─────────────────────┘
                                        ↓
                       ┌──────────────────────────────────────┐
                       │  Bind params (context/scope/embed) →  │
                       │  adapter.execute → raw rows           │
                       └────────────────┬─────────────────────┘
                                        ↓
                       ┌──────────────────────────────────────┐
                       │  Map to shape (optional)              │
                       │  cached Prism IR → apply; else        │
                       │  mapToShape hook (LLM) → IR + rows    │
                       └────────────────┬─────────────────────┘
                                        ↓
                       ┌──────────────────────────────────────┐
                       │  Cache write (by fingerprint)         │
                       │  DSL + Prism IR + schema fingerprint  │
                       └────────────────┬─────────────────────┘
                                        ↓
                       ┌──────────────────────────────────────┐
                       │  { result, meta: { cache, context,    │
                       │    timing, warnings? } }              │
                       └──────────────────────────────────────┘

On a cache hit, generation and mapping are skipped: the pipeline re-runs deterministically against the cached DSL and applies the cached Prism IR. No LLM calls. That re-run (resolve → analyze → compile → bind → execute) is what makes scope and context values current on every request, even cached ones.


Subsystems

The DSL

A constrained JSON structure (schemas/) that can only express safe, valid queries. Top level (QuerySchema):

  • from — array of entity names or { as, query } subqueries (min 1).
  • fields — array of entity.field paths or { field, as } (no SELECT *; optional when aggregate is present — a query needs at least one of the two).
  • filter — a recursive FilterSchema union.
  • compute — record of alias → ComputeExpression (arithmetic, concat, coalesce, case).
  • aggregate — record of alias → AggregateExpression (count/sum/avg/ min/max).
  • groupBy, sort, limit, distinct.

Filter operators: eq/neq/gt/gte/lt/lte, in/notIn, like/ ilike, isNull/isNotNull, and/or/not, plus semantic (vector similarity) and fuzzy (string distance). Values are literals, { $context } (caller-provided) or { $scope } (server-injected).

Two refinements over a naive design, both for the model's benefit: there is no offset (cursor pagination via a gt filter is safer and cacheable) and no between (it is sugar for and(gte, lte)). Fewer operators means a smaller JSON Schema and better model adherence.

Scope

Three functions, in two stages:

  • discoverEntities(dsl) walks every place an entity name can appear — from, field paths, filter paths (recursively through and/or/not), compute and aggregate references, groupBy, sort, and into subqueries — and returns the set of referenced entities. No schema needed; pure string extraction.
  • checkScope(entities, policy) looks up each discovered entity in the policy. public rules pass; deny (or no rule under a default: 'deny' policy) throws VexScopeError. It runs before resolution and changes nothing.
  • scopeResolved(resolved, policy) places each table's row rules on the resolved query, as column = $scope predicates: in WHERE for a driving or inner-joined table, in the join's ON for a left-joined one, and inside every subquery and exists. It runs after resolution, because where a rule goes depends on how its table is joined (see decision 4 under Key design decisions).

The analyzer and the compiler never know scope exists: they process a resolved query that happens to carry extra predicates.

applyScope(dsl, entities, policy) is deprecated. It runs checkScope and returns the query as it came, with no row rule added; a caller that needs rows filtered goes through the engine, or calls scopeResolved on the resolved query.

Engine (deterministic pipeline)

  • resolver (resolve) turns the scoped DSL into a ResolvedQuery: field paths → table aliases + columns, FK-based join discovery between entity sources (a nullable FK resolves to a LEFT join so a null FK never drops the referencing row; subquery sources are not FK-joined — the compiler cross-joins them, and the outer query can reference their field/compute/aggregate outputs), subquery recursion.
  • analyzer (analyze) validates the resolved query and returns { warnings, errors }. Errors halt the pipeline; warnings ride along in the response. Checks: nesting depth vs maxNestingDepth; cartesian products (multiple entity sources with no connecting join); unindexed filter columns (warn or reject); aggregates without groupBy (warn); vector field without an HNSW/IVFFlat index for semantic search (warn).
  • executor (executeQuery, buildContextContract, findMissingContext) binds parameters and calls adapter.execute. Binding resolves each ParamSlot from context (context), scope (scope), or by embedding text (semantic). findMissingContext reports which required keys the caller omitted so the runtime can return a valid-but-empty response instead of failing.

The limit, and whose number it is. Every query compiles with a LIMIT. A query that states none is given defaultLimit; one that states more than maxLimit is clamped to it — unless its author seeded it. maxLimit exists for the query nobody reviewed: a generated one must not be able to ask for the table. Applied to a number a developer wrote into a seeded entry it did something else: it replaced that number with a smaller one, and the reply did not say so. So the clamp reads provenance off the stored entry: protected with no requestHash is what seedCache writes, and what a host writes when it stores an entry through the cache backend itself. No request can produce it (a generated entry always carries the hash of the request it was born from, and the one field a wire call can change on a stored entry is protected), and an entry that has it runs with its stated limit. It records who put the entry there, not whether a person read it: a host that stores a query it did not write has vouched for it. config.capAuthored puts the clamp back for a host that wants one ceiling over everything.

What is left is the case nothing can decide for the author: a list that states no limit. Returning every row is not offered: nothing would bound the read, and an unbounded read of a large table is the failure defaultLimit is there to stop. So the default stays, and the engine stops being silent about it: when the LIMIT was its own number and exactly that many rows came back for a list, the reply's meta.warnings says the list may have been cut (and the console, once per named entry). No extra row is fetched to make it certain; the line says "may".

Adapters

DatabaseAdapter is the whole database boundary: introspect, compile (ResolvedQuery → CompiledQuery), execute, and a capabilities flag set. Compilation is adapter-internal — there is no shared compiler in engine/; each adapter owns its full SQL dialect (the Postgres adapter has its own filter/compute/aggregate SQL generation under adapters/postgres/).

An adapter may also implement limitReads(ms). The engine calls it at introspect() with config.statementTimeoutMs (0 leaves reads unbounded), and it answers where the limit is held: 'connection' (the connection's own setting already covers it), 'statement' (set per read) or 'unenforced', on which the engine warns. An adapter without it runs reads unbounded.

A CompiledQuery is { sql, paramSlots, contextContract }. The ordered paramSlots array drives positional binding ($1, $2, …); each slot knows its kind (context/scope/semantic), type, and (for semantic) dimensions. The text→vector embed function is an engine-level injected provider (createQueryEngine({ embed })), used by the executor during binding for semantic slots — the adapter only emits the vector-distance SQL, never embeds.

The Postgres adapter advertises vector search, JSON fields, full-text, CTEs, window functions, RETURNING, and statement timeout; it coerces numeric column strings (int8/numeric/float) back to JS numbers on the way out.

Cache — mechanics

Identity is the fingerprint (see Cache v2 below). The supporting hashes:

  • Request hash (computeRequestHash) hashes intent + normalized shape + context key names (not values). It identifies the request stored with a named slot (match → hit, differ → regenerate-and-replace), and keys the negative cache and single-flight de-duplication.
  • Schema fingerprint (computeSchemaFingerprint) is a stable hash of the structural schema (names, types, nullability, PKs, relations, indexes — excluding volatile rowCount and cosmetic fields). It is stored on each entry; a mismatch means the cached DSL may reference changed columns, so the entry is treated as a miss and evicted. Stable across restarts so warm-up entries aren't wrongly discarded.
  • Entry kinds. A discriminated union: ok (DSL + optional Prism IR), mutation (a dev-authored write — see Mutations) and unsatisfiable (a TTL'd negative result — a request the agent declared impossible, so it isn't re-run until the TTL lapses or the schema changes).
  • Backends. In-memory (createMemoryCache), durable Postgres (createPostgresCache, jsonb table, server-side TTL, protected/ last_used_at/request_hash/mapping columns; the table is a strata sequence, nisc.vex.cache, applied once through the ledger by init(); validate-on-write and evict-on-read), and tiered (createTieredCache, L1 + L2 read-through/write-through with full/lazy/partial warm-up). Every backend implements the same CacheBackend contract; init/entries are optional capabilities used for warm-up and the discovery overview.
  • Validation (validateEntry) guards every write and every promotion into memory, so a corrupt or schema-drifted row can never poison the cache. It returns null for a valid entry and the reason, as a string, for any other.
  • GC (sweepCache) evicts entries idle past maxIdleMs (lastUsedAt ?? createdAt), skipping protected ones. Hygiene, not necessity.

Cache v2 — fingerprints

Agreed and implemented 2026-07-10; replaced shape-keyed caching everywhere (engine, HTTP layer, and all consumers — relay, fable, mythos, showroom).

Why v1 has to go. Shape-hash identity collides: two different intents with the same normalized shape share one slot, so the second silently returns the first's data stamped hit: true. Entries are anonymous — no way to pin, promote, iterate, or roll back a generation. And running a query writes the cache as a side effect, so testing and publishing are the same physical act: callers either bypass the cache and lose the artifact, or hit it and can never change it.

The model: one identity, fingerprint.

  • A request WITHOUT a fingerprint generates and caches; the server mints a fingerprint (fp_…) and returns it in meta.cache.fingerprint.
  • A minted fingerprint is an immutable pin — embed it and replay the exact generation forever. Changing the query means proving again and embedding the new fingerprint. (git: a commit hash.)
  • A caller-chosen fingerprint (any string) is a mutable named slot — the name is yours, its content follows your request. (git: a branch.)
  • Intent NEVER keys anything: natural language is fake identity in both directions. It remains generator input + human metadata.

Three request postures — the body replaces every cache mode:

// 1. Explore: generate, cache, mint
{ "intent": "top 10 open deals by value…", "shape": [{ "id": "", "value_display": "" }] }
// → meta.cache: { "fingerprint": "fp_9k27ax", "hit": false }

// 2. Replay: fingerprint alone (+ context VALUES welcome) — never generates
{ "fingerprint": "fp_9k27ax", "context": { "sortBy": "deals.value" } }
// → meta.cache: { "fingerprint": "fp_9k27ax", "hit": true }; unknown fp → 404

// 3. Own a slot: fingerprint + request
{ "fingerprint": "deals/table", "intent": "…", "shape": [ … ] }
// stored request matches → hit; differs → regenerate + REPLACE the slot
// (meta.cache.replaced: true) — unless protected → 409

Request equality = intent string + normalized shape + context KEY NAMES (normalizeShape survives for this comparison; context values are runtime data and never identity — which is what makes replays parameterizable: sortBy/search terms may vary per call against one cached DSL).

Lifetime = usage. Every replay stamps lastUsedAt; a GC sweep evicts entries unused for N days. An embedded pin is touched on every mount and lives forever by being used; an abandoned exploration ages out. There are NO provisional/permanent tiers and NO promote API — the distinction is replaced by the convention alive = used. (Entries are single-digit KB; the sweep is hygiene, not necessity.)

Protection: one stored bit, two writers, one knob.

  • protected: true on an entry means a differing request 409s instead of replacing. Written ONLY by (i) the seed path and (ii) an explicit PATCH (which is what "save/lock this view" calls). Runtime writes are never protected by default.
  • One endpoint knob: locked: true = replay-only posture (no generation at all — the production stance; v1's cache: 'only' promoted from caller flag to endpoint config).
  • No governance fields ever ride in the query body.

The REST surface. Vex is an API endpoint over REST and is designed as one. Scoped mounts (/api/deals/vex) ride the existing options.entities filter — scope constrains generation (smaller schema, no out-of-scope joins); identity stays global.

  • POST /api/<scope>/vex — execute (the three postures above). Errors: unknown fingerprint replay → 404 cache_miss; protected slot + differing request → 409 fingerprint_protected; locked endpoint asked to generate → 403 locked.
  • GET /api/<scope>/vex — self-description: { protection: "all"|"some"|"none", locked, fingerprints: [{ fingerprint, protected, schemaFresh, intent, lastUsedAt }] } plus the schema/DSL/body docs. Doubles as agent-facing discovery: "does a named query already cover this?" is one call.
  • PATCH /…/vex with { fingerprint, protected } — flip the bit. DELETE with { fingerprint } — evict (unprotected only). Fingerprints ride the body, not the path — names contain /.

What dies with v1:

  • CacheMode (use/refresh/bypass/only), ExecuteOptions.cache, the ?cache= query param — the request body says everything now.
  • The shape hash as a cache key (and with it the entire collision class).
  • The embed-VERBATIM requirement for generated screens: a pin has no wording to drift, and context may vary per replay — sorting and parameterized reloads become legal on cached queries again.

Unchanged: the negative cache (request-hash keyed, TTL'd), schema- fingerprint freshness/eviction, backends and tiering, validateEntry.

Where it landed:

  • Engine (engine/runtime.ts): resolveFingerprint — no fp → mint; fp alone → replay or cache_miss; fp + request → hit on matching request hash, regenerate-and-replace on mismatch (409 when protected). Fingerprint-only replays use the entry's STORED shape for the array-vs-single envelope. Replays touch lastUsedAt.
  • Request schema: fingerprint in; intent/shape optional. Meta: cache: { hit, fingerprint, replaced?, intent? }.
  • Backends: keys are fingerprint strings; entries carry protected, lastUsedAt, requestHash; sweepCache GC; postgres migrates in place.
  • HTTP: handleQuery (no cache param), async handleDiscovery overview, handleFingerprintPatch/handleFingerprintDelete; express + hono adapters.
  • Consumers: seeds re-keyed to protected names (deals/list, todos/open, …); every hand-authored read replays { fingerprint, context }; relay's architect embeds { fingerprint: meta.cache.fingerprint, context }; mythos' /api/query is locked (replay-only); showroom's demos own vex-demo/<id> slots (canned = seed + replay, live = delete + regenerate).

Mutations (write pipeline)

Writes are first-class but deliberately NARROWER than reads. The asymmetry is the design: a bad generated query shows wrong data, a bad generated write destroys it — so mutations have no generation path at all. A mutation is a dev-authored artifact that enters through the seed path, lives in the cache as a kind: 'mutation' entry (the def in the same jsonb slot a query's DSL uses), and is invoked by fingerprint. The def never travels: an inline { mutation } body is not a request shape, and the wire is ONE shape for both kinds — POST { fingerprint, context }, dispatched by the entry's kind. Replay-only holds under every posture (locked only ever governed query generation); the query engine refuses a mutation fingerprint explicitly.

The pipeline: validate (closed grammar — $scope unauthorable, update/delete require a WHERE) → desugar (upsert → insert/update by key presence) → require context (a write never executes with holes; the missing_context error carries the FULL derived signature) → scope (the same ScopePolicy reads use: set writes identity on insert and update, match pins rows) → column check against the introspected schema → compile (through the read pipeline's own compileFilter/compileFieldOrValue/resolveParams, so values bind as SQL parameters exactly like reads) → execute. Batches run in one transaction; the client is structural (MutationClient — PGlite, a pg wrapper, a test double), so the core imports no driver.

A batch that uses what it wrote ($returned). { $returned: 'table.column' } in a later statement reads the one row an earlier statement of the same batch wrote — the create-a-parent-then-its-children case, where the parent's id is the database's to generate and no natural key exists to $lookup it by. Three decisions shape it:

  • Named by table, nothing else. A statement has no name of its own, and giving it one (as) would be a second vocabulary inside the batch. A reference names the table an earlier statement writes; if two earlier statements write that table the lint refuses the reference, because nothing could say which was meant.
  • Bound between statements, not compiled into one. The statements stay the separate, sequential statements they were: each compiles to an ordinary parameter where the reference sits, and the engine fills that parameter from the earlier statement's RETURNING row before running it. Folding the batch into one statement of CTEs was tried and dropped — every CTE in a statement sees the same snapshot, so a $lookup could no longer read a row an earlier statement of the batch had inserted, which a batch can do today.
  • Exactly one row, or the batch fails. A $lookup that finds nothing writes NULL, because the row it reads may honestly not exist. A reference reads a row the batch claims to have written; when the scope boundary or a WHERE left that statement with no row, writing NULL (or skipping the child) would commit half of what the author described. So it throws inside the transaction and everything rolls back. What can be known without running — no earlier writer, two of them, an insertEach, a do-nothing onConflict — is refused at seed by lintMutation.

Scope is untouched by it: each statement is scoped on its own exactly as before, and the value a reference carries comes from a row that statement was already allowed to write.

Derived context signatures close the discoverability loop for both kinds: a $context ref sits at a position whose column the schema types, so "what do I pass this fingerprint" is computed from the stored def — never authored, never stale. Discovery lists every entry with kind, intent, the typed context contract, and shape (reads) or effect ({op, table, columns}, writes). An authoring lint (lintMutation) refuses un-keyed update/delete defs at seed time.

Reactive reads (refresh)

Agreed and built 2026-09-26. A read can keep answering.

The declaration is on the entry, because freshness is a property of the question — "the current standings" is live, "the answer as asked" is not:

{ fingerprint: 'members/roster', refresh: 'reactive', dsl: { … } }

refresh is a mode, not a flag: 'snapshot' (the default, and what every read was before this existed — answered when asked, never again) or 'reactive'. It will grow; 'clock' — time as a source of change, for a read that depends on today — is the next state, and is not accepted until it is built, because a value the schema takes and the engine ignores would read as live and behave as a snapshot. Only a stored entry can be reactive (the mode is authored, never generated), and only one whose mapping is compiled, because a later answer must never wait on a model. It persists like reach: a column in the Postgres cache, validated on write, compared by the seed path.

The caller follows by passing { signal, onChange } to execute — the first answer is the returned promise as always; every later answer that differs goes to onChange until signal aborts. Over HTTP there is no callback, so handleQuery takes an optional in-process VexLive, and the hono adapter reads one from the request's env — the third argument of hono's app.request, which only an in-process caller can set. A read nobody follows is simply a read answered from the rows cache.

The rows cache (engine/live.ts) holds two things apart:

  • rows — key → the rows a reactive query returned. The only large thing, bounded by age (ttlMs, default 60 s), count (maxEntries) and text (maxBytes, measured as each result's JSON length), least-recently-used first out; a result over maxEntryBytes is never stored. Evicting rows never breaks anything; it costs one query. It lives in process memory, per engine.
  • follows — key → the tables the query reads, how to run it again, and who to tell. Bounded by what is followed, not by this module: a follow ends when its last follower's signal aborts.

The key is the compiled SQL and its bound values. Scope compiles into both — row rules into the SQL, scope values into the parameters — so two callers share rows exactly when they would provably get the same rows. The cache never reasons about policy. The MAPPING, which reads $.scope (a reader's language), runs per caller over the shared rows: one query for twenty viewers, twenty cheap mappings.

A write bumps its tables' versions. Stored rows remember the versions they were read at, and are stale when those moved — invalidation is a counter bump, not a walk over the cache. The same stamp closes the race that would otherwise poison it: rows fetched by a read that started before a write and finished after it carry the versions from before, so they are stale the moment they land, and a new read does not join that fetch.

Who invalidates: the handler, after every committed mutation — engine.invalidate(tablesChangedBy(writes, schema)). It sits there, and not in any host's observer, because this is the one door every vex write passes: an HTTP request, a host's in-process machinery write, a reflex's effect. A read that heard about only some writes would be live only sometimes, which is worse than a snapshot that says what it is. tablesChangedBy adds, for a delete, every table that references a deleted one, transitively — a cascade changes rows vex never wrote, and the introspected schema carries the reference but not its rule, so a delete is assumed to reach them all (over-telling costs a refetch; under-telling leaves a screen stale). Writes vex never sees — raw SQL, a database trigger — are not invalidations; a host that makes them calls engine.invalidate itself, and the TTL heals the rest.

Followed reads refetch once per burst (debounceMs, trailing) — thirty writes are one query — and are numbered, so a slow refetch landing after a newer one is dropped. Change is decided twice: once per key on the rows (the same rows → nobody is mapped), then per follower on its own mapped answer (canonical JSON, hashed), so a follower hears only when its answer changed. Every followed read is also re-checked once per TTL, bypassing the cache — a lost invalidation heals within one TTL. A refetch that fails keeps the last good answer and says so (rows.error).

Visibility. engine.rows.stats() — entries, bytes, follows, followers, hits, misses, evictions, oversized, refreshes — and rows.evict / rows.error events on the engine's onEvent. An engine evicting faster than it answers warns once a minute: its rows limits are too small for what it holds.

Rules for authors. A reactive entry should sort — an unordered read can come back in another order after a write and count as changed (harmless: one extra answer). It reads time from $scope, never context: the values bound when the read was made are the ones it refetches with.

Who "me" is (behaviors, GenerationCaller.bindings)

Built 2026-09-27. A generated query can mean the caller — "what's my name?", "my orders", "after me" — without the model ever seeing who that is.

The meaning comes from what the host declared, never from a name. The host's behaviors (the ScopeBehaviors its policies are compiled from) bind columns to scope keys: { set: 'member_id', to: 'userId' }, { match: 'studio_id', to: 'tenantId' }. Scope values are always the caller's own, so a column bound to a key the caller carries holds that value on the caller's own rows — whether it is called user_id, guest_id or id.

Why the behaviors and not the policy. A compiled policy keeps only the phases a caller is granted. A member who may only read members has members: { read: [] }; the rule that says member_id is them lives on an insert they do not hold. So the engine takes the behaviors themselves (QueryEngineConfig.behaviors) and reads every reach and every phase.

What a generation is handed: GenerationCaller.bindings — every column bound to a scope key this caller actually carries, on a table their policy lets them read. Keys and columns, never values. A caller carrying none of the keys — machinery, an executeAs job — has no self, and is told so; nothing else about it is special. The reference agent turns the bindings into one context line (describeCaller): filter such a column with { $scope: key } to mean the caller, and read the caller's row as an aliased subquery source to compare other rows against it.

Safety is unchanged. The bindings are guidance for the model; access is still the policy, and the value is still bound by the engine. A query written this way is stored like any other and answers for whoever replays it — one "what's my name?" serves everybody.

Checked at introspect: every column a behavior names must exist, or the engine refuses to start.

LLM integration (decoupled)

The engine takes two optional hooks:

  • generateDsl(request, schema, caller) → Query — natural language + shape → DSL. schema is only the tables the caller's policy can read; caller.read runs a DSL through the engine's pipeline as that caller. A generation sees what the person asking could see — and it is keyed (single-flight, negative cache) by request AND policy, because two callers who see different tables are asking different agents.
  • mapToShape(rows, shape, intent?) → { ir, config, transformed } — raw rows → requested shape, returning a compiled Prism IR that is cached for reuse, and the Prism config it was compiled from, which is kept beside it (mapping). intent is the request's own words, when it has them, so the mapping can give a value the form they ask for (money as $1.2M, a date as Apr 6).

This is the seam that keeps the core deterministic. Vex ships a reference implementation of both, built on @niscorp/cortex and @niscorp/prism and exported from the @niscorp/vex/agent subpath — createQueryDsl (fills generateDsl) and createShapeMapper (fills mapToShape):

  • The query agent (vexQueryDslAgent, id vex.query) is a structured-output Cortex agent over QuerySchema with six tools — getSchema, getSampleRows, getDistinctValues, describeField, testQuery, cannotSatisfy — and three stop conditions (stepCount(20), outputRetries(3), repeatedCalls(2)); createQueryDsl aborts the run when cannotSatisfy is called. Context producers inject the live schema and the DSL specification as compact text, and the caller line, so the static instructions stay stable. Every data-touching tool is a DSL run through caller.read; the tool set holds no adapter and no SQL. createQueryDsl wraps it into the generateDsl hook.
  • The mapping agent is Prism's exported mappingAgent; createShapeMapper builds the same envelope the runtime replays against — { result: rows } for an array shape, { result: rows[0] ?? null } for an object shape — runs the agent once, with the intent as its notes, to get a transform config, compiles it to IR, and applies the IR once over that envelope. A flat shape whose fields the rows carry is mapped by Prism with no model call, its declared fields computed from the stored ones; the agent writes the mapping for any other.

The reference agents are a separate concern from the deterministic core: they live behind the @niscorp/vex/agent subpath, so @niscorp/cortex (and @niscorp/signal behind it) is pulled in only when you import it. The engine itself depends on neither — it needs @niscorp/prism only to execute a stored mapping. Provide your own generateDsl/mapToShape, or use the reference factories.

HTTP layer (optional)

Vex is a library; it does not own a server. But it ships two thin framework adapters and a framework-agnostic handler so a resource can be exposed with a few lines:

  • handler.ts — handleDiscovery returns a self-describing JSON document (entities, fields, relations, the request contract, and the DSL JSON Schema); handleQuery parses a body, runs engine.execute, and maps VexErrors to HTTP status codes.
  • @niscorp/vex/hono and @niscorp/vex/express wrap that handler: GET returns discovery, POST runs a query or replays a write, PATCH/DELETE manage fingerprints, and scope is resolved per-request via a getScope callback.

Events

An optional onEvent handler receives a typed VexEvent stream across the pipeline — query.start, query.cache, query.dsl, query.sql, query.rows, query.mapped, query.done, query.error, plus llm.request/ llm.response for callers that wrap their LLM client. This is how the dev server renders per-stage timing and a full LLM transcript log.


File structure

src/
  index.ts                       Public API barrel
  types.ts                       QueryEngine, QueryEngineConfig, ExecuteOptions, hook types
  errors.ts                      VexError (code + details)
  events.ts                      VexEvent union, VexEventHandler
  handler.ts                     Framework-agnostic discovery + query handlers
  guide.ts                       vexGuide — the agent-facing contract text

  schemas/
    query.schema.ts              QuerySchema (top-level DSL) + Source, SortEntry
    filter.schema.ts             FilterSchema (recursive union)
    compute.schema.ts            ComputeExpressionSchema
    aggregate.schema.ts          AggregateExpressionSchema
    value.schema.ts              FieldOrValue, ContextRef ($context), ScopeRef ($scope)
    identifier.schema.ts         the name grammar: field paths and identifiers
    database.schema.ts           DatabaseSchema, EntitySchema, FieldSchema, ...
    request.schema.ts            QueryRequestSchema, QueryResponse, error codes
    index.ts                     Barrel

  scope/
    discover.ts                  discoverEntities(dsl)
    apply.ts                     checkScope, scopeResolved, applyScope, VexScopeError
    grants.ts                    createScopePolicy, mergeScopePolicies, scopeGrants, scopeProfiles, scopeBindings
    scope.types.ts               ScopePolicy, ScopeEntityRule, ScopeMatch, ScopeSet

  engine/
    runtime.ts                   createQueryEngine — orchestration + cache + single-flight
    resolver.ts                  resolve(dsl, schema) → ResolvedQuery
    analyzer.ts                  analyze(resolved, config) → { warnings, errors }
    executor.ts                  executeQuery, buildContextContract, findMissingContext
    optional.ts                  pruneOptional, presenceOf — optional conditions
    live.ts                      createLiveRows — the rows cache and follows behind refresh: 'reactive'
    engine.types.ts             ResolvedQuery and friends, AnalysisConfig, TestResult

  adapters/
    adapter.types.ts             DatabaseAdapter, CompiledQuery, ParamSlot, capabilities
    postgres/
      postgres.adapter.ts        createPostgresAdapter
      introspect.ts              schema discovery from pg_catalog
      compile.ts                 ResolvedQuery → parameterized SQL
      operators.ts               per-operator SQL generation
      statement-timeout.ts       how a read's time limit is enforced
      date-parsers.ts            RAW_DATE_PARSERS, shared by the two pool shims
    pglite/index.ts              createPglitePool (@niscorp/vex/pglite)
    pg/index.ts                  createPgPool (@niscorp/vex/pg)
    hono/index.ts                vex() Hono app (@niscorp/vex/hono)
    express/index.ts             vex() Express handler (@niscorp/vex/express)
    index.ts                     adapter barrel

  mutations/
    schema.ts                    MutationSchema (closed grammar), MutationDefinitionSchema
    engine.ts                    executeWrites, executeMutation — desugar/scope/validate/compile/execute
    signature.ts                 collectMutationContext, collectQueryContext, mutationEffect, lintMutation
    index.ts                     Barrel

  cache/
    cache.types.ts               CacheBackend, CacheEntry (ok | mutation | unsatisfiable)
    hash.ts                      normalizeShape, computeRequestHash, computeSchemaFingerprint, mintFingerprint
    memory.ts                    createMemoryCache (L1)
    postgres.ts                  createPostgresCache (L2, durable)
    tiered.ts                    createTieredCache (L1 + L2, warm-up)
    validate.ts                  validateEntry
    seed.ts                      seedCache, SeedEntry, SeedMutation
    util.ts                      isEntryFresh, fireAndForget, sweepCache
    index.ts                     Barrel

  agent/                         Reference Cortex agents (exported via ./agent)
    query.agent.ts               defineAgent: vexQueryDslAgent (vex.query → Query)
    tools.ts                     the six query tools
    index.ts                     createQueryDsl, createShapeMapper, vexQueryDslAgent

  utils/
    context.ts                   buildValidationContext, resolveParams
    canonical.ts                 canonical JSON text + hash
    sha256.ts                    SHA-256 of a text, synchronous, no node built-in

scripts/                         Dev-only: docker, seed, dev server, fixtures

Dependencies

Package Relationship Purpose
zod peer (required) Schema validation + JSON Schema generation
pg peer (optional) A Postgres pool for the adapter and the cache; @niscorp/vex/pg imports it
hono peer (optional) Hono framework adapter
express peer (optional) Express framework adapter
@niscorp/cortex peer (optional) Reference query agent (defineAgent/defineTool, standalone runtime)
@niscorp/signal via Cortex (its peer) LLM calls + embeddings
@niscorp/prism peer (required) Result mapping (IR compile/execute; mappingAgent for the reference mapper)
@niscorp/strata peer (required) The Postgres cache table's sequence and ledger; request depth guard

zod, @niscorp/prism and @niscorp/strata are mandatory. Everything else is pulled in only by the path you use.

No Node built-in on the path a page takes. The main entry and @niscorp/vex/pglite import nothing from Node, so a bundler builds them for a browser as they are — vex over PGlite in the page, the client-degrade posture. The one thing that stood in the way was hashing: every identity vex computes (a request hash, a schema fingerprint, a policy key, the row and answer hashes of a reactive read) is a SHA-256, and it came from node:crypto. It is now written out in utils/sha256.ts — synchronous, because three exported functions answer with a hash directly and a reactive read's follower compares two inside a callback, which rules out WebCrypto's subtle.digest. The digests are node's, byte for byte, so nothing a cache already holds under one has moved.


Key design decisions

  1. Constrained DSL, not text-to-SQL. Security (the model can't emit injection through a validated JSON union), cacheability (a structure hashes more reliably than free text), and safety (the analyzer rejects cartesian products and over-deep nesting before SQL exists).

    A validated JSON union is only injection-proof if every string in it reaches SQL as something the union can vouch for. Authored text reaches a statement exactly three ways: a column that resolved against the introspected schema, a literal quoted the way Postgres's quote_literal does (safe under either standard_conforming_strings), or an output name held to ident and quoted. Values from a request bind as parameters and never reach text at all. The name grammar (identifier.schema.ts) is checked three times — at parse, in the resolver for a DSL that skipped parsing, and by a compiler that refuses any field position that did not resolve.

    ⟲ It was not, until 2026-09-24. Field positions (isNull, in, like, aggregate arguments) were typed z.string(); the resolver skipped any string without a dot and the compiler pasted it into the statement, so { isNotNull: "(SELECT secret FROM …)" } was a boolean oracle over a table the policy denied. Output names went into AS "…" unescaped, and a literal containing a backslash could close its own string on a server with standard_conforming_strings off. test/security/injection.test.ts is the attack list, against a real Postgres; it failed 35 of 41 before the fix.

  2. The LLM is injected, not embedded. Making generateDsl/mapToShape plain hooks keeps the engine deterministic and testable without a model, lets the heavy Cortex/Signal stack stay optional, and lets a consumer swap in their own generator. The bundled Cortex agents are a reference, not a requirement.

  3. Fingerprint identity, minted or named. The output shape still determines the DSL and the mapping — but the cache key is a fingerprint, not the shape. A first request mints one (fp_…); a caller may instead own a named slot. Shape-hashing collided (two intents, one shape, one slot) and left every entry anonymous; a fingerprint makes a generation an addressable, replayable artifact — pin it, replay it, roll it back. (See Cache v2.)

  4. Scope injected server-side, after RESOLUTION. If the model could see scope filters it could be talked out of them. Binding them to $scope values after the DSL is fixed makes them unforgeable.

    The policy decides whether scope applies; the values only fill it. An engine with a policy enforces it on every run, and a $scope slot nobody filled refuses (missing_scope) rather than binding NULL — which matches no row on a read and stamps NULL on a write.

    ⟲ Until 2026-09-24 scoping switched on only when a policy AND values were both present. Omitting the values turned the policy off entirely, table access included — a call that forgot to say who it was for read as everybody, and one did (relay's record lookup). Writes had always required both; reads now agree. The generating agent's tools had the same hole by a different route: they ran their own SQL, unscoped, beside the pipeline. They now reach the database only through caller.read.

    Scope answers two questions and they run at different stages, which is the correction to an earlier version that ran both at once:

    • May this caller touch this table? — checkScope, over entity names, before resolution. Needs no schema and no aliases.
    • Where does the row rule go? — scopeResolved, after resolution, because the answer depends on HOW the table is reached.

    That second stage exists because merging row rules into the DSL's filter compiles them to WHERE, and a WHERE predicate on a left-joined table is a LEFT JOIN demoted to an INNER one. A driving row whose optional FK is null gets null-padded columns, null = $tenant is null rather than true, and the row disappears. No error, no warning, a shorter list — and invisible until the data actually contains a null FK, which in one consumer took months. The resolver had already gone to the trouble of inferring left for exactly this reason; the scope layer was undoing it two steps later.

    Row rules for left-joined tables now go in that join's ON clause. The boundary is unchanged — another tenant's row still cannot contribute columns — it simply no longer annihilates the row that had none.

    A consequence worth having on its own: scope writes nothing into the authored Query. The document integrations ship over the wire carries no engine-trusted field, so there is no key to strip at intake and no forgery surface to remember.

  5. exists shadows SQL rather than inventing a vocabulary. Correlated subqueries were the one thing the filter grammar could not express, so "issues nobody was sent to" and "rooms nobody is in" became two round trips each — a read returning a flat array of ids, then a notIn against it. That is fine at three dozen rows, wrong at three thousand, and racy in between.

    { exists: { from, filter } } is the same { from, filter } a query already is, and nothing more: no fields, no sort, no limit. EXISTS asks whether a row is there, so anything shaping output would be noise — and refusing it is what keeps this an operator rather than an invitation to nest arbitrary SQL. The correlation needs no new syntax at all, because a dotted string on either side of a comparison is already a field path:

    { "exists": { "from": ["tasks"],
                  "filter": { "eq": ["tasks.issue_id", "issues.id"] } } }
    

    not composes for NOT EXISTS. Whoever knows SQL writes this on the first try, which was the entire design constraint.

    Two implementation notes that are load-bearing. It compiles inline, into the parent's own parameter counter — the other subquery path compiles independently and renumbers afterwards, which is how two parameterised subqueries came to collide on $1; there is nothing to renumber if nothing was numbered apart. And its inner aliases come from the shared alias counter, so an exists over the same table as the outer query cannot take the same alias and silently correlate a table with itself.

    Scope recurses into it. Access-checking the inner table is not enough: an EXISTS returns a boolean, but a boolean about somebody else's rows is still an answer about somebody else's rows, and an uncorrelated exists over a scoped table would otherwise report whether ANY tenant had one.

  6. Negative caching with a TTL. An "impossible" request is expensive to re-discover every time, so a cannotSatisfy result is cached — but TTL'd and fingerprinted, because a schema change can make yesterday's impossible request possible.

  7. Single-flight on the default path. A burst of identical cache misses collapses to one generation; the rest await it. (The refresh/bypass cache modes that opted out died with v1 — see Cache v2.)

  8. Adapters own compilation. The DSL and agent loop are database-agnostic; only SQL generation is not. Keeping the entire compile step inside the adapter means a new backend is a self-contained unit and the core never grows dialect knowledge.

  9. A discovery endpoint, not just a query endpoint. A self-describing resource (GET → entities + contract + DSL JSON Schema) lets a client — or another agent — learn how to query it without out-of-band documentation.

  10. Writes are replay-only, one wire shape with reads. Mutations are dev-authored cache entries (kind: 'mutation') invoked by fingerprint; the def never travels and no generation path exists. The client-facing API is uniformly { fingerprint, context } — the entry's kind picks the pipeline. Context signatures are DERIVED from the stored def (typed from the schema by position), so the input contract can't drift.


Deferred

  • Dynamic mutation generation — icebox, on purpose. If it ever returns, it feeds the existing pipeline (author → validate → seed) behind an effect contract and an approval step; nothing shipped today gets replaced.
  • Mutation transactions with cross-statement refs — built: see A batch that uses what it wrote ($returned) under Mutations.
  • A reply shape for writes. A mutation still answers with the rows its statements returned (one row as that row, several as an array); choosing what of a batch the caller is handed — a mapping over the writes — is not built.
  • Engine-derived paging (a cursor the engine mints from a query's sort). Considered with the limit work and left out: an author can already page with a $context bound in the filter, and a second way to do it was not earned.
  • MySQL / SQLite adapters — the interface is ready; implementation on demand.
  • Multi-level field paths — addable in the resolver without DSL changes.
  • Intent-aware positive caching — superseded: collisions did bite, and the answer is identity, not intent-matching. See Cache v2 — fingerprints.
  • Cross-process cache coherence for the tiered backend (an L1 TTL refresh).
  • refresh: 'clock' — time as a source of change: a reactive read whose bound scope includes a clock value (today) refetches when that value next moves.
  • Cross-process invalidation for reactive reads — a host carries engine.invalidate between processes (moss's fabric); until then each process's TTL bounds the staleness.
  • Row-level pruning — for a single-table reactive read, test a written row against the scoped filter and skip the refetch. An optimisation, not a design.
  • Adapter-owned vector serialization — the pgvector [...] literal is currently formed in the generic binding step; a second adapter would move it behind the adapter.

Reach is the caller's, not the table's

A ScopePolicy fuses two questions: which phases an entity has, and what a granted phase does. The second used to be answered per TABLE — one match rule per entity, applied to every caller that held any grant on it.

That is right until two callers need the same grant at different distances. "The desk reads every booking; a member reads their own" cannot be said with one rule per table, and the workaround is always the same: a SECOND TABLE carrying the tighter rule, kept level with the first by a trigger. One fact in two places is a drift bug waiting for the first ordering nobody thought about.

So a table may declare NAMED rule sets, and createScopePolicy takes the caller's profile:

bookings: { default:  { read: [tenant] },
            personal: { read: [tenant, own] } }

createScopePolicy(grants, behaviors)             // studio-wide
createScopePolicy(grants, behaviors, 'personal') // + their own rows

The profile is chosen ONCE per caller rather than per grant, because "acts for themselves" is a property of the caller and not of each thing they read — and saying it once is one place to get it wrong instead of eight. A table declaring no variant under a profile falls back to its default, which is what keeps shared reads (a timetable everyone sees the same way) free of per-profile entries.

It fails closed in the direction that matters. An unknown profile denies everything rather than falling back: a mistyped name that quietly meant "the default" would widen a caller to every row of every table they hold a grant on, which is the one failure mode a policy layer may not have. A table that declares named variants and no default likewise refuses an unprofiled caller instead of guessing that "no rule" is safe.

AND A CALLER IS NOT ONE ROLE. Reach belongs to a role, and people hold several — the instructor who trains at the studio they teach at is the ordinary case, not the edge. So the unit of compilation is the ROLE: one policy each, merged (mergeScopePolicies), broadest rule set winning per entity and phase. A principal may do anything any of their roles permits.

That merge has a consequence worth stating plainly: for somebody holding both, a staff role's studio-wide reach WINS over a member role's personal one on the tables they share. Correct for the roster they are paid to read, wrong for their own membership card.

SO REACH IS SAID TWICE, and by two different parties. The CALLER carries a floor — how far this principal may see, from their role. The ENTRY may carry a ceiling — how far this READ may see, whoever asks (reach). "Their own card" is a property of the question, not of whoever happens to be asking it, and an app that could only say it about people was an app that got it wrong for anybody holding two roles.

The two compose in one direction only: an entry's reach recompiles the caller's OWN grants under a different profile, so it narrows rows and never widens verbs. A principal without the verb is refused either way. And a declared reach that cannot be compiled refuses the read rather than falling back — the fallback would be an answer with too much in it, and nothing would say so.