Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Nervix is experimental software in active development. It is intended for evaluation, local testing, and design exploration. It is not suitable for real production workloads.

Nervix is a realtime stream processing system with a robust declarative DSL for defining data-flow graphs.

Nervix stream processing Language (NSPL) is used to declare the schemas, connections, runtime nodes, and external integrations that make up a Nervix graph:

  • schemas
  • wire schemas
  • codecs
  • relays
  • clients
  • ingestors
  • processors
  • emitters
  • vhosts
  • endpoints

NSPL has reached alpha. Its period of rapid, broad experimentation is over, so large backward-incompatible language changes are no longer expected. The language will continue to evolve, however, and focused breaking changes may still occur. See Language Stability for the current compatibility policy.

The core runtime model is a graph of connected nodes:

  • ingestors bring data into the system
  • relay owners route data, while processors, junctions, deduplicators, and reingestors transform it
  • emitters push results out to external systems

Current built-in transport integrations include Kafka, Pulsar, HTTP, Prometheus, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS, and WebSockets, with Sentry event-envelope emission over HTTP.

The connections between nodes are expressed through RELAYs.

Input data often mixes tenants, users, devices, accounts, or other business groups in the same external feed. Nervix uses explicit CREATE BRANCH declarations to process those groups independently. A branch names the key schema, such as CREATE BRANCH by_tenant_user SCHEMA tenant_user_branch TTL 5m; ingestor and reingestor routes construct concrete keys with BRANCHED BY by_tenant_user SET ....

RELAYs are scheduled runtime nodes between producers and consumers. Each has one owner that holds its buffer, concrete branch presence, fan-out, and metrics. Relays are declared as BRANCHED BY <branch> or UNBRANCHED; only ingestors and reingestors carry the key mapping that constructs concrete branch identities. Processing state remains isolated by that identity, while one relay owner coordinates its cluster-wide buffering and delivery. An emitter drains records out of the graph. A reingestor can compute a new group and start downstream branches under that grouping.

Nervix already runs clustered deployments, schedules graph nodes across multiple servers, executes codecs in the runtime, replicates selected runtime state, and supports multi-node failover scenarios. It is still evolving, but it is beyond a parser-only prototype.

This book is split into six sections:

Start with the quickstart if you are new to Nervix, and with the manual unless you are specifically trying to understand internals. Agents can start with the portable NSPL Agent Skill.

Nervix is licensed under the Fair Core License (FCL). See License.

Quickstart

Nervix is experimental software in active development. It is intended for evaluation, local testing, and design exploration. It is not suitable for real production workloads.

This section takes you from nothing to a running Nervix graph. It is a hands-on tutorial: each step is a small, complete change that you apply to a live single-node cluster and verify before moving on. You build one graph throughout — it starts as a trivial pass-through pipeline and grows into a branched graph with conditional routing and structured error handling.

Every page links the Manual chapter that documents each feature in full: the tutorial shows one working path, and the manual holds the reference.

Installation

Install the server and CLI from GitHub:

cargo install --locked \
  --git https://github.com/nervix-io/nervix.git \
  nervix-server nervix-cli

Ensure Cargo’s binary directory, $HOME/.cargo/bin by default, is on your PATH.

See Installation for prerequisites, reproducible Git revisions, a three-node Docker deployment, and the Kubernetes Operator installation.

Running Nervix

This page starts everything the tutorial needs on your local machine: one Nervix node, a Kafka broker, and a Redis server.

Start The Server

A Nervix node is a single nervix-server process. From a repository checkout, create the local development identity and start a fresh single-node cluster:

just generate-dev-tls
nervix-server \
  --node-id node-1 \
  --interconnect-listen-addr 127.0.0.1:47392 \
  --interconnect-advertise-addr 127.0.0.1:47392 \
  --interconnect-tls-ca tls/dev/ca.pem \
  --interconnect-tls-cert tls/dev/node.pem \
  --interconnect-tls-key tls/dev/node-key.pem \
  --allow-bootstrap \
  --init-default-user-password nervix
  • --node-id names this node inside the cluster.
  • The interconnect address and TLS files configure the mandatory authenticated HTTP/2 listener; a single local node points its listener and advertised address at the same local port.
  • --allow-bootstrap lets this node form a brand-new cluster instead of joining an existing one.
  • --init-default-user-password seeds the default user’s password on the very first startup. Once the user exists, remove the flag from normal startup.

Every flag has a NERVIX_* environment variable twin, such as NERVIX_NODE_ID. Control-plane state is persisted under ./.nervix-db by default; use --db-path to relocate it. The directory contains the node database for registry and runtime state and a separate consensus database whose journal carries only Raft state.

The server exposes:

  • the session gRPC API on 127.0.0.1:47391 (--addr), used by nervix-cli and the Rust client library
  • the web console at http://127.0.0.1:47420/console/, which combines a command line with a live graph view
  • health and metrics endpoints on port 9090: /livez, /readyz, and /metrics (Metrics And Observability)

Stop The Server

Press Ctrl-C, or send the process SIGTERM, to stop the node. The first SIGINT or SIGTERM starts graceful shutdown: the node advertises that its process is terminating, stops admitting new work, closes the client connections it had accepted, and runs its shutdown phases, logging whether each one completed or abandoned work. The process exits with status 0 once graceful shutdown finishes, or prints the error and exits with status 1 when a listener or a shutdown step failed.

Graceful shutdown must finish within the shutdown timeout, --shutdown-timeout (NERVIX_SHUTDOWN_TIMEOUT), which defaults to 50s and is measured from the first signal. When it passes, the process logs the shutdown phase it had reached and exits at once with status 1. Nothing extends that deadline: a client that holds an upload or a session open does not, and a later signal can only end the process sooner.

A second SIGINT or SIGTERM abandons graceful shutdown and ends the process immediately, without running the phases that remain. The process exits with status 130 when the second signal is SIGINT and 143 when it is SIGTERM, the statuses a shell reports for a process those signals terminate. Work still in progress is lost exactly as it is when the process crashes; SIGKILL always ends the process that way.

The server registers both signals before it starts anything else. A signal that arrives while the node is still starting takes effect as soon as startup completes, while its shutdown deadline runs from the moment the signal arrived. A node that cannot register the signals refuses to start.

Shutdown And Recovery describes the phases, what the node drains before it exits, what each ending preserves, and how to read a shutdown that did not finish.

Connect A Client

nervix-cli is the interactive NSPL client. With no arguments it connects to http://127.0.0.1:47391 as user default and prompts for the password:

nervix-cli
nervix[default]> SHOW CLUSTER STATUS;

Statements end with ;, and the prompt shows the active domain. The same statements can be typed into the web console’s command line, or executed one-shot with nervix-cli --command "<statements>". There is no file-execution mode: to run a saved script, paste it into the client or pass it through --command "$(cat pipeline.nspl)". To format a saved script, use nervix-nspl-format.

Start Kafka And Redis

The tutorial reads from Kafka and publishes to Redis. Any reachable Kafka broker and Redis server work; for a throwaway local setup:

docker run -d --name broker -p 9092:9092 apache/kafka:latest
docker run -d --name redis -p 6379:6379 redis:7

If you work from a repository clone, just deps starts a complete local dependency stack instead (Developing Nervix).

Nervix never creates topics, queues, or any other external entities as a side effect of running a graph. Missing entities surface as initialization or publish errors, so the tutorial provisions its Kafka topic explicitly before ingesting from it.

Continue to Your First Pipeline: Kafka To Redis.

Your First Pipeline: Kafka To Redis

The first graph is deliberately trivial: JSON order records arrive on a Kafka topic, Nervix decodes them, and an emitter publishes them to a Redis Pub/Sub channel. Along the way you meet every building block that later steps reuse: domains, schemas, codecs, relays, clients, ingestors, and emitters.

Provision The Topic

Nervix never creates external entities, so create the Kafka topic first:

docker exec broker /opt/kafka/bin/kafka-topics.sh \
  --create --topic orders --bootstrap-server localhost:9092

Create A Domain

Every entity in Nervix lives in an explicit domain — there is no implicit default. Open nervix-cli and create one, then switch the session to it:

CREATE UNPACED DOMAIN quickstart;

USE quickstart;

An unpaced domain admits records as they arrive, with no domain clock pacing — the right choice for a first pipeline. See Domains And Time. USE is a client-local statement that scopes the rest of your session to the domain.

Open A Transaction

Submitting more than one model statement at a time requires a transaction, and it is also how a graph is assembled atomically — nothing takes effect until COMMIT;. See NSPL Overview for the statement surface.

BEGIN;

The graph declarations in the rest of this page run inside this transaction.

Describe The Data

Nervix separates what a record looks like inside the graph from how it is serialized on the wire. The internal schema is exact-typed; the wire schema describes the JSON payload in the wire format’s own type names; the codec bridges the two. See Schemas And Codecs.

CREATE SCHEMA order_record (
  order_id STRING,
  customer STRING,
  status STRING,
  amount I64,
  quantity I64
);

CREATE WIRE JSON SCHEMA order_wire MODE STRICT (
  order_id string,
  customer string,
  status string,
  amount integer,
  quantity integer
);

CREATE CODEC order_codec
  FROM WIRE JSON SCHEMA order_wire
  TO SCHEMA order_record;

STRICT rejects payload fields that are not declared; LOOSE would drop them instead (Wire Schemas). There are no implicit casts anywhere in Nervix, so the codec is the only place where wire values become typed internal values. A DATETIME field would additionally need an explicit encoding such as ENCODE created_at AS RFC3339 (Codecs).

Create A Relay

Relays are the named streams that connect runtime nodes. Each relay carries records of exactly one schema. See Streams And State.

CREATE RELAY orders SCHEMA order_record UNBRANCHED;

UNBRANCHED means one shared stream. Branched Processing introduces the alternative.

Declare The External Connections

Connection endpoints never live on ingestors or emitters; they are named CLIENT objects that nodes reference. Kafka client config is passed through to the Kafka driver, so bootstrap.servers uses its familiar key.

CREATE CLIENT kafka_local
  TYPE KAFKA
  CONFIG {
    'bootstrap.servers' = '127.0.0.1:9092'
  };

CREATE CLIENT redis_local
  TYPE REDIS
  POOL SIZE MIN 1 MAX 4
  CONFIG {
    'addr' = 'redis://127.0.0.1:6379/'
  };

Ingest From Kafka

An ingestor brings external data into the graph. It decodes each payload with the codec and routes the result to a relay:

CREATE KAFKA TOPIC INGESTOR kafka_orders
  FROM kafka_local
  TOPIC 'orders'
  OFFSET BY CONSUMER GROUP 'quickstart'
  MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
  ON QUIESCE SUSPEND DECODE USING order_codec
  TO orders
    INHERIT ALL
    UNBRANCHED
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Reading top to bottom:

  • KAFKA TOPIC INGESTOR names the connector and the entity the ingestor reads. FROM names the TYPE KAFKA client, and the topic and consumer group are string literals sent to Kafka exactly as written.
  • OFFSET BY CONSUMER GROUP tracks progress in Kafka; MODE ACK SEQUENTIAL acknowledges records in order. The full Kafka source grammar is in Supported Ingestor Types.
  • TO orders starts a route. Routes begin empty: INHERIT ALL copies every decoded field into the outgoing record, and later steps show SET constructing new values. See The Working Message.
  • Every route needs an explicit flush policy — FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE. There are no hidden defaults.
  • ON MESSAGE ERROR LOG is the route’s per-record error policy; ON GENERAL ERROR LOG is the node-wide policy (Runtime Node Error Policies).

Emit To Redis

An emitter drains a relay out of the graph. The Redis integration is Pub/Sub (Supported Emitter Sinks), and the same codec that decoded the input encodes it back to JSON:

CREATE EMITTER redis_orders
  FROM orders
  TO REDIS PUBSUB redis_local CHANNEL orders_out
    MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING order_codec
  INHERIT ALL
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Redis Pub/Sub exposes no delivery confirmation, so MODE NO_ACK names server acceptance as the publish boundary. The required retry policy paces connection and other infrastructure failures; it does not turn subscriber presence into an acknowledgment.

Commit And Start

COMMIT; applies the structural changes: the whole graph is validated and installed atomically. Nothing processes data yet — that is the job of START;, the domain lifecycle command that begins actual work (Start And Stop):

COMMIT;

START;

The Complete Script

CREATE UNPACED DOMAIN quickstart;

USE quickstart;

BEGIN;

CREATE SCHEMA order_record (
  order_id STRING,
  customer STRING,
  status STRING,
  amount I64,
  quantity I64
);

CREATE WIRE JSON SCHEMA order_wire MODE STRICT (
  order_id string,
  customer string,
  status string,
  amount integer,
  quantity integer
);

CREATE CODEC order_codec
  FROM WIRE JSON SCHEMA order_wire
  TO SCHEMA order_record;

CREATE RELAY orders SCHEMA order_record UNBRANCHED;

CREATE CLIENT kafka_local
  TYPE KAFKA
  CONFIG {
    'bootstrap.servers' = '127.0.0.1:9092'
  };

CREATE CLIENT redis_local
  TYPE REDIS
  POOL SIZE MIN 1 MAX 4
  CONFIG {
    'addr' = 'redis://127.0.0.1:6379/'
  };

CREATE KAFKA TOPIC INGESTOR kafka_orders
  FROM kafka_local
  TOPIC 'orders'
  OFFSET BY CONSUMER GROUP 'quickstart'
  MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
  ON QUIESCE SUSPEND DECODE USING order_codec
  TO orders
    INHERIT ALL
    UNBRANCHED
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

CREATE EMITTER redis_orders
  FROM orders
  TO REDIS PUBSUB redis_local CHANNEL orders_out
    MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING order_codec
  INHERIT ALL
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

COMMIT;

START;

Send An Order

Subscribe to the output channel in one terminal:

docker exec redis redis-cli SUBSCRIBE orders_out

Produce an order in another:

docker exec -i broker /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 --topic orders
{"order_id":"o-1001","customer":"acme","status":"new","amount":1500,"quantity":3}

The Redis subscriber prints the record back as JSON. You can also watch any relay from inside Nervix with a read-only session subscription (Sessions):

nervix-cli --domain quickstart subscribe watch orders

The pipeline works end to end. Next, add logic to it in Conditional Routing.

Conditional Routing

The pass-through pipeline treats every order the same. This step adds a junction that splits the stream: high-value orders get a computed tier field and their own Redis channel, routine orders continue separately.

Everything here is additive. The domain keeps running while you apply it — a transaction of pure CREATE statements rebuilds the schedule without pausing the graph (Automatic Model-Alteration Quiescing).

The Enriched Record

Construction is exact-typed, so a record with an extra tier field is a different schema, with its own wire schema and codec for emission — the same pattern as the first pipeline:

BEGIN;

CREATE SCHEMA order_tiered (
  order_id STRING,
  customer STRING,
  status STRING,
  amount I64,
  quantity I64,
  tier STRING
);

CREATE WIRE JSON SCHEMA order_tiered_wire MODE STRICT (
  order_id string,
  customer string,
  status string,
  amount integer,
  quantity integer,
  tier string
);

CREATE CODEC order_tiered_codec
  FROM WIRE JSON SCHEMA order_tiered_wire
  TO SCHEMA order_tiered;

CREATE RELAY high_value_orders SCHEMA order_tiered UNBRANCHED;

CREATE RELAY routine_orders SCHEMA order_record UNBRANCHED;

Route With A Junction

A junction subscribes to one or more relays and fans records out to routes. Each route is an independent contract: its own construction, its own WHERE filter over the finalized output, its own flush policy. See Junction and The Working Message for the input/output scopes used below.

CREATE JUNCTION route_orders
  FROM orders
  UNBRANCHED
  TO high_value_orders
    INHERIT ALL
    SET tier = CASE
          WHEN input.amount >= 10000 THEN "vip"
          ELSE "high"
        END
    WHERE output.amount >= 1000
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  TO routine_orders
    INHERIT ALL
    WHERE output.amount < 1000
    FLUSH EACH 1s MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG;
  • SET initializes the one field INHERIT ALL cannot supply. Assignments run in order, and CASE/IF expressions are part of the expression language (Conditional Expressions).
  • The route WHERE filters finalized records: output.amount is the value the route just constructed, while input.amount inside SET reads the incoming record.
  • The orders relay now has two subscribers — the emitter from the first pipeline and this junction. Relays fan out to every subscriber (Streams And State).

Emit The High-Value Stream

CREATE EMITTER redis_high_value
  FROM high_value_orders
  TO REDIS PUBSUB redis_local CHANNEL high_value_out
    MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING order_tiered_codec
  INHERIT ALL
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

COMMIT;

Watch The Split

Subscribe to the new channel:

docker exec redis redis-cli SUBSCRIBE high_value_out

Produce three orders with different amounts:

{"order_id":"o-1002","customer":"acme","status":"new","amount":250,"quantity":5}
{"order_id":"o-1003","customer":"globex","status":"new","amount":2500,"quantity":1}
{"order_id":"o-1004","customer":"acme","status":"new","amount":25000,"quantity":2}

o-1002 appears only on orders_out. o-1003 also appears on high_value_out with "tier":"high", and o-1004 with "tier":"vip".

Next: Branched Processing partitions this stream per customer.

Branched Processing

One external feed usually mixes many tenants, customers, or devices. Branches are how Nervix processes those groups independently: when a new group key appears, the branched part of the graph gets its own relay presence and processor state for that group, fully isolated from every other group. See Streams, Branch Keys, And Branches.

This step partitions orders per customer and deduplicates them inside each customer’s branch, so one customer’s duplicates can never suppress another customer’s orders.

Declare The Branch

A branch names its key schema and a TTL. When a branch instance goes idle for the TTL, its branch-local relay and processor state is dropped as a whole (TTL). In an unpaced domain the TTL uses wall clock time.

BEGIN;

CREATE SCHEMA customer_branch (
  customer STRING
);

CREATE BRANCH by_customer SCHEMA customer_branch TTL 5m;

CREATE RELAY orders_by_customer SCHEMA order_record BRANCHED BY by_customer;

CREATE RELAY orders_deduped SCHEMA order_record BRANCHED BY by_customer;

Construct The Branch Key

Only ingestors and reingestors construct branch keys. Since orders is already flowing, a reingestor — a branch-boundary node — re-partitions it without touching the existing graph:

CREATE REINGESTOR partition_orders
  FROM orders
  TO orders_by_customer
    INHERIT ALL
    BRANCHED BY by_customer SET customer = message.customer
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG;

BRANCHED BY by_customer SET customer = message.customer builds the concrete key from each record. The first order from a customer materializes that customer’s branch; every relay and processor downstream that declares BRANCHED BY by_customer runs per customer (Branch Semantics).

Deduplicate Per Branch

A deduplicator drops records whose key it has already seen within a time window. Declared BRANCHED BY by_customer, its history is branch-local:

CREATE DEDUPLICATOR dedupe_orders
  FROM orders_by_customer
  DEDUPLICATE ON input.order_id
  MAX TIME 5m
  BRANCHED BY by_customer
  TO orders_deduped
    INHERIT ALL
    FLUSH EACH 250ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG;

Emit The Deduplicated Stream

An emitter consumes every concrete branch and collapses branch identity at the external boundary (Branch Semantics) — the Redis channel receives all customers:

CREATE EMITTER redis_deduped
  FROM orders_deduped
  TO REDIS PUBSUB redis_local CHANNEL orders_deduped_out
    MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING order_codec
  INHERIT ALL
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

COMMIT;

Prove The Isolation

Subscribe to orders_deduped_out, then produce the same order_id interleaved across two customers:

docker exec redis redis-cli SUBSCRIBE orders_deduped_out
{"order_id":"o-2001","customer":"acme","status":"new","amount":900,"quantity":1}
{"order_id":"o-2001","customer":"globex","status":"new","amount":900,"quantity":1}
{"order_id":"o-2001","customer":"acme","status":"new","amount":900,"quantity":1}

The first two records pass — acme and globex each have their own deduplication history. The third is dropped as a duplicate inside the acme branch only. That is branch isolation: state, batching, and routing for one group never interfere with another. For sizing branched graphs, see Capacity Planning For Branched Graphs.

Next: Error Routes handles records that fail instead of dropping them.

Error Routes

So far every failure policy has been LOG. Nervix can instead capture a failing record as a structured error — with a stable reference, code, message, and the partially constructed output — and send it to a dead-letter relay for inspection or downstream handling. See Message Errors. The Errors And Diagnostics architecture chapter explains how a runtime failure becomes this structured error and how sensitive data stays out of it.

To provoke real failures, this step computes a per-unit price: amount / quantity fails when a record arrives with quantity of zero.

The Dead-Letter Model

Error handlers construct an ordinary record on an ordinary relay. Fields taken from partial_output must be OPTIONAL — the failed operation may not have produced them:

BEGIN;

CREATE SCHEMA priced_order (
  order_id STRING,
  customer STRING,
  amount I64,
  quantity I64,
  unit_price I64
);

CREATE SCHEMA order_error (
  error_reference STRING,
  error_code STRING,
  error_message STRING,
  source_order STRING,
  attempted_price I64 OPTIONAL
);

CREATE RELAY priced_orders SCHEMA priced_order UNBRANCHED;

CREATE RELAY order_errors SCHEMA order_error UNBRANCHED;

Send Failures To The Dead Letter

ON MESSAGE ERROR SEND TO replaces LOG on the route. The handler can read the error metadata (error.*), the original input (input.*), and the partial output of the failed construction (partial_output.*):

CREATE JUNCTION price_orders
  FROM orders
  UNBRANCHED
  TO priced_orders
    INHERIT ALL EXCEPT status
    SET unit_price = input.amount / input.quantity
    FLUSH IMMEDIATE
    ON MESSAGE ERROR SEND TO order_errors
      SET error_reference = error.reference,
          error_code = error.code,
          error_message = error.message,
          source_order = input.order_id,
          attempted_price = partial_output.unit_price;

COMMIT;

INHERIT ALL EXCEPT status inherits everything but the one field priced_order does not declare. Error routes preserve the branch in which the failure executed — this junction is unbranched, so order_errors is unbranched too. The node-level ON GENERAL ERROR policy exists only on ingestors and emitters (Runtime Node Error Policies).

Trigger A Failure

Watch the dead-letter relay with a session subscription:

nervix-cli --domain quickstart subscribe errors order_errors

Produce one healthy record and one with quantity of zero:

{"order_id":"o-3001","customer":"acme","status":"new","amount":900,"quantity":3}
{"order_id":"o-3002","customer":"acme","status":"new","amount":900,"quantity":0}

o-3001 flows to priced_orders with unit_price of 300. o-3002 fails the division and arrives on order_errors as a structured record: a stable error_reference, the machine-readable error_code, the human-readable error_message, and a null attempted_price — the division never produced one. Hot-path errors never leak sensitive payload values.

Next: add a second way into the graph in HTTP Ingestion.

HTTP Ingestion

Orders do not have to come through a broker. Nervix hosts its own inbound HTTP endpoints: you declare a virtual host and a path, attach an ingestor, and POST records straight at the server. See HTTP ENDPOINT And WEBSOCKETS ENDPOINT.

Inbound endpoints are served by the node itself on its HTTP listener — --http-listen-addr, 0.0.0.0:8080 by default (TLS-enabled vhosts use the separate HTTPS listener instead).

Declare The Endpoint

Three statements, in order: the VHOST names the hostnames served, the ENDPOINT binds a path on that vhost, and the ingestor consumes the endpoint. An HTTP ENDPOINT ingestor names its connector and entity in its head and reads only a TYPE HTTP endpoint. The existing order_codec decodes the same JSON as the Kafka path:

BEGIN;

CREATE RELAY orders_http SCHEMA order_record UNBRANCHED;

CREATE VHOST edge orders.example.com;

CREATE ENDPOINT orders_ingress
  ON edge
  PATH '/orders'
  TYPE HTTP;

CREATE HTTP ENDPOINT INGESTOR http_orders
  FROM orders_ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING order_codec
  TO orders_http
    INHERIT ALL
    UNBRANCHED
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

COMMIT;

MODE NO ACK is the only mode endpoint ingestors support — there is no broker to acknowledge to. With this schemaful codec each POST body is one record, while a JAQ-backed codec can unfold one body into several. To merge this stream with the Kafka-fed orders relay, a junction can consume both relays with FROM orders, orders_http.

POST An Order

Watch the relay first:

nervix-cli --domain quickstart subscribe http_watch orders_http

Then send a record. The vhost is matched against the request’s Host header (lowercased, port stripped), so tell curl which host you mean:

curl -i -X POST http://127.0.0.1:8080/orders \
  -H 'Host: orders.example.com' \
  -H 'Content-Type: application/json' \
  -d '{"order_id":"o-4001","customer":"acme","status":"new","amount":700,"quantity":7}'

The server answers 202 Accepted and the record appears in the subscription. Requests to an unknown host or path get 404, and anything other than POST gets 405.

One thing 202 does not mean: that the payload decoded. Dispatch is fire-and-forget — a body that fails the codec goes to the route’s ON MESSAGE ERROR policy, not back to the HTTP client. Verify through the subscription, not the status code.

Next: reshape foreign JSON at the boundary in JAQ Transformations.

JAQ Transformations

So far every payload matched the wire schema field for field. Real feeds rarely do. When a partner sends orders as a nested envelope, a JAQ codec reshapes the payload at the boundary instead of declaring a wire schema: the codec parses the native format and runs a jq-style program whose outputs must each be a JSON object matching the internal schema. The concise JAQ Reference links the complete, version-matched upstream manual.

Suppose the partner posts this envelope:

{"order":{"id":"o-5001","customer":"acme","state":"new"},"totals":{"amount":1200,"quantity":4}}

A JAQ Codec

A JAQ codec replaces the wire schema entirely — it goes straight from the format to the internal schema, with the program inline:

BEGIN;

CREATE CODEC partner_order_codec
  FROM JSON
  TO SCHEMA order_record
  WITH JAQ TRANSFORMATIONS ON INGESTION $jaq$
  {
    order_id: .order.id,
    customer: .order.customer,
    status: .order.state,
    amount: .totals.amount,
    quantity: .totals.quantity
  }
  $jaq$;

The explicit ON INGESTION direction means this codec decodes but cannot encode. A bidirectional codec adds ON EMITTING '<program>' after the ingestion program (JAQ Transformations). FROM accepts JSON, YAML, TOML, XML, and CBOR.

Two rules to remember: any field the program can produce as null must be OPTIONAL in the internal schema, and surplus keys in the program’s output are silently dropped.

Wire It To An Endpoint

Reuse the vhost from HTTP Ingestion with a second path:

CREATE RELAY partner_orders SCHEMA order_record UNBRANCHED;

CREATE ENDPOINT partner_ingress ON edge PATH '/partner-orders' TYPE HTTP;

CREATE HTTP ENDPOINT INGESTOR partner_order_source
  FROM partner_ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING partner_order_codec
  TO partner_orders
    INHERIT ALL
    UNBRANCHED
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

COMMIT;

Send The Envelope

nervix-cli --domain quickstart subscribe partner_watch partner_orders
curl -i -X POST http://127.0.0.1:8080/partner-orders \
  -H 'Host: orders.example.com' \
  -H 'Content-Type: application/json' \
  -d '{"order":{"id":"o-5001","customer":"acme","state":"new"},"totals":{"amount":1200,"quantity":4}}'

The subscription shows the flattened order_record — the envelope never enters the graph.

Unfold A Batch

A partner that sends several orders in one body needs no second endpoint. Every object the program yields becomes its own message, so a program that iterates an array unfolds one POST into one record per element:

CREATE CODEC partner_batch_codec
  FROM JSON
  TO SCHEMA order_record
  WITH JAQ TRANSFORMATIONS ON INGESTION $jaq$
  .orders[] | {
    order_id: .order.id,
    customer: .order.customer,
    status: .order.state,
    amount: .totals.amount,
    quantity: .totals.quantity
  }
  $jaq$;

An ingestor decoding with partner_batch_codec turns a body of {"orders":[...]} into one order_record per element, in array order. The body is still accepted or rejected as a whole: if one element does not fit the schema, no record from that body enters the graph. Unfolding Payloads covers acknowledgement, limits, and diagnostics.

Next: binary payloads in Protobuf Codecs.

Protobuf Codecs

Protobuf payloads are decoded from real .proto definitions. The definitions are uploaded as a versioned resource; the codec compiles them, decodes each payload into the message’s JSON view, and hands that to a JAQ program — the same bridge as the previous step.

The Proto File

Create proto/order_event.proto in your working directory, with field names matching order_record:

syntax = "proto3";

package quickstart;

message OrderEvent {
  string order_id = 1;
  string customer = 2;
  string status = 3;
  int64 amount = 4;
  int64 quantity = 5;
}

Upload It As A Resource

UPLOAD RESOURCE is a client-side statement — the CLI reads the directory from your machine and streams it to the cluster — so it cannot sit inside a transaction, and its path resolves relative to where nervix-cli runs:

BEGIN;
CREATE RESOURCE order_proto;
COMMIT;

UPLOAD RESOURCE order_proto VERSION './proto';

The client reports uploaded resource version 1. Uploads are immutable and versioned; see Versioning.

The Codec And Its Endpoint

The clause order is rigid: resource and its mandatory version, CONFIG (mandatory — file/include select and root the .proto files inside the resource), the fully-qualified MESSAGE name, then the target schema and JAQ program. VERSION 1 names the upload above; VERSION LATEST would bind the highest completed version when the statement is applied and store that number. The message’s JSON view keeps snake_case proto field names and numeric 64-bit integers, so a field-for-field message needs only the identity program '.':

BEGIN;

CREATE CODEC order_protobuf_codec
  FROM PROTOBUF
  USING RESOURCE order_proto VERSION 1
  CONFIG {'file' = 'order_event.proto', 'include' = '.'}
  MESSAGE 'quickstart.OrderEvent'
  TO SCHEMA order_record
  WITH JAQ TRANSFORMATIONS ON INGESTION '.';

CREATE RELAY proto_orders SCHEMA order_record UNBRANCHED;

CREATE ENDPOINT proto_ingress ON edge PATH '/proto-orders' TYPE HTTP;

CREATE HTTP ENDPOINT INGESTOR proto_order_source
  FROM proto_ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING order_protobuf_codec
  TO proto_orders
    INHERIT ALL
    UNBRANCHED
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

COMMIT;

Because it declares only ON INGESTION, this codec is decode-only; encoding to protobuf requires an ON EMITTING program. A DATETIME field would ride in a proto string plus ENCODE <field> AS RFC3339 on the codec (Codecs).

Send A Binary Order

Watch the relay:

nervix-cli --domain quickstart subscribe proto_watch proto_orders

protoc --encode turns protobuf text format into wire bytes, which curl posts as the raw body:

protoc --encode=quickstart.OrderEvent proto/order_event.proto <<'EOF' > order.bin
order_id: "o-6001"
customer: "acme"
status: "new"
amount: 4200
quantity: 6
EOF

curl -i -X POST http://127.0.0.1:8080/proto-orders \
  -H 'Host: orders.example.com' \
  -H 'Content-Type: application/octet-stream' \
  --data-binary @order.bin

The decoded record appears in the subscription — typed, with no JSON anywhere on the wire.

Next: extend the expression language itself in User-Defined Functions.

User-Defined Functions

The CASE expression in Conditional Routing works, but the banding logic is trapped inside one junction. A UDF packages it as a named function every node can call. UDFs are written in Roto, compiled to native code, and operate on whole Arrow columns at a time; the source is inline in the statement — there is no separate upload step. See Declaration.

Declare The Function

BEGIN;

CREATE UDF amount_band
  WITH ROTO_0_13
  ARGS (amount I64)
  RETURNS STRING
  CODE $roto$
    fn amount_band(amount: I64Column) -> StringColumn {
        when_s(amount.gt_s(9999), "vip")
            .when_s(amount.gt_s(999), "high")
            .otherwise_s("routine")
    }
  $roto$;

The declared signature must exactly match the Roto entry function — same name, matching column types (an I64 argument is an I64Column), no implicit casts. $roto$ ... $roto$ is a dollar-quoted string, so the body spans lines verbatim. The available column operations are cataloged in Roto Column Operations; UDFs are deterministic unless declared VOLATILE (Nulls, Errors, And Volatility).

Call It

Calls always use the udf:: qualifier and compose with builtins and route filters (Calling UDFs):

CREATE RELAY banded_orders SCHEMA order_tiered UNBRANCHED;

CREATE JUNCTION band_orders
  FROM orders
  UNBRANCHED
  TO banded_orders
    INHERIT ALL
    SET tier = udf::amount_band(input.amount)
    WHERE udf::amount_band(input.amount) != "routine"
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

COMMIT;

This reuses the order_tiered schema from Conditional Routing — the same enrichment, now expressed once and callable from any junction, filter, or route in the domain.

Watch It Classify

nervix-cli --domain quickstart subscribe band_watch banded_orders

Produce orders with amounts 500, 2500, and 25000 on the Kafka topic (the junction reads the Kafka-fed orders relay). The 500 order is filtered out by the route WHERE; the others arrive with "tier":"high" and "tier":"vip".

Lifecycle notes: changing the body means DROP UDF amount_band; and recreating it, and the drop is rejected while any node still references the function (Introspection And Lifecycle). For where UDFs sit between builtins and WASM processors, see Choosing An Extension Tier.

Next: aggregate the stream in Window Aggregates.

Window Aggregates

Individual orders are flowing; now summarize them. A window processor collects records into sliding windows and constructs aggregate output. Declared BRANCHED BY by_customer, it windows each customer independently — building directly on Branched Processing.

The Summary Model

Window routes are set-only: every output field is an aggregate expression, so the output relay needs its own summary schema (the order schema cannot be reused unless every field is aggregated):

BEGIN;

CREATE SCHEMA order_window (
  customer STRING,
  order_count I64,
  total_amount I64,
  max_amount I64,
  avg_amount F64
);

CREATE RELAY order_windows SCHEMA order_window BRANCHED BY by_customer;

The Window Processor

Windows use WIDTH and STEP instead of FLUSH. Each bound can count messages, elapse a duration, or combine both — a combined bound closes the window when either condition is met:

CREATE WINDOW PROCESSOR customer_order_window
  FROM orders_by_customer
  WIDTH 3 MESSAGES 30s DURATION
  STEP 3 MESSAGES 30s DURATION
  BRANCHED BY by_customer
  TO order_windows
    SET customer = FIRST(input.customer),
        order_count = COUNT(input.order_id),
        total_amount = SUM(input.amount),
        max_amount = MAX(input.amount),
        avg_amount = AVG(input.amount)
  ON MESSAGE ERROR LOG;

COMMIT;
  • The aggregate functions are COUNT, COUNT_IF, SUM, AVG, MIN, MAX, FIRST, LAST, ARG_MIN, ARG_MAX, BOOL_AND, BOOL_OR, VAR_POP, VAR_SAMP, STDDEV_POP, STDDEV_SAMP, COVAR_POP, COVAR_SAMP, CORR, and PERCENTILE_LINEAR_HISTOGRAM, and the approximate APPROX_COUNT_DISTINCT, APPROX_QUANTILE, and APPROX_TOP_K. Aggregate calls also participate in larger scalar expressions. See Window Aggregates for their types, null handling, numerical behavior, and the bounded state the approximate ones need.
  • A null argument contributes nothing to an aggregate. An aggregate that can be null, such as VAR_SAMP or any aggregate over an OPTIONAL field, needs an OPTIONAL output field or a COALESCE.
  • input.<field> is valid only inside aggregate arguments; a bare customer = input.customer is rejected. Aggregates cannot be nested.
  • WIDTH equal to STEP makes the windows tumbling (no overlap); a smaller STEP slides them. STEP may never exceed WIDTH.
  • Every required field of order_window must be assigned, and every SET target must exist in the schema.

Watch The Summaries

nervix-cli --domain quickstart subscribe window_watch order_windows

Produce three acme orders. When the third arrives, one summary record for the acme branch appears — order_count of 3, with the totals. Interleave globex orders and you will see that each customer’s window fills separately: the branch keeps window state per customer, exactly like the deduplicator’s history. If a window stays partially filled, the 30s duration bound closes it.

Duration bounds start from record timestamp watermarks and become due when a later watermark or the bound domain clock reaches the target. A paced domain’s TIME RATE changes the real wait for a partially filled window while preserving the records’ external event timestamps. The output keeps the window’s minimum low watermark and records the emission snapshot as its high watermark. See Ingestion Timestamps.

Next: produce records without any input at all in Generators.

Generators

Every node so far reacts to input. A generator is the opposite: it has no input relay and produces records on a timer, reading a snapshot of materialized relay state on every tick. The classic use is a per-key heartbeat or periodic re-publication of the latest known value.

Materialize The Relay

Materialized state keeps the latest full record per branch instance. It is a relay property, and an existing relay can adopt it with ALTER RELAY (Altering Relays):

BEGIN;

ALTER RELAY orders_by_customer SET MATERIALIZED STATE LAST BY TIMESTAMP;

LAST BY TIMESTAMP keeps the newest record per customer according to record timestamp watermarks — ingestion time works out of the box in an unpaced domain (Ingestion Timestamps).

The Generator

A generator declares exactly one materialized relay and reads it through the relay_state.<relay>.<field> namespace. Its routes are set-only and, unlike windows, do require a flush policy. Branch identity is node-wide: the generator, its materialized relay, and its output relay must share the exact branch.

CREATE SCHEMA order_heartbeat (
  customer STRING,
  last_order_id STRING,
  last_amount I64
);

CREATE RELAY order_heartbeats SCHEMA order_heartbeat BRANCHED BY by_customer;

CREATE GENERATOR order_heartbeat_source
  USING MATERIALIZED STATE orders_by_customer
  EACH 5s
  BRANCHED BY by_customer
  TO order_heartbeats
    SET customer = relay_state.orders_by_customer.customer,
        last_order_id = relay_state.orders_by_customer.order_id,
        last_amount = relay_state.orders_by_customer.amount
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

COMMIT;

When a customer’s branch first materializes, that branch gets its own generator task; every 5s tick reads an immutable snapshot of the customer’s latest order and constructs one heartbeat. Branch eviction (the branch TTL) stops the task and drops its state. In an unpaced domain the cadence follows wall clock time; paced domains tick on the domain clock (Domains And Time).

The first generator occurrence is immediate when the generator starts. Later occurrences stay on the original EACH schedule. Slow work coalesces missed ticks into one generation at the newest due occurrence, then continues at the first future boundary without a catch-up burst.

Watch It Tick

nervix-cli --domain quickstart subscribe heartbeat_watch order_heartbeats

Nothing appears yet — a branched generator is silent until a branch exists. Produce one acme order and heartbeats start arriving every five seconds, repeating the latest acme order. Produce a globex order and a second, independent heartbeat stream joins in. Send a newer acme order and the acme heartbeat switches to it on the next tick.

One lifecycle caveat: START clears materialized state for the domain, so after a restart the generators stay silent until new records materialize branches again (Materialized State).

Next: join two streams in Correlators.

Correlators

Orders are one stream; payments are another. A correlator joins two streams on a predicate, holding unmatched records in a bounded buffer until their partner arrives or a deadline passes. There is no ambiguous default input — a correlator has explicit LEFT and RIGHT sides, and every expression reads through the left. and right. scopes.

Branch identity is node-wide here too: the left relays, right relays, output relays, and timeout targets must all share the correlator’s exact branch. This one is fully unbranched, joining the Kafka-fed orders relay with a new payments feed.

The Payments Feed

BEGIN;

CREATE SCHEMA payment_record (
  order_id STRING,
  payment_id STRING,
  amount I64
);

CREATE WIRE JSON SCHEMA payment_wire MODE STRICT (
  order_id string,
  payment_id string,
  amount integer
);

CREATE CODEC payment_codec
  FROM WIRE JSON SCHEMA payment_wire
  TO SCHEMA payment_record;

CREATE RELAY payments SCHEMA payment_record UNBRANCHED;

CREATE ENDPOINT payment_ingress ON edge PATH '/payments' TYPE HTTP;

CREATE HTTP ENDPOINT INGESTOR payment_source
  FROM payment_ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING payment_codec
  TO payments
    INHERIT ALL
    UNBRANCHED
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The Correlator

Matched pairs become paid_orders records. Orders that see no payment within MAX TIME are forwarded to unpaid_orders by the left side’s timeout action; unmatched payments are dropped by the right side’s:

CREATE SCHEMA paid_order (
  order_id STRING,
  customer STRING,
  amount I64,
  payment_id STRING
);

CREATE RELAY paid_orders SCHEMA paid_order UNBRANCHED;

CREATE RELAY unpaid_orders SCHEMA order_record UNBRANCHED;

CREATE CORRELATOR correlate_payments
  LEFT FROM orders
  RIGHT FROM payments
  CORRELATE WHERE left.order_id = right.order_id
  MATCH EARLIEST
  MAX TIME 30s
  ON CORRELATION TIMEOUT SEND TO unpaid_orders, DROP
  UNBRANCHED
  TO paid_orders
    SET order_id = left.order_id,
        customer = left.customer,
        amount = left.amount,
        payment_id = right.payment_id
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

COMMIT;

Reading the clauses:

  • CORRELATE WHERE is a full boolean expression — equality is typical, but function calls and AND chains work (left.order_id = right.order_id AND left.amount = right.amount).
  • MATCH EARLIEST pairs an arriving record with the oldest pending candidate on the other side (LATEST takes the newest). Matching is one-to-one and consuming: the chosen pair leaves the buffers, and each record correlates at most once.
  • MAX TIME bounds how long an unmatched record waits — and therefore the correlator’s memory (Capacity Planning For Branched Graphs).
  • ON CORRELATION TIMEOUT <left-action>, <right-action> takes exactly two actions — DROP or SEND TO <relay>. A timeout forwards the original record unchanged, so the target relay’s schema must be compatible with that side’s schema — unpaid_orders reuses order_record.
  • Routes are set-only: INHERIT is rejected, and every required field of paid_order must be assigned from left., right., or output. values (Filters And Construction). In an ON MESSAGE ERROR SEND TO handler, a correlator exposes left and right instead of input (Message Errors).

Pay An Order

Watch both outcomes:

nervix-cli --domain quickstart subscribe paid_watch paid_orders
nervix-cli --domain quickstart subscribe unpaid_watch unpaid_orders

Produce an order on the Kafka topic, then pay it over HTTP:

{"order_id":"o-7001","customer":"acme","status":"new","amount":3000,"quantity":1}
curl -i -X POST http://127.0.0.1:8080/payments \
  -H 'Host: orders.example.com' \
  -H 'Content-Type: application/json' \
  -d '{"order_id":"o-7001","payment_id":"p-1","amount":3000}'

The joined record appears on paid_orders. Now produce order o-7002 and pay nothing: thirty seconds later the original order surfaces on unpaid_orders — the timeout path in action.

Next: change the running graph without tearing it down in Altering A Running Graph.

Altering A Running Graph

Nothing built so far required tearing anything down — every step added nodes. ALTER statements change what already exists: junction logic, relay properties, schemas, ingestor sources, emitter sinks, stateful processor behavior, reingestor wiring, and generator cadence or routes. Nervix classifies each validated change and pauses as little as possible — the statement result reports the executed level (ALTER Lock And Quiesce Classification):

  • DYNAMIC — hot-applied; nothing pauses (relay capacity, junction filters and route logic, deduplicator or reorderer MAX TIME, emitter flush policy)
  • ENTITY_PAUSE — only the affected node or relay is gated and drained (topology changes, deduplication keys, reorderer ordering, ingestor, reingestor, generator, and emitter reconfiguration, relay materialized state — the ALTER RELAY in Generators ran at this level)
  • DOMAIN_PAUSE — domain ingestion stops, in-flight work drains, the new graph is installed atomically, and flow resumes (schema and branching changes)

Before altering anything, SHOW CREATE <kind> <name>; prints the current canonical definition.

Change Routing Logic — Dynamic

Lower the high-value threshold from 1000 to 500. Route changes go through ALTER JUNCTION; REPLACE ROUTE requires the complete route body, not a diff:

ALTER JUNCTION route_orders
  REPLACE ROUTE TO high_value_orders
    INHERIT ALL
    SET tier = CASE
          WHEN input.amount >= 10000 THEN "vip"
          ELSE "high"
        END
    WHERE output.amount >= 500
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

The result reports quiesce level: DYNAMIC — the domain never paused, buffered work stayed in place, and the very next order is evaluated against the new threshold. Produce a 700-amount order and watch it reach high_value_out, which the old threshold would have dropped.

Relay capacity is equally uneventful (Altering Relays, capacity semantics under Capacity):

ALTER RELAY orders SET CAPACITY 8;

Evolve A Schema — Domain Pause

Payments gain a method. A codec requires its wire schema to exactly match the internal schema, so both must change in one transaction — the registry validates the complete candidate graph before anything applies (Altering Schemas):

BEGIN;

ALTER WIRE JSON SCHEMA payment_wire
  ADD FIELD method string OPTIONAL;

ALTER SCHEMA payment_record
  ADD FIELD method STRING OPTIONAL;

COMMIT;

This reports quiesce level: DOMAIN_PAUSE: ingestion stops, buffered output is force-flushed (no in-flight records are lost), the graph is swapped, and flow resumes — there is no user-facing PAUSE statement, and a drain timeout rolls the whole batch back (Automatic Model-Alteration Quiescing).

Add the field as OPTIONAL: old payloads without it keep decoding, and the field finalizes as a typed null. Verify by POSTing a payment with "method":"card" to /payments and watching the payments relay.

Two rules worth knowing before you lean on this:

  • One alteration at a time per domain — a concurrent mutation is rejected with already has a model alteration in progress, not queued.
  • DROP plus CREATE of the same entity in one batch is compared as one modification — you cannot sneak a schema change past domain quiescing by recreating the relay.

Ingestors and emitters have their own operation sets — Altering Ingestors (an ingestor’s ALTER repeats its connector and entity, such as ALTER KAFKA TOPIC INGESTOR kafka_orders, and every ingestor change is entity-pause) and Altering Emitters (flush changes are dynamic; sink, client, and codec changes are entity-pause; source-list membership changes are domain-pause). Processor operations are covered under Altering Deduplicators, Altering Reorderers, Altering Reingestors, and Altering Generators.

Next: run the graph on a logical clock in Paced Domains.

Paced Domains

The quickstart domain is unpaced: records are admitted as they arrive and every cadence follows wall clock time. A paced domain instead maintains a domain clock — records are admitted by their event timestamps, and every time-driven behavior (generator cadence, branch TTL, duration windows) follows the logical clock. Start the clock at a historical point with an accelerated TIME RATE and the same graph replays the past faster than real time. See Paced Domains.

This page builds a small replay domain next to quickstart.

A Second Domain

CREATE PACED DOMAIN quickstart_replay WITH PERIOD 200ms SKEW 100000h;

USE quickstart_replay;
  • PERIOD is the logical spacing between domain ticks.
  • SKEW is the admission window around each tick. It is deliberately huge here: the tutorial posts records with fixed historical timestamps, and they must fall inside a window anchored to the clock. Live feeds whose timestamps track the clock use small skews like 1s.

A Timestamped Stream

Paced ingestion needs an event time, so this schema finally carries a DATETIME — which is also the promised demonstration of the codec’s datetime encoding: the wire field is a string, and ENCODE ... AS RFC3339 bridges it (Codecs).

BEGIN;

CREATE SCHEMA order_event (
  order_id STRING,
  amount I64,
  occurred_at DATETIME
);

CREATE WIRE JSON SCHEMA order_event_wire MODE STRICT (
  order_id string,
  amount integer,
  occurred_at string
);

CREATE CODEC order_event_codec
  FROM WIRE JSON SCHEMA order_event_wire
  TO SCHEMA order_event
  ENCODE occurred_at AS RFC3339;

CREATE RELAY replay_orders
  SCHEMA order_event UNBRANCHED
  WITH MATERIALIZED STATE LAST BY TIMESTAMP;

CREATE VHOST replay_edge replay.example.com;

CREATE ENDPOINT replay_ingress ON replay_edge PATH '/events' TYPE HTTP;

CREATE HTTP ENDPOINT INGESTOR replay_source
  FROM replay_ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING order_event_codec
  TIMESTAMP AT occurred_at
  TO replay_orders
    INHERIT ALL
    UNBRANCHED
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

TIMESTAMP AT occurred_at is not optional style here: in a paced domain every ingestor must declare a timestamp source (TIMESTAMP NOW or TIMESTAMP AT <field>), or ingestion fails with requires ingestor ... to declare TIMESTAMP NOW or TIMESTAMP AT <field> (Ingestion Timestamps).

To make the clock visible, add a generator whose cadence is declared in logical seconds:

CREATE SCHEMA replay_heartbeat (
  order_id STRING,
  amount I64
);

CREATE RELAY replay_heartbeats SCHEMA replay_heartbeat UNBRANCHED;

CREATE GENERATOR replay_ticker
  USING MATERIALIZED STATE replay_orders
  EACH 10s
  UNBRANCHED
  TO replay_heartbeats
    SET order_id = relay_state.replay_orders.order_id,
        amount = relay_state.replay_orders.amount
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

COMMIT;

Start The Clock

A stopped paced domain has no ticks, so nothing is admitted — try POSTing now and the request fails. Then start the clock at a historical instant, running ten times faster than wall clock (Start And Stop):

START AT '2026-01-01T00:00:00Z' TIME RATE 10.0;

START; alone would resume from persisted state, and START AT NOW re-anchors to the current wall clock; TIME RATE defaults to 1.0.

Replay The Past

nervix-cli --domain quickstart_replay subscribe replay_watch replay_heartbeats
curl -i -X POST http://127.0.0.1:8080/events \
  -H 'Host: replay.example.com' \
  -H 'Content-Type: application/json' \
  -d '{"order_id":"o-8001","amount":1500,"occurred_at":"2026-01-01T00:00:05Z"}'

The record is admitted against its January event time, and heartbeats begin — declared EACH 10s, they arrive roughly every second of wall clock, because ten logical seconds pass per wall second at TIME RATE 10.0. Branch TTLs and duration windows in this domain would follow the same logical clock, which is what makes accelerated replay reproducible; deterministic UDFs preserve that reproducibility too (Domains And Time, UDF determinism under Nulls, Errors, And Volatility).

A record whose timestamp falls outside every reached logical tick window is rejected at ingestion — that is SKEW doing its job. And as everywhere, START clears materialized relay state, so a fresh replay starts from an empty snapshot surface.

Where To Go Next

The tutorial graph now ingests from Kafka, HTTP, JSON envelopes, and protobuf; routes conditionally; partitions and deduplicates per customer; dead-letters failures; classifies with a custom function; aggregates windows; emits heartbeats from materialized state; joins payments to orders; evolves live; and replays history on a logical clock. The manual covers what remains:

Installation

Nervix does not yet publish stable binary or container-image releases. Choose the installation method for your environment:

  • Cargo Install From GitHub builds and installs nervix-server and nervix-cli for local or non-Kubernetes use.
  • Docker runs a three-node cluster with Docker commands or Docker Compose.
  • Kubernetes Operator installs the operator with Helm and manages persistent Nervix clusters through NervixCluster resources.

Pin a reviewed Git commit or container-image digest when you need a reproducible installation.

Cargo Install From GitHub

Install the Nervix server, the interactive CLI, and the NSPL formatter directly from the Nervix GitHub repository:

cargo install --locked \
  --git https://github.com/nervix-io/nervix.git \
  nervix-server nervix-cli nervix-nspl-format

This builds the binaries from source and installs them into Cargo’s binary directory, which is $HOME/.cargo/bin by default. Ensure that directory is on your PATH.

The build requires Rust 1.99 or newer and the native build tools required by Nervix’s dependencies. The initial build can take several minutes.

Confirm that the commands are available:

nervix-server --help
nervix-cli --help
nervix-nspl-format --help

Pin A Git Revision

The unpinned command installs the current default branch. For a reproducible installation, select a reviewed commit:

cargo install --locked \
  --git https://github.com/nervix-io/nervix.git \
  --rev <commit-sha> \
  nervix-server nervix-cli nervix-nspl-format

Run the same command with a different revision to upgrade. Add --force if Cargo reports that the selected packages are already installed.

To remove both binaries:

cargo uninstall nervix-server nervix-cli nervix-nspl-format

Docker

Nervix publishes a rolling multi-architecture Debian image to ghcr.io/nervix-io/nervix:debian-latest. The image contains nervix-server, nervix-cli, and nervix-nspl-format. The examples on this page create a three-node cluster for local evaluation.

For a reproducible or production deployment, replace the rolling tag with a reviewed timestamped tag or image digest. Use TLS, a secret manager, and a client-reachable advertise hostname instead of the local-development defaults shown here.

CUDA Images

Build a local CUDA image from a repository checkout with just docker-build-cuda; the build requirements are in Developing Nervix. The image uses the cuda target in Dockerfile.debian; the CPU image uses the cpu target. Both copy the same Nervix binaries from one shared builder stage. The CUDA image contains ONNX Runtime 1.30.0’s CUDA 13 provider, CUDA 13.2.2, and cuDNN 9.25.1. It supports Linux AMD64 and pins the Debian base to debian:trixie-20260918-slim and its image digest. CUDA and cuDNN occupy one layer directly above that base. The next layer updates and upgrades Debian packages while holding CUDA and cuDNN at their pinned versions. Nervix’s layers follow and reuse that CUDA layer across application builds.

To expose an NVIDIA GPU, install a host driver supporting CUDA 13.2 and the NVIDIA Container Toolkit, set NERVIX_IMAGE=nervix:cuda, and add --gpus all to the docker run commands below. The container toolkit supplies the host driver; CUDA and cuDNN are supplied by the image.

Run Three Nodes With Docker

Choose the image and initial password. The password environment variable is expanded by the shell and passed only to the bootstrap node, which creates the default user with it. The other nodes need no password: they read that user from the cluster, and a node that stops gracefully asks the leader to drain it under its node certificate.

export NERVIX_IMAGE=ghcr.io/nervix-io/nervix:debian-latest
export NERVIX_INIT_DEFAULT_USER_PASSWORD='replace-with-a-password'

docker network create nervix
docker pull "$NERVIX_IMAGE"

Provision a CA and one certificate/key pair per node under ./tls before starting the containers:

tls/ca.pem
tls/node-1.pem
tls/node-1-key.pem
tls/node-2.pem
tls/node-2-key.pem
tls/node-3.pem
tls/node-3-key.pem

Each node certificate must permit TLS server and client authentication. It must contain its Docker hostname (nervix-1, nervix-2, or nervix-3) as a DNS SAN and exactly one identity URI matching its configured cluster and node, such as nervix://cluster/nervix-docker/node/node-1. Sign all three certificates with the CA in ca.pem.

Start the bootstrap node:

docker run --detach \
  --name nervix-1 \
  --hostname nervix-1 \
  --network nervix \
  --restart unless-stopped \
  --stop-timeout 60 \
  --volume nervix-node-1-data:/var/lib/nervix \
  --volume "$PWD/tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro" \
  --volume "$PWD/tls/node-1.pem:/etc/nervix/interconnect/node.pem:ro" \
  --volume "$PWD/tls/node-1-key.pem:/etc/nervix/interconnect/node-key.pem:ro" \
  --publish 47391:47391 \
  --publish 47421:47420 \
  --publish 9091:9090 \
  --env NERVIX_ADDR=0.0.0.0:47391 \
  --env NERVIX_GRPC_ADVERTISE_ADDR=127.0.0.1:47391 \
  --env NERVIX_WEB_CONSOLE_LISTEN_ADDR=0.0.0.0:47420 \
  --env NERVIX_WEB_CONSOLE_ADVERTISE_ADDR=127.0.0.1:47421 \
  --env NERVIX_OBSERVABILITY_LISTEN_ADDR=0.0.0.0:9090 \
  --env NERVIX_CLUSTER_ID=nervix-docker \
  --env NERVIX_NODE_ID=node-1 \
  --env NERVIX_INTERCONNECT_LISTEN_ADDR=0.0.0.0:47395 \
  --env NERVIX_INTERCONNECT_ADVERTISE_ADDR=nervix-1:47395 \
  --env NERVIX_INTERCONNECT_TLS_CA=/etc/nervix/interconnect/ca.pem \
  --env NERVIX_INTERCONNECT_TLS_CERT=/etc/nervix/interconnect/node.pem \
  --env NERVIX_INTERCONNECT_TLS_KEY=/etc/nervix/interconnect/node-key.pem \
  --env NERVIX_REPLICA_COUNT=3 \
  --env NERVIX_ALLOW_BOOTSTRAP=true \
  --env NERVIX_INIT_DEFAULT_USER_PASSWORD \
  "$NERVIX_IMAGE"

Start the other two nodes. Both discover the cluster through nervix-1 on the private Docker network:

Keep each node’s named data volume across restarts. Once the three nodes have joined, their Raft membership retains the advertised interconnect names. If nervix-1 stops while the other nodes continue, it uses those names to find a survivor when it restarts; it does not need a second configured bootstrap host. The joining nodes still use NERVIX_CLUSTER_BOOTSTRAP_HOST for their first contact with a fresh cluster. Once they have recovered Raft peer endpoints from their volumes, they can restart while that bootstrap host does not resolve and rejoin through another peer whose name resolves. Keep the other members’ advertised names resolvable to the restarting node.

docker run --detach \
  --name nervix-2 \
  --hostname nervix-2 \
  --network nervix \
  --restart unless-stopped \
  --stop-timeout 60 \
  --volume nervix-node-2-data:/var/lib/nervix \
  --volume "$PWD/tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro" \
  --volume "$PWD/tls/node-2.pem:/etc/nervix/interconnect/node.pem:ro" \
  --volume "$PWD/tls/node-2-key.pem:/etc/nervix/interconnect/node-key.pem:ro" \
  --publish 47392:47391 \
  --publish 47422:47420 \
  --publish 9092:9090 \
  --env NERVIX_ADDR=0.0.0.0:47391 \
  --env NERVIX_GRPC_ADVERTISE_ADDR=127.0.0.1:47392 \
  --env NERVIX_WEB_CONSOLE_LISTEN_ADDR=0.0.0.0:47420 \
  --env NERVIX_WEB_CONSOLE_ADVERTISE_ADDR=127.0.0.1:47422 \
  --env NERVIX_OBSERVABILITY_LISTEN_ADDR=0.0.0.0:9090 \
  --env NERVIX_CLUSTER_ID=nervix-docker \
  --env NERVIX_NODE_ID=node-2 \
  --env NERVIX_INTERCONNECT_LISTEN_ADDR=0.0.0.0:47395 \
  --env NERVIX_INTERCONNECT_ADVERTISE_ADDR=nervix-2:47395 \
  --env NERVIX_INTERCONNECT_TLS_CA=/etc/nervix/interconnect/ca.pem \
  --env NERVIX_INTERCONNECT_TLS_CERT=/etc/nervix/interconnect/node.pem \
  --env NERVIX_INTERCONNECT_TLS_KEY=/etc/nervix/interconnect/node-key.pem \
  --env NERVIX_REPLICA_COUNT=3 \
  --env NERVIX_CLUSTER_BOOTSTRAP_HOST=nervix-1:47395 \
  "$NERVIX_IMAGE"

docker run --detach \
  --name nervix-3 \
  --hostname nervix-3 \
  --network nervix \
  --restart unless-stopped \
  --stop-timeout 60 \
  --volume nervix-node-3-data:/var/lib/nervix \
  --volume "$PWD/tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro" \
  --volume "$PWD/tls/node-3.pem:/etc/nervix/interconnect/node.pem:ro" \
  --volume "$PWD/tls/node-3-key.pem:/etc/nervix/interconnect/node-key.pem:ro" \
  --publish 47393:47391 \
  --publish 47423:47420 \
  --publish 9093:9090 \
  --env NERVIX_ADDR=0.0.0.0:47391 \
  --env NERVIX_GRPC_ADVERTISE_ADDR=127.0.0.1:47393 \
  --env NERVIX_WEB_CONSOLE_LISTEN_ADDR=0.0.0.0:47420 \
  --env NERVIX_WEB_CONSOLE_ADVERTISE_ADDR=127.0.0.1:47423 \
  --env NERVIX_OBSERVABILITY_LISTEN_ADDR=0.0.0.0:9090 \
  --env NERVIX_CLUSTER_ID=nervix-docker \
  --env NERVIX_NODE_ID=node-3 \
  --env NERVIX_INTERCONNECT_LISTEN_ADDR=0.0.0.0:47395 \
  --env NERVIX_INTERCONNECT_ADVERTISE_ADDR=nervix-3:47395 \
  --env NERVIX_INTERCONNECT_TLS_CA=/etc/nervix/interconnect/ca.pem \
  --env NERVIX_INTERCONNECT_TLS_CERT=/etc/nervix/interconnect/node.pem \
  --env NERVIX_INTERCONNECT_TLS_KEY=/etc/nervix/interconnect/node-key.pem \
  --env NERVIX_REPLICA_COUNT=3 \
  --env NERVIX_CLUSTER_BOOTSTRAP_HOST=nervix-1:47395 \
  "$NERVIX_IMAGE"

The host ports are:

NodegRPCWeb consoleHealth and metrics
node-147391474219091
node-247392474229092
node-347393474239093

Connect an installed CLI to http://127.0.0.1:47391, or open the web console at http://127.0.0.1:47421/console/, and run:

SHOW CLUSTER STATUS;

The cluster status should show one local node, two live peer nodes, and all three nodes in the Raft membership. The CLI follows leader redirects through the three published gRPC ports.

Each node advertises its container name, such as nervix-1:47395, and finds its peers by those names through Docker’s embedded DNS, which Docker names in the container’s /etc/resolv.conf. A node resolves peer names, and the hosts its connectors reach, with its own asynchronous resolver from that file and /etc/hosts, read once at startup; Name Resolution describes the options that point it at other files or name servers, and what it does not support.

docker stop sends each node SIGTERM, which starts graceful shutdown, and kills a container only if its node is still running when the stop timeout ends. A node ends its own shutdown within its shutdown timeout, 50 seconds by default, which covers the 30-second default drain timeout and the services that stop after it. The --stop-timeout 60 above keeps Docker waiting longer than that, so a node that cannot finish shutting down exits with status 1 on its own rather than being killed; with Docker’s default of ten seconds, a node could be killed partway through its drain. Shutdown And Recovery explains what a node loses when its grace period ends first. Stop the nodes, then remove the containers and private network:

docker stop nervix-1 nervix-2 nervix-3
docker rm nervix-1 nervix-2 nervix-3
docker network rm nervix

The named volumes remain so the cluster can be recreated with its state intact. Removing nervix-node-1-data, nervix-node-2-data, and nervix-node-3-data permanently deletes that state.

Run Three Nodes With Docker Compose

Save the following as docker-compose.yml:

x-nervix-common: &nervix-common
  image: ${NERVIX_IMAGE:-ghcr.io/nervix-io/nervix:debian-latest}
  restart: unless-stopped
  stop_grace_period: 60s
  environment: &nervix-environment
    NERVIX_ADDR: 0.0.0.0:47391
    NERVIX_WEB_CONSOLE_LISTEN_ADDR: 0.0.0.0:47420
    NERVIX_OBSERVABILITY_LISTEN_ADDR: 0.0.0.0:9090
    NERVIX_CLUSTER_ID: nervix-docker
    NERVIX_INTERCONNECT_LISTEN_ADDR: 0.0.0.0:47395
    NERVIX_INTERCONNECT_TLS_CA: /etc/nervix/interconnect/ca.pem
    NERVIX_INTERCONNECT_TLS_CERT: /etc/nervix/interconnect/node.pem
    NERVIX_INTERCONNECT_TLS_KEY: /etc/nervix/interconnect/node-key.pem
    NERVIX_REPLICA_COUNT: "3"
  networks:
    - nervix

services:
  nervix-1:
    <<: *nervix-common
    hostname: nervix-1
    environment:
      <<: *nervix-environment
      NERVIX_NODE_ID: node-1
      NERVIX_GRPC_ADVERTISE_ADDR: 127.0.0.1:47391
      NERVIX_WEB_CONSOLE_ADVERTISE_ADDR: 127.0.0.1:47421
      NERVIX_INTERCONNECT_ADVERTISE_ADDR: nervix-1:47395
      NERVIX_ALLOW_BOOTSTRAP: "true"
      NERVIX_INIT_DEFAULT_USER_PASSWORD: ${NERVIX_INIT_DEFAULT_USER_PASSWORD:?set NERVIX_INIT_DEFAULT_USER_PASSWORD}
    ports:
      - "47391:47391"
      - "47421:47420"
      - "9091:9090"
    volumes:
      - nervix-node-1-data:/var/lib/nervix
      - ./tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro
      - ./tls/node-1.pem:/etc/nervix/interconnect/node.pem:ro
      - ./tls/node-1-key.pem:/etc/nervix/interconnect/node-key.pem:ro

  nervix-2:
    <<: *nervix-common
    hostname: nervix-2
    depends_on:
      - nervix-1
    environment:
      <<: *nervix-environment
      NERVIX_NODE_ID: node-2
      NERVIX_GRPC_ADVERTISE_ADDR: 127.0.0.1:47392
      NERVIX_WEB_CONSOLE_ADVERTISE_ADDR: 127.0.0.1:47422
      NERVIX_INTERCONNECT_ADVERTISE_ADDR: nervix-2:47395
      NERVIX_CLUSTER_BOOTSTRAP_HOST: nervix-1:47395
    ports:
      - "47392:47391"
      - "47422:47420"
      - "9092:9090"
    volumes:
      - nervix-node-2-data:/var/lib/nervix
      - ./tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro
      - ./tls/node-2.pem:/etc/nervix/interconnect/node.pem:ro
      - ./tls/node-2-key.pem:/etc/nervix/interconnect/node-key.pem:ro

  nervix-3:
    <<: *nervix-common
    hostname: nervix-3
    depends_on:
      - nervix-1
    environment:
      <<: *nervix-environment
      NERVIX_NODE_ID: node-3
      NERVIX_GRPC_ADVERTISE_ADDR: 127.0.0.1:47393
      NERVIX_WEB_CONSOLE_ADVERTISE_ADDR: 127.0.0.1:47423
      NERVIX_INTERCONNECT_ADVERTISE_ADDR: nervix-3:47395
      NERVIX_CLUSTER_BOOTSTRAP_HOST: nervix-1:47395
    ports:
      - "47393:47391"
      - "47423:47420"
      - "9093:9090"
    volumes:
      - nervix-node-3-data:/var/lib/nervix
      - ./tls/ca.pem:/etc/nervix/interconnect/ca.pem:ro
      - ./tls/node-3.pem:/etc/nervix/interconnect/node.pem:ro
      - ./tls/node-3-key.pem:/etc/nervix/interconnect/node-key.pem:ro

networks:
  nervix:

volumes:
  nervix-node-1-data:
  nervix-node-2-data:
  nervix-node-3-data:

Set the required initial password and start the cluster from the directory containing that file:

export NERVIX_INIT_DEFAULT_USER_PASSWORD='replace-with-a-password'
docker compose up --detach

Set NERVIX_IMAGE before docker compose up to use a pinned tag or digest instead of debian-latest.

Inspect the containers and follow their logs:

docker compose ps
docker compose logs --follow

Use the same host ports in the table above. Stop the cluster while preserving its volumes:

docker compose down

docker compose down sends each node SIGTERM and waits up to the file’s stop_grace_period of 60 seconds, longer than each node’s 50-second shutdown timeout, for graceful shutdown to finish before it kills a container. To permanently delete all three nodes’ persisted state, add --volumes.

Kubernetes Operator

The Nervix Kubernetes Operator watches namespaced NervixCluster resources across a Kubernetes cluster. For each resource, it creates the services, StatefulSet, and one persistent volume claim per Nervix node. It also coordinates one-time initialization of the default user before scaling the StatefulSet to the requested replica count.

Prerequisites

Before installing the operator, ensure that:

  • kubectl and Helm 3 can reach the Kubernetes cluster.
  • The cluster has a default StorageClass that can provision persistent volumes.
  • Your Kubernetes identity can create custom resource definitions and cluster-wide RBAC resources.
  • Cluster nodes can pull images from ghcr.io.

Install The Operator With Helm

The Helm chart is currently distributed in the operator repository rather than as a versioned chart release. Clone the repository and install its bundled chart:

git clone --depth 1 https://github.com/nervix-io/nervix-k8s-operator.git

helm upgrade --install nervix-k8s-operator \
  ./nervix-k8s-operator/charts/nervix-k8s-operator \
  --namespace nervix-system \
  --create-namespace

Wait for the operator to become available:

kubectl --namespace nervix-system rollout status \
  deployment/nervix-k8s-operator

The chart installs the NervixCluster custom resource definition and deploys one operator that can manage Nervix clusters in any namespace.

Create The Initial User Secret

Create a namespace and a Secret containing the initial password for the default user. Use a password file that contains the exact password without a trailing newline, and do not commit that file:

kubectl create namespace nervix

kubectl --namespace nervix create secret generic nervix-initial-password \
  --from-file=password=/secure/path/to/password

The operator exposes this credential only to the first bootstrap pod. After authentication proves that Nervix committed the user, the operator restarts that pod without the credential and then scales the cluster to the requested replica count.

Create A Nervix Cluster

Save the following resource as nervix-cluster.yaml:

apiVersion: nervix.io/v1alpha1
kind: NervixCluster
metadata:
  name: nervix
  namespace: nervix
spec:
  image: ghcr.io/nervix-io/nervix:debian-latest
  replicas: 3
  clusterId: nervix-kube
  initialDefaultUserPasswordSecretRef:
    name: nervix-initial-password
    key: password
  storage: 5Gi

Apply it and watch the cluster become ready:

kubectl apply --filename nervix-cluster.yaml
kubectl --namespace nervix get nervixcluster nervix --watch

The READY count should eventually match REPLICAS. Inspect the objects owned by the operator with:

kubectl --namespace nervix get pods,services,persistentvolumeclaims

One operator can manage multiple Nervix clusters. Create each cluster as a separate NervixCluster, usually in a separate namespace or with a distinct name in the same namespace.

Enable Local Access

For development clusters that need direct access from outside Kubernetes, add this to the NervixCluster spec:

  localAccess:
    enabled: true

Apply the updated resource:

kubectl apply --filename nervix-cluster.yaml

The default NodePorts are 31390 for the gRPC entry point, 31420 for the web console, and 31090 for health and metrics. The operator also creates per-node NodePorts so clients redirected to the leader can reach it.

Connect the CLI to the gRPC entry point:

nervix-cli --server http://<node-address>:31390

The web console is available at http://<node-address>:31420/console/. Omit localAccess for an in-cluster-only deployment.

Shutdown And Grace Periods

Deleting a pod, draining a Kubernetes node, or rolling the StatefulSet sends SIGTERM, which starts graceful shutdown. A Nervix node ends its own shutdown within its shutdown timeout, 50 seconds by default, which covers the 30-second default drain timeout and the services that stop after it. The operator sets terminationGracePeriodSeconds: 60 so the kubelet waits longer than that, and a node that cannot finish exits on its own rather than being killed partway through its drain. If you raise NERVIX_SHUTDOWN_TIMEOUT, raise the grace period with it.

Note that /readyz reports whether the node currently knows a Raft leader; it does not turn negative when a node begins terminating. A draining node is therefore not removed from Service endpoints by readiness alone. It stops accepting new client connections itself in the first shutdown phase, and it keeps serving already-admitted work until its drain finishes.

A terminating node moves its scheduled work to a live peer when one is available, and otherwise completes everything it has admitted in place. Rolling one pod at a time keeps a peer available to receive that work, so ownership moves instead of waiting for the pod to come back. See Shutdown And Recovery for the phases, the drain guarantees, and what each ending preserves.

Production Considerations

Both the chart and the example currently default to rolling latest image tags. Pin reviewed operator and Nervix image tags or digests before using this setup in production. Also choose storage, resource requests, NodePort exposure, and Secret management appropriate for the cluster.

Client Tools

Nervix ships two interactive clients, and both speak the same language to the same place: they open a session against the cluster’s session API and submit NSPL to the leader. Almost every statement in this book can be typed into either one, and neither is required — a Nervix cluster is administered entirely through NSPL, whatever submits it.

Choosing A Client

ClientWhere it runsUse it for
Command Line Clientyour terminal, installed separatelyinteractive work, scripting, shell completions, streaming a relay to stdout, node administration
Web Consoleyour browser, served by every nodereading a running graph, exploring an unfamiliar domain, guided subscriptions, uploading resources, taking and restoring backups
Rust Client Libraryyour own programembedding Nervix control and subscriptions in a Rust application

One further tool is not a client at all: nervix-nspl-format formats saved .nspl files and checks that they are formatted. It never opens a session.

The console is the better tool for understanding a graph you did not write, because it draws one. The command line client is the better tool for repeating yourself, because it can be scripted.

Only a few things differ. Uploading a resource version, and restoring a backup archive, name a local path in the command line client and use a file picker in the console; a backup’s archive is written to a local file by the command line client and saved as a browser download by the console, and only the command line client reads an archive with DESCRIBE BACKUP; the command line client follows a domain clock from its domain-clock subcommand or with ATTACH DOMAIN CLOCK, while the console follows the clock of the domain it has selected and shows it in its clock panel; the console additionally offers guided actions — clicking a graph item or an entity — that simply type NSPL for you.

What Every Client Shares

  • The same statement surface. Domains, schemas, codecs, relays, clients, runtime nodes, and emitters are all declared in NSPL (NSPL Overview).
  • Session and transaction statements. USE, LIST DOMAINS, UPLOAD RESOURCE, CREATE SUBSCRIPTION, DELETE SUBSCRIPTION, ATTACH DOMAIN CLOCK, and DETACH DOMAIN CLOCK are session/client operations. BEGIN, COMMIT, and REVERT operate on a leader-owned, replicated transaction.
  • Server-driven completion. Suggestions come from the cluster, not from a local copy of the grammar, so they include the identifiers that actually exist in the active domain. Inside a transaction they also include the identifiers its queued statements define.
  • HTTP Basic authentication against a registry user.
  • Automatic leader redirects. A session opened against a follower is redirected to the current leader; you do not need to know which node leads.
  • Automatic transaction attach. Both interactive clients retain the transaction id and attach it before replaying a command after reconnect or leader failover, and do not repeat an operation whose replicated progress already advanced.

Sessions, subscriptions, sampling, backpressure, and domain clock attachment are described in Sessions.

To get a cluster running first, see Installation and Running Nervix.

Command Line Client

nervix-cli is the interactive NSPL client. It is installed alongside nervix-server (Cargo Install From GitHub) and is also present in the container image (Docker).

Connecting

With no arguments the client connects to http://127.0.0.1:47391 as user default and prompts for the password:

nervix-cli
nervix-cli connected to http://127.0.0.1:47391
Type 'exit' to quit. Trailing ';' is optional.
[events] notifications are printed above the prompt

nervix[default]>

Point it at another cluster with --server, which takes a full URL. An https:// URL enables TLS:

nervix-cli --server https://nervix.example.com:47390 --tls required --tls-ca-cert ./ca.pem

Options

OptionEnvironment variableDefaultMeaning
--server <URL>http://127.0.0.1:47391session gRPC endpoint; the scheme selects TLS
--tls <preferred|required>preferredrequired refuses to connect over a non-https URL
--tls-ca-cert <PATH>PEM certificate authority used to verify the server
--dns-resolver-config <PATH>NERVIX_DNS_RESOLVER_CONFIG/etc/resolv.confresolver configuration for native hostname connections
--dns-hosts-file <PATH>NERVIX_DNS_HOSTS_FILE/etc/hostshosts file consulted before DNS
--dns-name-server <ADDRESS:PORT>NERVIX_DNS_NAME_SERVERSfrom resolver configurationreplacement name server; repeatable or comma-separated
--domain <NAME>defaultdomain the session starts in
--username <NAME>NERVIX_USERNAMEdefaultregistry user
--password <PASSWORD>NERVIX_PASSWORDprompted interactively when unset
--command <NSPL>run statements once and exit

There is no general CLI configuration file and no --version flag. --server, --domain, and the TLS options have no environment-variable equivalents. The DNS options select the resolver used by the native session; the system resolver and hosts files are the defaults, and Name Resolution describes how that resolver answers. The password can come from NERVIX_PASSWORD so it need not appear in shell history.

The table above is the short version. nervix-cli Reference is printed by the binary itself while this book is built, so it is the authoritative list of every option and subcommand for this release.

Modes

The client runs in exactly one of three modes, in this order of precedence:

  1. A subcommand, such as subscribe, domain-clock, backup, restore, or drain-node.
  2. --command, which submits NSPL, prints the result, and exits.
  3. The interactive REPL, when neither of the above is given.

There is no file-execution mode. To run a saved script, pass it through --command; to format one, use nervix-nspl-format:

nervix-cli --domain quickstart --command "$(cat pipeline.nspl)"

The Interactive REPL

Prompt And Multi-Line Input

The prompt shows the active domain, and continuation lines are marked with dots:

nervix[quickstart]> CREATE SCHEMA order_record (
....[quickstart]>   order_id STRING,
....[quickstart]>   amount I64
....[quickstart]> );

Input accumulates until the buffered statements parse or the line ends with ;, so a trailing semicolon is optional on a complete statement and a partial one simply keeps buffering. While a transaction is open the prompt says so:

nervix[quickstart tx]>

After COMMIT is accepted, an in-progress commit is shown as nervix[quickstart committing]>. The prompt is driven by replicated transaction status rather than a local boolean.

Completion

Tab completes. Suggestions are computed by the server for the exact cursor position, so they cover grammar keywords and the identifiers that actually exist: models in the active domain, resource names and versions, session subscription names, and domain names. Inside a quoted path that names a file or directory on this machine, such as UPLOAD RESOURCE ... VERSION '<path>', BACKUP ... TO '<path>', DESCRIBE BACKUP '<path>', and RESTORE ... FROM '<path>', the client completes local filesystem paths instead, expanding ~ to your home directory.

The replacement covers the current word even when the cursor is inside it, and keeps the text after that word. The CLI reads successive bounded pages when the server has more matches. A missing or stale domain or transaction context does not produce candidates from a different configuration.

Scripts can request the same candidates with --suggest 'DROP SCHEMA ord' --cursor 15. The cursor is a UTF-8 byte offset; omitting it selects the end of the input. The CLI prints JSON containing a status and each candidate’s display value, kind, and exact text edit.

While a transaction is open, completion describes the configuration that transaction is building: models and resources its queued statements create are suggested before COMMIT, and a model whose DROP is queued stops being suggested until a later statement recreates it. Only the session bound to the transaction sees them; every other session is offered committed configuration alone. A queued resource has no versions to suggest, because UPLOAD RESOURCE is not transaction content.

History

Submitted lines are stored in .nervix_client_history, relative to the directory the client was started in, capped at 200 entries. ArrowUp and ArrowDown walk it.

Diagnostics

Server-reported errors are rendered as annotated reports against the text you submitted, with the offending span highlighted in place rather than described by offset.

Asynchronous Output

Subscription deliveries, server notifications, and state changes and ticks of an attached clock arrive independently of the prompt and are printed above it:

[events] subscription [watch] from [orders]: {"order_id":"o-1001","amount":1500}
[events] server ERROR: emitter 'redis_orders' publish failed
[events] topology INFO: raft transition: node-2 became leader
[events] domain clock [simulation]: generation 2, stopped
[events] domain clock [simulation] tick: generation 3, id 12, boundary 2030-01-01T00:00:01.100Z, authority UTC 2026-09-27T00:00:00Z, node logical 2030-01-01T00:00:01.120Z

A domain clock line follows each newer state or accepted tick after the attach reply. A state or tick that changes again before it is delivered or read is replaced by the newest one, so a slow client can skip states and tick ids. Between prompts the REPL holds at most 128 event lines, each cut at 8 KiB with … [line truncated]; lines that arrive while it is full are dropped, and [events] notice: <n> event lines were omitted because terminal output could not keep up reports how many. When the server ends an attachment, or the session holding it is interrupted and the clock is attached again on the next session, the line reads [events] domain clock [<domain>] notice: ... with the reason.

The REPL prints these lines when it draws its next prompt: after the statement that is running finishes, or when you press Enter on an empty line.

Output keeps printing across reconnects. When the session is lost, the next statement you run opens a new one; the client also opens one at once while a subscription or a followed clock waits to be restored. Server notices then resume with the new session, rows arrive from every subscription restored on it or opened after it, and a followed clock prints its state again once it is attached there. When the client cannot open a session within its 120-second retry deadline, a line names what is waiting and the whole chain of why, and the client keeps trying:

[events] notice: subscription events could not resume yet: failed to connect to server: transport error: tcp connect error: tcp connect error: Connection refused (os error 111); the client keeps trying

After a reconnect the CLI opens every subscription and attaches every clock again. When the new session refuses one, a notice line reports the server’s message and when the next attempt follows:

[events] subscription [watch] notice: opening the subscription again failed: stream 'orders' does not exist in domain 'quickstart'; the next attempt follows in 2s
[events] domain clock [simulation] notice: attaching the clock again failed: session-scoped and client-local statements cannot be queued in a transaction; the next attempt follows in 2s

DELETE SUBSCRIPTION of a subscription no open session holds, because its session ended or the new session refused to open it again, completes at once and frees the name.

When the server ends a subscription because its relay was redefined or removed, the subscription’s last line reads:

[events] subscription [watch] notice: the subscription ended: session subscription 'watch' ended because relay 'orders' in domain 'quickstart' no longer exists

The CLI never opens an ended subscription again, including after a reconnect. CREATE SUBSCRIPTION under the same name opens it again under the relay’s current definition, and DELETE SUBSCRIPTION of it completes at once and frees the name.

If server notices arrive faster than the CLI reads them, the client drops the ones it held and [events] notice: server notices were dropped because they arrived faster than they were read marks the gap; the notices after it keep printing.

Leaving

exit, quit, Ctrl-D, or Ctrl-C.

Session And Transaction Statements

Some statements affect the client session or replicated transaction state rather than directly applying one model change:

StatementEffect
USE <domain>switch the session’s active domain
LIST DOMAINSlist domains with pace and status
BEGIN / COMMIT / REVERTopen, apply, or discard a replicated transaction on the leader
DESCRIBE TRANSACTION ['<id>'] [OPERATION <n>] [FORMAT TEXT | JSON]read the attached transaction, or one named by id, without changing it
UPLOAD RESOURCE <name> VERSION '<dir>'stream a local directory as a new version of that resource in the active domain
BACKUP CLUSTER TO '<file>' / BACKUP DOMAIN [<name>] TO '<file>'back up configuration, users and resources into a local archive file
DESCRIBE BACKUP '<file>' [FORMAT TEXT | JSON]read and verify a local archive without contacting a server
RESTORE CLUSTER FROM '<file>' / RESTORE DOMAIN <name> [AS <new_name>] FROM '<file>'recreate users, domains, resource versions and models from a local archive file
CREATE SUBSCRIPTION / DELETE SUBSCRIPTIONstart and stop a read-only relay subscription
ATTACH DOMAIN CLOCK / DETACH DOMAIN CLOCKstart and stop following the active domain’s clock

USE, LIST DOMAINS, UPLOAD RESOURCE, BACKUP, DESCRIBE BACKUP, RESTORE, and the domain clock statements must be submitted on their own, and never inside a transaction. ATTACH DOMAIN CLOCK prints the clock the serving node has installed, then each change above the prompt as described in Asynchronous Output:

nervix[simulation]> ATTACH DOMAIN CLOCK;
attached to the clock of domain 'simulation': generation 1, paced: period 1s, skew 100ms, logical origin 2030-01-01T00:00:00Z, UTC anchor 2026-09-27T09:30:00.125Z, time rate 2

A second attach is refused because the session already follows that clock, and a detach without an attachment is refused because it does not. USE does not detach a clock, so the REPL can follow the clocks of several domains at once, and the CLI attaches the clocks it follows again after a reconnect. See Domain Clock Attachment. Read-only statements, subscriptions, CREATE DOMAIN, CREATE USER, and node administration are also rejected while queueing transaction content. DESCRIBE TRANSACTION is the exception: while a transaction is open it reads that transaction’s impact report without queueing anything or consuming an operation number, so the next queued statement keeps the number it would have had. It must be submitted on its own. With an id it reads another transaction of the same user and leaves the prompt, the active domain, and the attached transaction as they were:

nervix-cli --domain production --command \
  "DESCRIBE TRANSACTION '01a0ca64-7062-7302-ac1e-43138ccc2067' OPERATION 2 FORMAT JSON;"

For a standalone --command inspection with FORMAT JSON, stdout contains exactly one JSON document serialized from the typed report, without terminal decoration or unrelated events. Failures print one JSON object with error.code and error.message on stdout and exit nonzero; a configuration, connection or request failure’s message is the whole chain of causes, such as invalid server URL: relative URL without a base. Diagnostic details and unrelated events go to stderr. FORMAT TEXT uses the normal readable terminal output. Inspection output is complete rather than paginated or truncated, so redirect a large text or JSON report to a file or downstream process when terminal output is impractical. After a stale-preview COMMIT refusal, run DESCRIBE TRANSACTION for the attached transaction before retrying the commit so the client fences it to the newly reviewed planning basis. See Transaction Quiescence And Impact Inspection for the report’s planned and actual scopes, historical topology, and diagnostic meanings.

BEGIN requires an existing active domain and binds the transaction to it; attaching a transaction switches the active domain to the transaction’s domain. An upload targets the active domain, renders live progress, and finishes after every current live node incarnation has verified and installed the assigned version:

upload resource 'order_model' finished: 4.2 MiB sent
uploaded resource version 1

See Resources for what a resource version contains.

Use --command for an atomic resource rebinding just like any other server statement:

nervix-cli --domain production --command \
  "REBIND RESOURCE order_model TO VERSION LATEST FOR INFERENCER score_orders;"

The command prints the resolved target, changed and selected usage counts, quiesce level, and a sorted line for every selected model. In a transaction, the queued response is a provisional plan; COMMIT resolves LATEST again before applying the atomic model step.

Backing Up

BACKUP in the REPL or through --command runs the backup and downloads its archive into the named file on this machine. The backup subcommand does the same with arguments instead of NSPL, and exits with a nonzero status whenever the archive was not delivered:

nervix-cli backup cluster --output cluster.nvxb
nervix-cli --domain payments backup domain --output - --without-resources > payments.nvxb
nervix-cli backup domain payments --output payments.nvxb --format json

--output - writes the archive to standard output and the report to standard error. A standard output that would discard the archive, the null device or one that was closed when the CLI started, fails with WRITE_FAILED before the backup is taken. The archive is downloaded and verified in a staging directory first; when standard output then fails, for example because its reader closed the pipe, the CLI keeps the verified archive there and reports WRITE_FAILED with the backup’s execution reference and the kept file, which is the only copy left. Backup And Restore describes how to recover it. --format json prints the report, or the failure, as one JSON document. DESCRIBE BACKUP reads a local archive without connecting to a server, whether it is typed in the REPL or passed to --command:

nervix-cli --command "DESCRIBE BACKUP 'cluster.nvxb' FORMAT JSON;"

The description lists every runtime state section of each domain under runtime_state. A deduplicator line names its keyspace’s branch fingerprint, key count, groups, revision and schema fingerprint; a window processor line names its branch fingerprint, retained rows, groups, branch incarnation, next admission sequence, pending delayed histogram removals, revision, schema fingerprint and window model digest. JSON reports the same values with kind deduplicator or window_processor, together with the length and digest of every section. Neither form prints branch-key values or deduplicator keys.

See Backup And Restore for what an archive holds and how long the server retains it.

Restoring

RESTORE in the REPL or through --command streams the archive the statement names from this machine to the leader, and prints the restore’s report when it ends. The restore subcommand does the same with arguments instead of NSPL, shows how much of the archive was sent while it streams, and exits with a nonzero status whenever the restore did not complete:

nervix-cli restore cluster --input cluster.nvxb --on-existing-user skip
nervix-cli restore domain payments --as payments_copy --input cluster.nvxb
nervix-cli restore domain payments --input payments.nvxb --dry-run --format json
nervix-cli restore domain payments --input payments.nvxb --resume --format json

--resume starts the complete restored domain at its archived generation and mapping, preserving materialized rows, deduplicator keys and windows. The default leaves it stopped; a subsequent normal START advances its generation and clears materialized state, while deduplicator keys and windows continue. A window whose model or branch incarnation changed starts empty, and the report lists it among its warnings. Text and JSON reports include each domain’s status and start generation, including the planned lifecycle of a dry run.

--dry-run verifies the archive and plans the restore without changing anything, and --format json prints the report, or the failure, as one JSON document. The Rust client sends the archive again under the same execution reference after a redirect or a lost connection, so a restore interrupted by a leader change is resumed rather than repeated. See Backup And Restore for what a restore recreates and the order of its steps.

Streaming A Relay

The subscribe subcommand opens a read-only session subscription and prints events until interrupted:

nervix-cli --domain quickstart subscribe watch orders
nervix-cli --domain quickstart subscribe sampled orders \
  --dropping \
  --batch-sample-rate 0.1 \
  --where 'input.amount >= 1000'
FlagMeaning
--droppingdrop deliveries when the session transport queue is full
--blockingblock instead of dropping; this is the default, so the flag is only ever explicit
--batch-sample-rate <0.0-1.0>sampling of each row the predicate selected
--where <expression>NSPL predicate over delivered records, read exactly as the WHERE clause of a typed CREATE SUBSCRIPTION and validated locally before the subscription opens

--dropping and --blocking are mutually exclusive. Subscription semantics, sampling, and backpressure are covered in Sessions.

The subcommand prints server notices beside the rows, and keeps printing both across reconnects: the client opens the subscription again on its next session, reports the gap as an interruption notice, and resumes the notices of the new session, as described in Asynchronous Output. A subscription the server ended is not opened again: the subcommand prints its end and keeps printing server notices until interrupted.

Following A Domain Clock

The domain-clock subcommand attaches to the selected domain’s clock and prints its state and progress on stdout until interrupted:

nervix-cli --domain simulation domain-clock

The first line is the attach reply. Every later clock state, tick, interruption, or attachment end uses the same one-line format as the REPL’s Asynchronous Output:

attached to the clock of domain 'simulation': generation 1, paced: period 1s, skew 100ms, logical origin 2030-01-01T00:00:00Z, UTC anchor 2026-09-27T09:30:00.125Z, time rate 2
[events] domain clock [simulation] tick: generation 1, id 1, boundary 2030-01-01T00:00:00Z, authority UTC 2026-09-27T09:30:00.125Z, node logical 2030-01-01T00:00:00Z
[events] domain clock [simulation]: generation 1, stopped
[events] domain clock [simulation]: generation 2, paced: period 1s, skew 100ms, logical origin 2026-09-27T09:31:00Z, UTC anchor 2026-09-27T09:31:00Z, time rate 1

The domain and generation identify each state. A paced state includes its period, skew, logical origin, UTC anchor, and rate. A tick includes its id, logical boundary, the authority’s UTC observation, and the serving node’s logical reading.

Stdout carries these lines and nothing else, and their format is stable, so scripts and external observers can parse them. Every line has one of these forms:

LinePrinted
attached to the clock of domain '<domain>': <clock>first, as the attach reply
[events] domain clock [<domain>]: <clock>when the serving node installs another clock, and when a new session attaches the clock again
[events] domain clock [<domain>] tick: generation <n>, id <n>, boundary <time>, authority UTC <time>, node logical <time>when the serving node accepts a newer tick of a paced clock
[events] domain clock [<domain>] notice: the session was interrupted; the clock is attached again on the next sessionwhen the session holding the attachment ends
[events] domain clock [<domain>] notice: attaching the clock again failed: <message>; the next attempt follows in <wait>when a new session refuses to attach the clock again
[events] domain clock [<domain>] notice: the attachment ended because <reason>when the server ends the attachment, as the last line

<clock> is generation <n>, followed by stopped, uninstalled, unpaced, or paced: period <duration>, skew <duration>, logical origin <time>, UTC anchor <time>, time rate <rate>. Within a line:

  • In the attach reply, state, and tick lines, fields are separated by , and appear in the order shown. Each field but the state is its name, one space, and its value, and a name can contain spaces, as logical origin, UTC anchor, time rate, authority UTC and node logical do. The state is stopped, uninstalled, unpaced, or paced: followed by one space and the period field.
  • <n> is an unsigned decimal integer. The generation counts the domain’s STARTs, and tick ids number the ticks of a generation from one: a tick’s boundary is the logical origin plus the id minus one periods.
  • <time> is an RFC 3339 instant in UTC with a Z offset and no fractional digits, or three, six, or nine of them, as the instant needs.
  • <duration> has unit suffixes, such as 500ms, 1s, or 1s 500ms, and can contain spaces, so read a field by its name rather than by splitting at spaces.
  • <rate> is a decimal number, such as 2 or 0.25.
  • In a notice line, <message> is display text that can itself contain , and ; , and <wait> is a whole number of seconds such as 1s or 30s.

Within one session, the tick ids of a generation increase and can skip, because the authority coalesces missed periods and a slow reader receives the newest tick rather than a backlog. When the session is lost, for example because its node restarts, the interruption line reports the gap, and the client opens a new session within its retry deadline and attaches the clock again. The output then shows the clock the new session reports, and its ticks continue from the newest one that session’s node holds. Ticks accepted in between are not replayed, and the first tick after the interruption can repeat the last id printed before it, or precede it when another node serves the new session. A node that is still starting answers the attach once it has installed the cluster’s committed domains, so a restart never ends the attachment. When the new session refuses the attach for another reason, a notice line reports it and when the client tries again on that session.

The server ends an attachment when the domain no longer exists on the serving node, and a new session that no longer has the domain ends it the same way. <reason> then reads the domain no longer exists on the serving node. The end line is the last clock line and the command exits with status 0, as it does after Ctrl-C detaches the clock or finds it already detached. A failed connection, a refused attach, including a missing domain, and a refused detach print the typed reason on stderr and exit nonzero, as does a lost session the client cannot replace within its retry deadline.

Cluster Node Administration

Each of these submits one statement and exits:

CommandStatementPurpose
nervix-cli cordon-node <id>CORDON NODEstop scheduling new work onto a node
nervix-cli uncordon-node <id>UNCORDON NODEallow scheduling again
nervix-cli drain-node <id>DRAIN NODEmove scheduled graph nodes away and keep the node cordoned
nervix-cli remove-node <id>DROP NODEremove the node from cluster membership

Drain and replication behaviour is described in Replication And Drain Behavior.

drain-node cordons the target before moving work. It visits domains and their hard placement groups or independent runtime nodes in canonical order, drains one unit at a time, and leaves the node cordoned. A successful result starts with the aggregate and effective quiesce level, followed by one line per move:

drained node 'node-2' (moved 2 of 2 scheduled graph node(s))
quiesce level: ENTITY_PAUSE
- kind=ingestor name=orders from=node-2 to=node-3 replicas=node-2 promoted_replica=yes
- kind=emitter name=warehouse from=node-2 to=node-1 replicas=none promoted_replica=no

Each move line identifies the runtime-node kind and name, former and destination owners, resulting replicas, and whether the destination was promoted from a replica. A node with no scheduled graph work reports moved 0 of 0 and quiesce level: DYNAMIC.

If one unit cannot drain or activate, the command returns an error containing the same header plus a failed: line for that unit. Independent units continue moving, so moved can be smaller than of. The node stays cordoned, successful moves remain committed, and running drain-node again retries the units it still owns.

Shell Completions

nervix-cli completions bash > /etc/bash_completion.d/nervix-cli

bash, elvish, fish, powershell, and zsh are supported. This subcommand does not contact the server, so it works before a cluster exists.

Leader Redirects

Persistent statements are applied by the leader. The CLI gives each one a stable execution reference and retains it until the terminal result. If the session lands on a follower, the client follows the redirect and reconnects on its own, repeating the statement under the same reference, and waits out an election the same way; ordinary commands are bounded by the client’s 120-second retry deadline. BACKUP uses the global --backup-wait-timeout instead, ten minutes by default, for the entire command across all domain cuts and reconnections. This applies to NSPL in --command and the REPL as well as the backup subcommand. Its per-domain TIMEOUT does not determine that total wait. A statement the leader never answered in its wait is reported with its execution reference as not known yet. A redirect that reaches the terminal as a statement’s own result is printed as:

topology: not-a-leader, retry on leader 'node-2' at http://10.0.0.12:47391/

After an uncertain backup, backup --execution-reference REFERENCE recovers the admitted command and downloads its archive with the original selected domain, scope, resources and capture options. The output file or stdout destination may change. The JSON failure report exposes error.execution_reference; Backup And Restore describes recovery and retention limits.

Transaction controls follow the same redirect beginning with BEGIN. The CLI retains the returned transaction id and attaches it on the new connection before resuming any queued statement or commit. Each append keeps its execution reference and expected queue position. A pending commit waits for the exact retained terminal result recorded under its own reference. An open transaction therefore survives an unclean connection loss or leader failover; a clean CLI exit reverts it. See Replicated NSPL Transactions and Client Session Protocol.

nervix-cli Reference

Every option, subcommand, default, and environment variable nervix-cli accepts, printed by the binary itself while this book was built. It therefore describes exactly the release you are reading about. For how to use the client, see Command Line Client.

nervix-cli

Interactive Nervix client

Usage: nervix-cli [OPTIONS] [COMMAND]

Commands:
  completions    Generate shell completion scripts
  subscribe      Subscribe to one relay and print events until interrupted
  domain-clock   Follow the selected domain's clock until interrupted
  remove-node    Remove a node from the cluster membership
  cordon-node    Prevent the scheduler from placing new tasks on a node
  uncordon-node  Allow the scheduler to place new tasks on a node
  drain-node     Move scheduled graph nodes away from a node and keep it cordoned
  backup         Back up configuration, users and resources into an archive file
  restore        Restore configuration, users and resources from an archive file
  help           Print this message or the help of the given subcommand(s)

Options:
      --server <SERVER>
          Session gRPC endpoint; an https:// URL connects over TLS [default: http://127.0.0.1:47391]
      --tls <TLS>
          Whether TLS is required; `required` refuses a server URL that is not https [default: preferred] [possible values: preferred, required]
      --tls-ca-cert <TLS_CA_CERT>
          PEM certificate authority used to verify the server certificate
      --dns-resolver-config <DNS_RESOLVER_CONFIG>
          Resolv.conf-format file used by native session hostname resolution [env: NERVIX_DNS_RESOLVER_CONFIG=] [default: /etc/resolv.conf]
      --dns-hosts-file <DNS_HOSTS_FILE>
          Hosts-format file consulted before DNS [env: NERVIX_DNS_HOSTS_FILE=] [default: /etc/hosts]
      --dns-name-server <DNS_NAME_SERVERS>
          Name server addresses with ports, replacing those in the resolver configuration [env: NERVIX_DNS_NAME_SERVERS=]
      --domain <DOMAIN>
          Domain the session starts in [default: default]
      --username <USERNAME>
          Registry user to authenticate as [env: NERVIX_USERNAME=] [default: default]
      --password <PASSWORD>
          Password for the registry user; prompted for interactively when unset [env: NERVIX_PASSWORD=]
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
      --command <COMMAND>
          Run NSPL statements once and exit instead of starting the interactive REPL
      --suggest <SUGGEST>
          Print completion candidates for NSPL input as JSON and exit
      --cursor <CURSOR>
          UTF-8 byte cursor in --suggest input; defaults to the end
  -h, --help
          Print help

nervix-cli completions

Generate shell completion scripts

Usage: nervix-cli completions [OPTIONS] <SHELL>

Arguments:
  <SHELL>  Target shell [possible values: bash, elvish, fish, powershell, zsh]

Options:
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
  -h, --help
          Print help

nervix-cli subscribe

Subscribe to one relay and print events until interrupted

Usage: nervix-cli subscribe [OPTIONS] <NAME> <RELAY>

Arguments:
  <NAME>   Session-local subscription name
  <RELAY>  Relay name to subscribe to

Options:
      --dropping
          Drop delivered events when the session transport queue is full
      --blocking
          Block delivered events when the session transport queue is full
      --batch-sample-rate <BATCH_SAMPLE_RATE>
          Optional per-arrival batch sample rate from 0.0 through 1.0
      --where <WHERE_CLAUSE>
          Optional NSPL predicate applied to delivered records
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
  -h, --help
          Print help

nervix-cli domain-clock

Follow the selected domain's clock until interrupted

Usage: nervix-cli domain-clock [OPTIONS]

Options:
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
  -h, --help
          Print help

nervix-cli remove-node

Remove a node from the cluster membership

Usage: nervix-cli remove-node [OPTIONS] <NODE_ID>

Arguments:
  <NODE_ID>  Node id to remove

Options:
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
  -h, --help
          Print help

nervix-cli cordon-node

Prevent the scheduler from placing new tasks on a node

Usage: nervix-cli cordon-node [OPTIONS] <NODE_ID>

Arguments:
  <NODE_ID>  Node id to cordon

Options:
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
  -h, --help
          Print help

nervix-cli uncordon-node

Allow the scheduler to place new tasks on a node

Usage: nervix-cli uncordon-node [OPTIONS] <NODE_ID>

Arguments:
  <NODE_ID>  Node id to uncordon

Options:
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
  -h, --help
          Print help

nervix-cli drain-node

Move scheduled graph nodes away from a node and keep it cordoned

Usage: nervix-cli drain-node [OPTIONS] <NODE_ID>

Arguments:
  <NODE_ID>  Node id to drain

Options:
      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)
  -h, --help
          Print help

nervix-cli backup

Back up configuration, users and resources into an archive file

Usage: nervix-cli backup [OPTIONS] --output <OUTPUT> <SCOPE> [NAME]

Arguments:
  <SCOPE>
          What the archive covers: `cluster` for every domain and user, `domain` for one domain

          Possible values:
          - cluster: Every domain and every user
          - domain:  One domain

  [NAME]
          The domain a `domain` backup covers; the session's `--domain` when omitted

Options:
  -o, --output <OUTPUT>
          Where the archive is written; `-` writes it to standard output

      --without-resources
          Record every resource version and its digests without the version's bytes

      --without-state
          Capture configuration only, without pausing domains

      --without-pause
          Capture the latest published state without pausing domains

      --timeout <TIMEOUT>
          Maximum time for each running domain's quiesced capture

      --execution-reference <EXECUTION_REFERENCE>
          Recover the same backup with its original domain and capture options

      --format <FORMAT>
          How the backup's report is printed
          
          [default: text]
          [possible values: text, json]

      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)

  -h, --help
          Print help (see a summary with '-h')

nervix-cli restore

Restore configuration, users and resources from an archive file

Usage: nervix-cli restore [OPTIONS] --input <INPUT> <SCOPE> [NAME]

Arguments:
  <SCOPE>
          What to restore: `cluster` for every domain and user of a cluster archive, `domain` for one domain of an archive

          Possible values:
          - cluster: Every domain and user of a cluster archive
          - domain:  One domain of an archive

  [NAME]
          The archived domain a `domain` restore recreates

Options:
  -i, --input <INPUT>
          The archive file to restore from

      --as <TARGET>
          Restore the domain under this name instead of its archived one

      --on-existing-user <ON_EXISTING_USER>
          What a cluster restore does with an archived user the cluster already has; `fail` when omitted

          Possible values:
          - fail:    Refuse the restore before it changes anything
          - skip:    Keep the existing user and its password
          - replace: Give the existing user the archived password hash

      --resume
          Resume the archived start and clock mapping after installing all state

      --dry-run
          Verify the archive and plan the restore, changing nothing

      --without-state
          Restore configuration without runtime state

      --without-source-offsets
          Restore runtime state but leave source offsets empty

      --format <FORMAT>
          How the restore's report is printed
          
          [default: text]
          [possible values: text, json]

      --backup-wait-timeout <BACKUP_WAIT_TIMEOUT>
          Overall BACKUP command wait across all domain cuts, redirects and reconnects (default 10m)

  -h, --help
          Print help (see a summary with '-h')

Web Console

Every Nervix node serves a browser console. It combines a live drawing of the running execution graph with an NSPL command line, so the same session can be used to read a graph and to change it.

The Nervix web console: entity sidebar, live execution graph, and NSPL REPL

Opening The Console

The console is served by the node itself; nothing extra is deployed. It listens on 0.0.0.0:47420 by default and is served under /console/, with / and /console redirecting there:

http://127.0.0.1:47420/console/

Change the address with --web-console-listen-addr or NERVIX_WEB_CONSOLE_LISTEN_ADDR. The published ports differ by installation method: see Docker and Kubernetes Operator.

Any node works. Opening the console on a follower is supported — the console follows the leader redirect, reconnects there, and reports which node it reached:

connected to leader 'node-2'

Signing In

Without credentials in the URL the console shows a login form for a registry user. Two other forms are accepted, which is what automation and bookmarks use:

  • an Authorization: Basic <base64(user:password)> request header
  • an ?auth=<base64(user:password)> query parameter

The query parameter puts credentials into browser history and server access logs, so prefer the login form or the header for anything but a local cluster.

Layout

The console is one screen with three regions:

  • the sidebar on the left: the domain selector, live throughput for the selected domain, and the domain’s entities grouped by kind — schemas, wire schemas, codecs, resources, clients, vhosts, and endpoints
  • the execution graph in the upper right
  • the REPL below it, which also hosts any relay subscriptions you open

The top bar carries the websocket connection state, the global Create menu, the Backups button, the domain lifecycle button, the selected clock state, and the theme picker. The clock panel below the domain selector shows the selected domain’s clock in detail.

Sidebar entries are counted per kind and each group collapses. Selecting an endpoint runs its DESCRIBE in the REPL; selecting a resource does the same and opens its version dialog. The Cluster footer stays independent of the selected domain: it reports the number of running domains, non-relay graph nodes, and relays across the current cluster graph.

Following the selected domain clock

Selecting a domain attaches the console session to its clock once. The console selects the first listed domain on its own when none is selected or the selected one disappears, and selecting another domain, from the domain menu or with a typed USE, detaches the previous clock first. The console’s session is served by the leader, so after a connection interruption or a leader change it attaches the selected clock again on the new session; the server releases the earlier attachment when its session ends. A console that holds a transaction attaches it first on a new connection, and while the session holds it the server refuses the clock attachment, as it refuses the automatic detach. The panel shows the attached clock’s typed state and START generation. For a paced clock it also shows logical now, rate, period, skew, the logical origin and UTC anchor, and the latest accepted tick id and logical boundary. Logical now is projected from the committed mapping at the browser’s UTC, refreshed four times a second; a browser whose clock differs from the cluster’s can show a different projection. The tick is the serving node’s accepted observation. Stopped, uninstalled, and unpaced states have no projected logical time. A refused attachment shows its reason and is not retried until the selection or connection changes.

The panel and the top bar’s CLOCK state name the attachment’s phase: NO DOMAIN without a selection, ATTACHING until the attach reply, STOPPED, UNINSTALLED, UNPACED or PACED while it follows the clock, INTERRUPTED while the connection is lost, which shows no projection, REFUSED with the server’s reason, DETACHED after a typed detach, and ENDED with its reason once the server ends the attachment, which the console does not attach again until the selection or connection changes.

Creating Entities From Forms

The top bar’s Create menu opens keyboard-accessible forms for domains, users, resource catalogs, schemas, codecs, signaling protocols, clients, VHOSTs, endpoints, hash maps, Roto UDFs, relays, branches, ingestors, junctions, reingestors, and subscriptions. The resource group in the sidebar also provides a contextual create action. A form keeps its unfinished draft when it closes, restores focus to the action that opened it, reports validation and server failures inline, and shows the canonical NSPL statement before submission. An expression typed into a form, such as a filter, a WHERE predicate, an assigned value or an invocation argument, reads exactly as it does in a typed statement: its string literals are verbatim, so a backslash is an ordinary character. The domain form supports paced and unpaced clocks, period and skew for a paced clock, placement policy, and IF NOT EXISTS. User and resource forms support their corresponding names and the same creation modifier.

Pace and placement are typed choices supplied by the session server. They are searchable and paged independently, and the placement lookup carries the selected pace as a typed dependency. Loading and no matches have separate neutral messages. A missing prerequisite, such as a captured domain or a selected relay for its field list, shows a neutral hint naming what to choose. A stale choice page shows a neutral message and Retry, which requests a fresh first page. Lookup, session-channel, and unreadable-reply failures remain alerts. These states apply to the domain, branch, relay, and subscription forms. An edit, dialog close, or replacement session makes an older reply ineligible to change the form.

A resource draft captures the selected domain the first time it opens. If the console later selects another domain, reopening the retained draft keeps its captured scope and offers Use current domain as an explicit change. Domain and user creation are cluster scoped. Passwords are sent in the canonical command but appear as eight asterisks in both the preview and REPL; the cleartext value is never added to terminal history.

Submitting uses the same durable command path as the REPL. The form therefore keeps the command’s execution reference through redirects and reconnects, uses the attached transaction’s expected position, and reports Editing, Submitting, Queued until reconnect, Queued in transaction, Completed, or Failed from the correlated outcome. A queued transaction operation is not presented as externally complete. A successful standalone resource create opens the existing resource version dialog, where upload identity and completion continue to be owned by the upload workflow.

The Create menu also opens forms for internal schemas, JSON/CBOR/AVRO wire schemas, and named branches. Schema fields are added in declaration order and can be moved or removed. An internal field selects its exact scalar type, then may wrap it in any sequence of variable vectors and fixed arrays; each fixed array needs a positive length. Optional and sensitive flags are separate controls. Wire fields select the types of their chosen format and an explicit STRICT or LOOSE mode. A wire schema’s format is part of its identity, so JSON, CBOR, and AVRO definitions may share a name. The preview and submitted command come from the completed semantic Model’s canonical renderer, just as they do for the earlier forms.

A branch form selects an internal key schema from a server-backed, searchable, paged list, then requires a TTL. It may set a positive maximum instance count with LRU eviction. The schema list uses the draft’s captured domain and the session’s attached transaction prefix, so a schema staged earlier in the same transaction is selectable. Changing the captured domain keeps a previous selection visible but marks it invalid until it is selected again. An invalid branch key schema, including one containing BYTES, is rejected by registry validation and shown inline with the branch and field named. These domain-owned drafts keep their captured scope when reopened; Use current domain changes it explicitly.

A relay form selects the relay’s internal schema from the same kind of list, then states its branching explicitly: UNBRANCHED, or BRANCHED BY with a branch chosen from a searchable, paged list of the captured domain’s branches. A new draft has chosen neither, and it cannot be submitted until one is chosen, so a relay is never unbranched by omission. Capacity starts from the default relay buffer and must be a positive count. Materialized state is NONE or LAST BY TIMESTAMP. Both lists read the attached transaction prefix, so a schema or branch staged earlier in the transaction is selectable, and a relay created while a transaction is attached is queued in it like the other forms’ creates. Changing the captured domain keeps the selected schema and branch visible but invalid until each is selected again. The preview is the canonical CREATE RELAY statement, which always states its capacity.

A codec form selects one current wire format: a declared JSON, CBOR, or AVRO wire schema; the fixed SYSLOG format; a JAQ-native JSON, YAML, TOML, XML, or CBOR format; or Protobuf. The internal schema is an exact typed selection. Declared wire-schema choices are limited to the selected format, even when wire schemas of different formats share a name. JAQ-native and Protobuf codecs choose ingestion (decode), emitting (encode), or both, and may add an emitting-batch program after an emitting program. These are program editors: their contents remain jaq source in the current codec Model, including quotes and whitespace. RFC3339 field encoding rules can be added in order where the format supports them. The preview renders the complete codec Model as canonical NSPL.

The Protobuf controls select an existing resource and one of its completed versions, including LATEST, then accept an optional .proto file and include root, additional compiler configuration entries, message type, and optional batch message type. The resource list includes catalogs staged earlier in the attached transaction; the version list offers completed uploads only. LATEST stays unresolved in the draft and submitted command, and is pinned when the server applies the statement. These controls never create a resource or upload a file as a side effect; provision and upload it explicitly through the resource workflow. Changing the resource or captured domain leaves an earlier version visible but requires selecting it again. A rejected program or configuration reports failure inline and keeps the draft editable.

A signaling protocol form chooses JSON, YAML, TOML, XML, CBOR, RAW, or Protobuf. Protobuf uses the same resource, version, file, include, and compiler configuration controls and requires separate send and wait message types. The ordered handshake editor adds SEND and WAIT steps, moves or removes steps, and keeps each step’s jaq programs in written order. WAIT steps can have multiple matchers, failure matchers, an optional capture when there is exactly one matcher, and ACCEPT DATA. The form also offers connection-wide ACCEPT DATA, failure matchers, and the handshake timeout. Its canonical preview and command preserve the order and raw program text. Both forms use the existing durable command dispatcher and retain rejected drafts, transaction positions, and reconnect handling.

A client form offers every current transport as a typed selection. Connector CONFIG remains an ordered key/value editor whose keys and values are passed to the connector unchanged; the form does not provision external services, topics, buckets, tables, or other objects. Redis, Postgres, MySQL, and MongoDB clients require explicit minimum and maximum pool sizes. A client may mount an existing resource and a completed version. A WebSocket client may select a configured signaling protocol. Switching transports clears transport-specific configuration and signaling choices; changing the resource or captured domain invalidates its version selection. LATEST remains a request until the server applies the statement, when the stored binding becomes an exact version. Configuration rows can be marked secret, and common credential keys and URLs with passwords are treated as secret automatically. Secret inputs use password controls. The preview and the command line history mask those values, while the transmitted command keeps the actual values.

A VHOST form accepts ordered hostnames and may bind TLS to an existing resource and completed version. A TLS VHOST installs its certificate in the HTTPS listener of every live node at command completion, including nodes that do not lead or run the domain’s graph. The certificate bundle must already be uploaded; missing or invalid material follows the existing command failure path and leaves the draft editable. An endpoint form selects an existing VHOST, requires a path, and chooses HTTP or WEBSOCKETS. A WebSocket endpoint may also select a signaling protocol. VHOST and signaling lists are typed, searchable, paged, and scoped to the captured domain, including the attached transaction prefix. An upstream selection or domain change refreshes dependent choices; stale selections must be chosen again before submission. A completed VHOST or endpoint is usable immediately by later commands through the same session.

A hash map form selects a resource, a completed version or LATEST, a codec, and a key field from that codec’s output schema. It also requires the file path within the resource. Codec and key choices read the captured domain and the attached transaction prefix; changing the codec makes an earlier key selection invalid until selected again. A changed resource invalidates its version, and changing the captured domain invalidates all references while leaving them visible for correction. LATEST remains unresolved in the draft and canonical command; the server pins it when the statement applies. The form shows Completed only after the resource file has decoded and the lookup index is ready. A malformed file or unavailable version reports failure inline and keeps the entered path and selections for inspection. A successful hash map can be queried with LOOKUP or used by LOOKUP_HASH_MAP immediately.

A Roto UDF form keeps arguments in declaration order, with exact scalar, vector, and fixed-array types and separate optional flags. The result has the same type controls. The current language is ROTO_0_13, with an explicit volatility control. The source editor preserves its complete text, including quotes, whitespace, the named function body, and Roto test blocks; canonical NSPL chooses a safe dollar-quote delimiter around it. The server checks the declared signature exactly and runs the Roto tests before Completed appears. Compilation, type, or test failure keeps the signature and source editable. A completed function is available to udf:: calls in later commands under the same session and domain.

An ingestor form offers every connector and entity an ingestor reads through a transport: HTTP REQUEST, HTTP ENDPOINT, KAFKA TOPIC, PULSAR TOPIC, MQTT TOPIC, NATS SUBJECT, RABBITMQ QUEUE, REDIS PUBSUB, PROMETHEUS QUERY, ZEROMQ PULL, SQS QUEUE, WEBSOCKETS CONNECTION, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER. Its client or endpoint is a typed choice limited to that pair: clients of its connector, or endpoints of the TYPE its head names. The preview writes the statement under that pair’s head. The source’s own fields, delivery mode, quiesce mode, and required durations or sizes appear as explicit controls. A topic, queue, subject, channel, consumer group, queue group or subscription keeps its text exactly as typed, case and spaces included, and the form refuses a name its entity’s rule refuses. The decoding codec list contains only codecs that support decoding. Timestamp behavior must be chosen explicitly: source timestamp, NOW, or AT a typed field from the codec’s output schema.

Each ingestor route explicitly chooses an unbranched or named branch and a relay with that exact branch declaration. Routes can be added, removed, and moved in order. They edit inheritance, ordered output assignments and function invocations, an optional WHERE expression, branch key assignments, an explicit flush policy, and a route-local message error policy. An ingestor error route selects an unbranched relay and its own field assignments. The node-wide general error policy is also required. The draft retains incomplete values and shows the canonical NSPL preview only when every required choice and expression is valid. It submits through the usual durable command path, including attached transactions and reconnect recovery.

A junction form chooses its exact unbranched or named branch before its input relays. A reingestor chooses inputs first and gives each output route an independent branch action: preserve the input branch, become unbranched, or construct a named branch from ordered key assignments. Additional inputs are offered only when their declared schema and branch match the first input exactly. Both forms keep input predicates, optional collection bounds, materialized dependencies, and output routes in visible order. A materialized dependency resolves against the incoming branch and requires REQUIRED SKIP, REQUIRED WAIT, or DEFAULT with ordered assignments. Each transforming route has ordered inheritance, output assignments, and invocations, an optional predicate, an explicit flush policy, and a route-local message error policy. Server-backed choices restrict output and error relays to the required branch and offer only materialized relays for state. A changed domain or dependent reference keeps the draft visible and requires the affected choice to be selected again. The preview and durable submission use the completed semantic Model; registry validation reports any remaining schema, branch, sensitivity, or graph error inline.

The Execution Graph

The execution graph for the quickstart pipeline

The graph is redrawn from snapshots the leader pushes twice a second. It shows external clients, ingestors, processors, relays, and emitters, connected in the direction records flow.

Records always travel from left to right. Items sit in columns ordered by how far a record has travelled, and every item appears to the right of everything that feeds it. Relays get columns of their own between the processing columns, drawn as labelled capsules with a bar per buffer quantile and the p50/p90/p99 depths on hover, so a relay reads as the port its producers converge on and its consumers fan out from. A pipeline with no branching or fan-out is drawn as a single straight line.

Edges run horizontally and vertically with rounded corners, through gutters kept clear of the items, so no edge ever passes under a node, a capsule, or a label. Each edge leaves and arrives at its own attachment point, so a node with several outputs fans out visibly rather than from one spot. Two relations between the same pair of items stay two edges: a node that both reads a relay’s records and looks up its materialized state draws one edge of each style, side by side. Edge style carries meaning:

  • solid — records flowing
  • dashed, error tint — a route’s ON MESSAGE ERROR destination
  • dashed, warning tint — a correlator’s ON TIMEOUT destination; a correlator’s two inputs are labelled LEFT and RIGHT
  • dotted, hollow arrow — a materialized-state dependency, including the relay a generator reads with USING MATERIALIZED STATE. State is looked up rather than delivered, so these edges carry no rate

A graph that feeds back on itself — a reingestor writing a relay that reaches it again — draws that one edge as a marked return path above the items it spans, so backwards travel is never mistaken for forward flow. Disconnected parts of a graph are stacked as separate bands.

When traffic is flowing, each edge carries its own rate, with the full messages, bytes, and batches per second on hover, and a pulse travelling along the edge. Where a node declares several routes to the same relay they are drawn as one edge, and hovering reports how many routes it stands for.

Branch groups are drawn as a tinted region with a stacked outline around the part of the graph that runs per branch, containing exactly the items that run per branch and nothing else. Regions of different branches never touch, even where they share a column. The region is headed with the branch name, its key fields, and how many branch instances are currently live; the outline thickens with that count. The count comes from the per-branch statistics of the graph snapshot, which name each concrete branch by the lowercase hexadecimal fingerprint of its key, so a snapshot never carries key values a branch schema may declare SENSITIVE. The ingestors and reingestors that construct the branch, and the emitters and reingestors that collapse it, sit on the region’s border rather than inside it. Clicking the header opens the branch’s key schema and its live instances.

The toolbar controls framing. Search highlights matching items, dims the rest, and brings the matches into view; the zoom buttons step in tens and Ctrl/Cmd with the scroll wheel is continuous, both between 25% and 300%; FIT frames the whole graph, which is also the view you start with; the fullscreen button expands the graph over the whole window. Dragging pans. The graph moves only when the topology changes, and never because traffic changed.

The header carries the domain’s lifecycle state — RUNNING, PAUSED, or STOPPED — and the state of the feed: LIVE while snapshots arrive, STALE when they stop, and OFFLINE when the session is disconnected. A stopped domain still draws its whole graph, with no rates and no pulses.

A domain with no installed graph shows NO ACTIVE DATAFLOW GRAPH instead.

Item Actions

The action menu for a relay

Clicking any node or relay opens its action menu. Each action types the corresponding statement into the REPL and runs it, so the console never does anything you could not have typed:

  • DESCRIBE — the runtime view of the item, where the item has a DESCRIBE form
  • SHOW CREATE — the NSPL that declares it
  • SUBSCRIBE — relays only; opens the subscription form described below for that relay, whose tab runs the same CREATE SUBSCRIPTION the REPL accepts

Node Health

Nodes are drawn with their runtime status. A node whose external connection has failed is marked in error, and an ingestor or emitter waiting to reconnect shows a countdown to its next attempt. Hovering a node reveals the underlying reason. Metrics for alerting belong in the observability endpoint rather than here — see Metrics And Observability.

Subscribing To A Relay

The subscription form with a typed field reference, a filter, and a sample rate

The Create menu’s subscription form opens a read-only session subscription as a tab beside the REPL. A relay’s SUBSCRIBE action opens the same form as a new draft for that relay in the graph’s domain, while the Create menu reopens the retained draft. A draft starts under a generated name such as web_console_subscription_2, which can be edited; names are unique among the console’s tabs.

The relay is selected from a searchable, paged list of the captured domain’s relays. The selected relay’s fields are listed in declaration order with their exact types, OPTIONAL, and SENSITIVE; selecting one inserts a typed input.<field> reference into the filter. The filter is the WHERE predicate over the relay’s record. While it is edited, the form shows its canonical reading, and it refuses to submit text that is not an expression. Checking the fields, scopes, and types the predicate uses belongs to the server, which reports a failure such as a string compared with an integer field when the subscription is created. Delivery is BLOCKING or DROPPING, and batch sampling takes a rate from 0 through 1 that each selected row passes with that probability. The preview is the canonical CREATE SUBSCRIPTION statement the tab sends.

Submitting opens the tab through the same subscription lifecycle as a CREATE SUBSCRIPTION typed in the REPL. The tab waits until the session can serve it, opens and becomes active once the server announces the schema of its rows, and streams records into it. The form reports Completed once the tab is open. It reports Failed with the server’s reason, such as a relay that no longer exists, a filter the server cannot compile, or an open transaction, and then no tab remains and the draft stays editable. A name an open tab already uses fails before anything is sent. After a reconnect or leader change, the console restores each open tab once under its name; the form does not submit it again.

The console restores its tabs before it attaches the session’s transaction again, because a session that holds a transaction refuses subscriptions, so an open transaction does not keep a tab from coming back. A tab stays restoring until the server opens its subscription again. A restoration the server refuses leaves the tab interrupted with the reason, and the console tries it again every second while no transaction is attached; closing an interrupted tab stops that.

When a relay is redefined or removed, the server ends the subscriptions that read it. Such a tab turns ended: it keeps the rows it showed together with the reason, receives nothing more, and is not restored after a reconnect, which would not change why it ended. Its ↻ button resubscribes it under the same name with the statement that first opened it. The tab shows resubscribing until the server answers, then becomes active again with the relay’s current schema. A refusal, such as a relay that no longer exists, leaves the tab ended with the reason. An ended tab keeps its name until it is closed, so resubscribe it or close it before opening another subscription with that name.

Closing the tab ends the subscription; closing an ended tab only removes it, because the server already ended its subscription. Subscriptions are read-only views: they cannot construct, inherit, or produce side effects. See Sessions.

The NSPL REPL

The REPL with server-driven completions offered for a partial statement

The REPL accepts the same NSPL as the command line client, including the client-local statements USE, LIST DOMAINS, BEGIN, COMMIT, REVERT, and the subscription statements. A typed CREATE SUBSCRIPTION opens a tab in the selected domain exactly as the subscription form does, and DELETE SUBSCRIPTION <name> closes the open tab of that name. The prompt shows the active domain, and marks an open transaction the same way the terminal client does:

nervix[quickstart]>
nervix[quickstart tx]>
nervix[quickstart committing]>

The console sends a statement whether or not a domain is selected, and the server decides whether it needs one: SHOW CLUSTER STATUS or CREATE DOMAIN runs with no domain selected, while a statement that acts on a domain fails with no active domain selected.

A typed BACKUP runs through the Backups dialog, which opens to show its progress and downloads its archive. A typed RESTORE opens the dialog’s restore form with the statement’s options, because the browser cannot read the path the statement names. BACKUP, RESTORE, and DESCRIBE BACKUP must each be sent on their own, and DESCRIBE BACKUP, which reads an archive file on the client’s machine, runs only in nervix-cli.

The REPL renders attachment outcomes, domain-clock state and tick frames, and attachment ends. Tick entries include the id, boundary, authority UTC observation, and serving node’s logical reading. Every tick adds a line, so a fast clock can push earlier output out of the REPL’s latest 256 lines. ATTACH DOMAIN CLOCK and DETACH DOMAIN CLOCK typed into the REPL use the same session-local requests as the automatic attachment, and the console refuses them itself while a transaction is active. A typed second attach is refused as already attached. A typed detach releases the clock and leaves the panel detached until a typed ATTACH DOMAIN CLOCK, another domain selection, or a new connection restores following.

BEGIN requires a selected domain that already exists and binds the transaction to it. The console follows the transaction’s domain, so attaching switches the domain selector to it. DESCRIBE TRANSACTION prints the open transaction’s impact report between queued statements, and with an id it reads another transaction of the same user while the prompt, domain selector, and attached transaction stay as they were. See NSPL Overview for its forms and output.

Every persistent command keeps one execution reference while the browser waits, reconnects, or follows leadership. The transaction id and status are replicated. If the WebSocket closes unexpectedly or leadership changes, the console reconnects and attaches that id before resuming a pending command. Each append is matched by its reference and expected position, and an outstanding commit remains pending through COMMITTING until its exact terminal result. A finished or failed attach does not settle pending commands: the console sends each under its original reference and position to recover its own outcome, or a failure if it was never admitted. The Create form then leaves Queued until reconnect with a completed or failed result. A second session can attach the same owner’s transaction and take it over; the displaced console then gets an explicit takeover error. A clean console session close reverts an open transaction, while an accepted commit continues on the leader without the browser. See Replicated NSPL Transactions and Client Session Protocol.

Tab cycles through completions offered by the server for the current cursor position. ArrowUp and ArrowDown walk the session’s command history, Ctrl/Cmd with Enter submits, and clear empties the scrollback.

Accepting a candidate replaces its exact source range, including the rest of a word after the cursor, while preserving surrounding text. The list shows one bounded page at a time; use MORE SUGGESTIONS to fetch the next page. The console shows a message when the selected domain or transaction context is missing or stale, or when the candidate lookup fails. An empty list with no message means there are no matches.

Bounded Buffers

The console holds a bounded amount of everything it keeps, however long it runs or however fast its session delivers, and says where it left something out:

  • The REPL and every subscription tab each keep their latest 256 lines, up to 256 KiB of text, and a line longer than that is cut. The first line then says that earlier lines were omitted.
  • The command history behind ArrowUp and ArrowDown keeps the latest 256 commands, up to 256 KiB. Walking back to the oldest command kept says that earlier commands were omitted, and a command larger than the whole history says that it cannot be recalled.
  • While the console connects or waits to reconnect, at most 64 requests of its controls wait for the session, carrying at most 4 MiB of statements, completion input, and search text. Once connected, at most 256 requests, carrying at most 16 MiB, are held or awaiting their reply. Commands, subscription changes, and inspections wait in the order they were issued while 64 requests, the most the server admits for one session, are in flight, and go out as earlier replies arrive; a completion, choice lookup, or domain selection goes out at once and is reported if the server finds the session full. A request past either bound is not sent, and the control that issued it shows why where its outcome would have appeared: the REPL, a form, a tab, the resource dialog, or the inspector. A completion request that is not sent reports a failed lookup rather than an empty list.
  • The execution graph and sidebar keep only the latest snapshot of the selected domain. Selecting another domain replaces it with that domain’s snapshot, which the server sends at once.
  • A backup’s download moves each 256 KiB chunk of the archive into the browser’s own blob storage as it arrives, and the page holds one chunk at a time. A restore reads its archive file one slice at a time, at most 4 MiB while it digests the file and 256 KiB while it streams it, and keeps at most 1 MiB queued on its connection.

Inspecting a transaction

The transaction inspector showing ordered operations and the affected graph

Select Transaction · Inspect in the top bar while a transaction is active. The inspector reads the typed impact report without attaching another transaction or changing the session domain. Use Discover transactions to list retained transactions in the REPL, then enter a transaction ID to inspect one. A DESCRIBE TRANSACTION command also opens its typed result in the inspector; the REPL still prints the server’s text or JSON rendering.

The outline groups accepted operations by their execution steps. Selecting an operation shows its own planned contribution; selecting its step shows the effective pause and effects of the atomic step. Whole transaction shows the union of the steps. Switch between Before, Changes, and After without moving graph items, or between Planned and Actual to review recorded progress. Search frames matching names and kinds; FIT, zoom, and drag control the viewport. Select a node or relation for its before/after presence, roles, and contributing operation numbers. The Relations list offers labeled buttons for keyboard selection of graph edges, including parallel data and materialized-state paths. Role marks and the resource card shape supplement the graph colors.

The summary names the transaction, domain, state, accepted and applied counts, completeness, freshness, aggregate quiesce level, planning basis, relocations, rebuilds, and state resets. An incomplete report shows its diagnostics. A stale preview must be refreshed before retrying COMMIT. The console sends the inspected whole-transaction preview identity with COMMIT, even when an operation is selected. Inspecting another transaction leaves the attached transaction’s commit basis intact. Retained reports keep their own graph geometry when the live execution graph changes.

The inspector receives the same complete typed report as the Rust client and CLI. It does not silently omit nodes, edges, operations, or steps from a large retained report; search, focus, and the outline change what is visible in the viewport without narrowing the result that was read. The Transaction Quiescence And Impact Inspection chapter defines the operation, step, and whole-transaction facts behind these views.

Uploading Resources

The resource dialog after uploading a version

The sidebar shows each resource’s highest completed version, such as v2, or catalog while no upload has completed. Selecting a resource opens its version list, read from the typed description that DESCRIBE RESOURCE returns beside the text the REPL prints: every version with each file and directory under its exact path, and under each version the models bound to it, listed by kind and name, or none.

Files or a whole directory can be uploaded from the browser as a new version of that resource in the selected domain. The successful upload result arrives after every current live node has verified and installed the version. An upload moves no binding: models keep the version they pin until REBIND RESOURCE moves them. Version contents and how nodes consume them are covered in Resources.

Backing Up And Restoring

The top bar’s Backups button opens a dialog with two forms, Back up and Restore. They run the same BACKUP and RESTORE statements as nervix-cli, on the console’s own session: they follow its leader redirect, and a statement typed in the REPL runs through the same dialog. Each form is bound to the statement’s options and shows the canonical NSPL it sends before you submit it. Backup And Restore describes what the statements capture and restore.

Taking A Backup

The Back up form backs up the cluster or one domain, the selected domain by default, into the archive file you name. It captures runtime state quiesced, optionally with a drain timeout, live (WITHOUT PAUSE), or not at all (WITHOUT STATE), and can leave resource bytes out (WITHOUT RESOURCES). Back up and download sends the BACKUP as a command. Once it completes, the dialog lists the summary the backup reported: the archive’s size and BLAKE3 digest, when it was captured and until when it can be downloaded, the users, and each domain with its cut, revision, and sections. The REPL prints the outcome as it prints any command’s.

The console then downloads the archive from the leader that assembled it, and checks it as it arrives. It accepts the archive only once every byte arrived and the size and BLAKE3 digest match the summary, and only then hands it to the browser as a download, saved under the last component of the file the statement names; the browser chooses the directory. A partial or altered archive never reaches the browser’s downloads. The first complete download releases the archive on the leader.

A download that fails in transport, stalls, or ends early starts again from the first byte while the leader retains the archive, and Download again retries one that stopped for such a reason. A download the leader refuses, because the archive expired, a download already collected it, or the node that assembled it restarted, ends with that reason; the backup itself completed, and running it again assembles a new archive.

The console records each backup it sends in the browser tab’s session storage until the archive is downloaded. Reloading the page while the backup runs or downloads sends the same backup again under its execution reference: the leader answers with the backup’s recorded outcome instead of running it twice, and the console downloads the archive again from its first byte. The node collects an archive once it has queued the archive’s last bytes for a download, so a reload that comes after that moment finds the archive collected, and the backup must run again. Closing the tab or signing in as another user forgets the record. So does a reload that finds a record the console cannot read back as exactly what it wrote, such as one edited outside the console; the reload then resumes nothing. An archive stays downloadable only while it is retained, by default for 15 minutes from the moment the console sent the backup.

The browser saves the archive with the permissions of its downloads directory. An archive holds password hashes, client secrets, and TLS private keys, so keep a downloaded archive where only its owner can read it.

Restoring An Archive

The Restore form restores one domain of an archive, under its archived name or a new one, or a whole cluster archive with its policy for users the cluster already has. It can resume the restored domains at their archived lifecycle, and leave out source offsets or all runtime state.

Choose the archive file, set the options, and select Dry run. The console reads the file once to measure its size and BLAKE3 digest, then streams it to the leader with the statement’s DRY RUN form, showing how many bytes it has read and sent. The dry run reports what the restore would create: each domain under the name it would have, with its status, start version, models, and resource versions, and every step it would apply. The planned model run of each domain is drawn with the transaction inspector’s view.

Restore streams the archive again and runs the same statement without DRY RUN. It stays disabled until a dry run of the current options and file has planned the restore. The result lists each step as applied, failed, or not attempted: a restore that fails at a step keeps the steps before it, and the dialog names the step. A refusal shows the leader’s reason, such as a domain that already exists or an archive that is not a valid backup archive, and changes nothing.

A restore that skips archived state says so. The result lists the restore’s warnings above its report, as nervix-cli prints them: the state the restored schedule cannot take, such as a window whose model changed, with the reason. The REPL prints the same warnings after the restore’s message.

A restore runs outside transactions, so the form refuses while the session holds one. Every attempt sends the same execution reference, statement, and archive: a lost connection or an unknown outcome streams the archive again, from its first byte, until the leader answers. If no answer arrives within about ten minutes, the dialog reports the restore’s outcome as unknown and names its execution reference.

The console never renders what an archive holds beyond the summary a backup reports and the report a restore returns.

Domain Lifecycle

The sidebar’s domain selector switches the console between domains; the top bar shows the selected domain’s state and runs START; or STOP; for it. Both statements appear in the REPL as if you had typed them. See Start And Stop.

Themes

Four themes are available from the top bar: Dark navy (the default), Pure dark, D0ZNPP, and Light.

NSPL Formatter

nervix-nspl-format puts NSPL files into their canonical shape. It is an offline tool: it never opens a session, contacts a cluster, or executes anything it reads. It parses a file, renders every statement canonically, writes the result back, and can instead report which files would change.

The shape it writes is the same one SHOW CREATE prints, so text copied between a file and a session is already formatted.

Installing

The formatter is installed alongside the other tools; see Cargo Install From GitHub.

Using It

nervix-nspl-format pipeline.nspl              # format one file in place
nervix-nspl-format .                          # format every NSPL file beneath here
nervix-nspl-format --check .                  # report instead of rewriting
nervix-nspl-format --stdout pipeline.nspl     # write to standard output
cat pipeline.nspl | nervix-nspl-format -      # read standard input
ArgumentBehavior
<PATH>...NSPL files or directories. - reads standard input and writes standard output.
--checkPrint the path of every file that is not formatted, and change nothing.
--stdoutWrite the formatted result to standard output instead of the file.

--check and --stdout cannot be combined, and - cannot be combined with other paths.

Searching Directories

A path naming a directory is searched recursively for .nspl files. The search honors your .gitignore and skips hidden directories, so it never descends into build output or vendored trees, and it does not follow symbolic links. Files are formatted in a stable order, so --check output is reproducible.

A path naming a file is formatted whatever its extension — naming a file is an explicit request. Only the recursive search filters by extension, which is why a template such as graph.nspl.upon is never picked up by nervix-nspl-format ..

There is no built-in diff. To see what would change:

nervix-nspl-format --stdout pipeline.nspl | diff -u pipeline.nspl -

Exit Codes

CodeMeaning
0Every file was handled, or under --check every file was already formatted.
1--check only: at least one file is not formatted.
2The arguments were not usable.
3A file could not be lexed or parsed. The lex or parse error is printed with the offending span, at its line in the file.
4A file could not be read or written, or was not UTF-8.
5A file could not be rendered, or its formatted output did not reparse to the same statements. This is a defect in the formatter; please report it.

A file that fails never blocks the others: every failure is reported and every other file is still formatted. A file that fails is left exactly as it was.

What It Guarantees

  • Meaning is preserved. Reparsing the output yields exactly the statements that went in. The formatter checks this itself before writing, and refuses to write if it ever fails.
  • Formatting is idempotent. Formatting an already formatted file changes nothing, byte for byte, and the file is not rewritten at all — so modification times do not churn.
  • Comments survive. A comment between two statements stays between them, and a comment above a statement stays above it. A file header and a comment trailing the last statement are kept.
  • Writes are atomic. The result is written to a temporary file in the same directory and renamed over the target, so an interrupted run never leaves a partly written file.

What It Changes

Formatting a hand-written file for the first time normalizes more than layout:

  • Keywords are uppercased, including type names, and identifiers are lowercased — NSPL lowercases identifiers when it parses them, so this is the name the cluster already uses.
  • String literals prefer single quotes; a value containing an apostrophe uses double quotes, and a value containing a newline or both quote styles is dollar-quoted, with a delimiter the value cannot close early, not even with its last characters.
  • Line endings become \n, except inside a string literal, whose line breaks are part of its value and are kept exactly as written. Trailing whitespace is dropped from comments.
  • An MQTT topic stays bare only when it is a lower-case identifier; any other topic, including one with an upper-case letter, is quoted, because MQTT topics are case-sensitive.
  • A START AT timestamp is written in UTC with an explicit +00:00 offset. A leap second, which RFC 3339 writes as :60, is written as the instant it reads as: one second past the second before it.
  • Values that were left implicit are written out, such as a relay’s CAPACITY and an ingestor’s INSTANCES.
  • Redundant parentheses are dropped from expressions, keeping only those that change grouping.

Durations and byte sizes keep the spelling you wrote: 250ms stays 250ms, and 1MiB stays 1MiB. A domain’s PERIOD and SKEW, a generator’s EACH and a polling ingestor’s EVERY are held as nanoseconds, so they are written as one whole number of the largest unit that divides them exactly: 1500ms stays 1500ms, and 60s becomes 1m.

Comments Inside A Statement

A comment between two statements has one obvious place to go. A comment inside a statement does not, so the formatter does not guess: a statement containing a comment in its body is written back exactly as you wrote it, and the statements around it are still formatted.

That statement keeps its original layout until the comment is moved out of it. This is deliberate — the alternative is relocating a comment away from the clause it explains.

NSPL Agent Skill

Nervix publishes a portable Agent Skills package that teaches compatible AI coding agents how to design, explain, review, and troubleshoot Nervix configurations written in NSPL. The maintained source is nervix-io/nervix/.agents/skills/nspl.

The skill contains guidance and references, not a Nervix client or server. Review its contents before installing it, just as you would review any third-party instructions supplied to an agent. It is distributed under the repository’s FCL-1.0-ALv2 license.

The skill discovers public language documentation through the versioned llms.txt index. That endpoint selects the latest published documentation version, whose relative Markdown links remain pinned to the same immutable build. The index intentionally covers the public NSPL configuration surface rather than Nervix internals or local-development instructions.

You do not need to clone the Nervix repository. The installer downloads the skill directly from the public GitHub repository, and the installed skill uses the public Nervix documentation.

Quick Start

1. Preview And Install The Skill

The skill becomes installable directly from the public repository as soon as it is merged into the default branch. A separate package upload, repository clone, or GitHub release is not required.

GitHub CLI 2.90.0 or newer provides the preview gh skill commands. If gh skill is unavailable, install or update GitHub CLI before continuing. Then inspect the complete skill before installing it:

gh skill preview nervix-io/nervix nspl --allow-hidden-dirs

Install it for one agent at user scope so it is available in every project. This Codex example can use claude-code, kimi-cli, or another value listed by gh skill install --help instead:

gh skill install nervix-io/nervix nspl \
  --allow-hidden-dirs \
  --agent codex \
  --scope user

The --allow-hidden-dirs option is required because the maintained source lives under .agents/skills/nspl. GitHub CLI records the repository source with the installation so it can be updated later.

Cross-Agent Installer

The skills CLI is an alternative that can install into multiple supported agents at once, including agents not currently supported by gh skill. It requires Node.js and can run directly through npx.

Install the NSPL skill for your user account so it is available in every project:

npx skills add nervix-io/nervix --skill nspl --global

The installer detects supported coding agents and asks which ones should receive the skill. It can install one canonical copy and create the agent-specific links needed by Claude Code, Codex, Cursor, Kimi Code CLI, GitHub Copilot, Gemini CLI, OpenCode, Grok Build, and other supported agents.

For a non-interactive installation into every agent supported by the installer:

npx skills add nervix-io/nervix --skill nspl --global --agent '*' --yes

Use --agent '*' only when all of those integrations are intentional. For a smaller installation, repeat --agent with the desired identifiers:

npx skills add nervix-io/nervix \
  --skill nspl \
  --global \
  --agent claude-code \
  --agent codex \
  --agent kimi-code-cli \
  --agent grok

Both installers download only the skill content needed by the selected agents. Neither command clones Nervix into the current directory.

2. Start A New Agent Session

Open the selected coding agent in your own project, configuration repository, or any other working directory. Start a new session after installation so the agent discovers the newly installed skill.

3. Ask The Agent To Use NSPL

The most portable invocation is to name the skill in the request:

Use the nspl skill to design a Nervix configuration that reads JSON orders from Kafka,
deduplicates them by order_id for 10 minutes, and publishes valid orders to another Kafka topic.
Use placeholders for broker addresses and credentials.

Agents may also expose installed skills through a skill picker or command. For example, use $nspl or /nspl when the selected agent supports that form:

$nspl Review this Nervix configuration for invalid types, missing flush policies, and branch
isolation problems: <paste NSPL here>

The agent can also select the skill automatically from an ordinary request about authoring, explaining, reviewing, or troubleshooting a Nervix configuration. Naming nspl explicitly is useful when you want to guarantee that the installed guidance is loaded.

Describe What You Need

Give the agent as much of this deployment contract as you know:

  • the input system, external topic, queue, stream, table, or other entity, and a sample payload;
  • the required internal fields and exact types, when known;
  • whether records are unbranched or isolated by fields such as tenant, device, or customer;
  • filtering, transformation, deduplication, windows, correlation, inference, lookup, or custom WASM processing;
  • operator-trusted Roto UDF requirements, including representative cases for test blocks;
  • the output system and desired payload shape;
  • delivery, ordering, batching, flush, error-handling, TLS, and credential requirements; and
  • whether you want a new graph, an explanation, a review, or help diagnosing an error.

Use this copyable template when starting from a new graph:

Use the nspl skill to produce a complete Nervix configuration.

Input system and external entity:
Sample input payload:
Required processing:
Branch key, or unbranched:
Output system and external entity:
Delivery, flush, and error requirements:
Deployment-specific values that should remain placeholders:

Return assumptions, external prerequisites, ordered NSPL command phases, and verification
commands. Do not invent undocumented syntax.

If details are missing, the skill directs the agent to ask only questions that materially change the graph or to use conspicuous placeholders and state its assumptions.

Use The Result

A generated configuration should contain:

  1. assumptions and external prerequisites;
  2. separate client-local and transactional server command phases;
  3. complete NSPL with deployment placeholders called out; and
  4. relevant SHOW, DESCRIBE, lookup, or subscription commands for verification.

An agent-generated Roto UDF should also contain test blocks for its contract. Nervix runs those tests during CREATE UDF; any rejecting test prevents the UDF from being persisted. See User-Defined Functions. For replay-sensitive functions, tests should also protect the deterministic-by-default and VOLATILE contract.

The skill does not connect to or configure a running Nervix deployment by itself. Review the result, replace placeholders, provision required external entities such as Kafka topics, and then submit the NSPL through your normal Nervix client workflow. See the NSPL Overview for the current language documentation.

Install For One Project

With GitHub CLI, change the scope from user to project:

gh skill install nervix-io/nervix nspl \
  --allow-hidden-dirs \
  --agent codex \
  --scope project

With the cross-agent installer, omit --global:

npx skills add nervix-io/nervix --skill nspl

Run the command from the target project’s root. Review the generated agent directories before committing them because project-scoped installations become part of that project rather than the Nervix repository.

Verify The Installation

List skills installed by GitHub CLI:

gh skill list

List globally installed skills:

npx skills list --global

For a project-scoped installation, run npx skills list from the project root. Confirm that nspl appears for the expected agents.

Update The Skill

Update an installation managed by GitHub CLI:

gh skill update nspl

Update a global installation to the latest version from the default branch of nervix-io/nervix:

npx skills update nspl --global

Update a project-scoped installation from that project’s root:

npx skills update nspl --project

Start a new agent session after updating when an existing session has already loaded the previous skill contents. Update the skill whenever upgrading Nervix or when the public NSPL surface changes.

For stricter reproducibility, install a reviewed commit instead of following repository updates:

gh skill install nervix-io/nervix nspl \
  --allow-hidden-dirs \
  --agent codex \
  --scope user \
  --pin <commit-sha>

Pinned installations do not update until they are reinstalled with a different commit.

Trust And Safety

Keeping the skill in the Nervix repository makes its changes visible in the same version history and code review as NSPL and its documentation. It does not make installed skills inherently trusted: a skill supplies instructions to an agent, and future updates can change those instructions.

  • Preview the skill before installing or updating it.
  • Review changes to SKILL.md and every bundled reference as carefully as code changes.
  • Pin a reviewed commit when reproducibility matters more than automatic updates.
  • Never add credentials or deployment secrets to a skill directory.
  • Remember that an agent follows a skill with the permissions already available to that agent; the skill itself grants no additional access.

The current NSPL skill contains instructions, public-documentation references, and display metadata. It contains no executable scripts.

Manual Installation

If Node.js or the skills CLI is unavailable, download the complete nspl skill directory and copy it into a skill location supported by the target agent. Keep SKILL.md, references/, and any future bundled resources together.

Common project-level locations include:

  • .agents/skills/nspl/ for Codex, Kimi Code CLI, GitHub Copilot, Cursor, Gemini CLI, Antigravity, and OpenCode
  • .claude/skills/nspl/ for Claude Code
  • .grok/skills/nspl/ for Grok Build

For a manual update, replace the entire installed nspl directory with the latest directory from the repository. Do not update only SKILL.md, because its bundled references are part of the same versioned skill.

Manual

This section is the user-facing guide to Nervix. If you are new to Nervix, the Quickstart builds a first graph step by step; this section is the full reference behind it.

Nervix is experimental software in active development. It is intended for evaluation, local testing, and design exploration. It is not suitable for real production workloads.

Use it for:

  • understanding product boundaries and non-goals
  • learning the NSPL surface
  • understanding the currently supported transport integrations, including Kafka and Pulsar
  • defining schemas, resources, relays, ingestors, processors, and emitters
  • planning branch capacity from the documented cost structure and current signals
  • working with sessions and the Rust client
  • following end-to-end examples

To write custom WASM processor guests in Rust, continue to the Rust WASM Guest SDK. If you want implementation details, runtime internals, or control-plane/data-plane architecture, continue to Architecture And Internals.

What It Is Not

Nervix is not an exactly-once transactional engine.

With ACK-based ingestion modes, Nervix behaves as an at-least-once system. Without ACK tracking, it behaves as at-most-once. That is an intentional design choice: Nervix prioritizes throughput and low latency over transactional persistence on every action.

Operationally, Nervix is much closer to a high-performance in-memory relaying runtime with snapshot-style replicated state than to a transactional database or durable event log.

Important consequences:

  • Raft-backed strong consistency applies to the control plane, not to the data plane.
  • Records moving through ingestors, processors, and emitters do not cross an exactly-once transactional boundary.
  • ACKs control retry and replay behavior. They do not provide exactly-once execution and are not persisted.
  • Runtime state persistence and replication are practical recovery mechanisms for selected execution node state, not atomic commit boundaries across the whole graph.
  • Nervix does not provide arbitrary-key queries against live runtime state from expressions. Materialized state is readable only inside its own branch. Cross-key access is out of scope because serving arbitrary keyed reads is database behavior. Nervix’s database-like surfaces, including materialized streams and resource-backed lookups, are structurally amortized instead. See Choosing An Enrichment Mechanism.

The data plane operates in two broad modes:

  1. Pure in-memory stream processing. Records traveling through relays and runtime nodes stay in memory while they are being routed and processed. ACK guards, ACK tokens, and ACK maps are part of this hot path and are not persisted.
  2. Snapshot-style replicated runtime state. Selected runtime state such as Kafka domain offsets, deduplicator state, and materialized relay state is snapshotted to persistent storage and replicated to followers. WASM guest state is the one kind a source acknowledgement waits for: an ATTACHED WASM processor checkpoints its guest state to stable storage and to every assigned replica before it acknowledges the inputs the checkpoint covers. That checkpoint still does not make the path exactly once; see Recovery, Replay And Duplicates.

The execution graph itself is different: NSPL models and domain schedules are control-plane state and are persisted with strong consistency guarantees before the data plane runs them.

Each runtime graph node is scheduled onto one cluster node and may have replicas. Replicas maintain eventually consistent copies of replicated runtime state and are designed to catch up through the replication channel.

NSPL Overview

NSPL is the language used to define the Nervix graph.

Language Stability

NSPL has reached alpha. Its period of rapid, broad experimentation is over, and large backward-incompatible changes are no longer expected. The language is more stable, but alpha is not a promise of backward compatibility: NSPL will continue to evolve, and focused breaking changes may still be made when they materially improve the language model or correctness.

AI coding agents can use the portable NSPL Agent Skill to design, explain, review, and troubleshoot Nervix configurations. The guide explains installation without cloning this repository, skill invocation, useful request details, expected output, and updates.

For application-owned producers and consumers, Client Ingestors And Emitters connects the native endpoint grammar to a complete graph and its execution contracts.

The current top-level surface includes:

  • domain lifecycle statements
  • user statements
  • create/alter/drop model statements
  • resource lifecycle statements
  • session subscription statements
  • explicit transaction controls: BEGIN, COMMIT, and REVERT
  • describe and show commands

Core create statements:

CREATE [IF NOT EXISTS] USER <name> WITH PASSWORD '<password>';

CREATE [IF NOT EXISTS] SCHEMA <name> (<field> <type> [OPTIONAL], ...);
CREATE [IF NOT EXISTS] WIRE JSON SCHEMA <name> MODE STRICT|LOOSE (<field> <json_type> [OPTIONAL], ...);
CREATE [IF NOT EXISTS] WIRE CBOR SCHEMA <name> MODE STRICT|LOOSE (<field> <cbor_type> [OPTIONAL], ...);
CREATE [IF NOT EXISTS] WIRE AVRO SCHEMA <name> MODE STRICT|LOOSE (<field> <avro_type> [OPTIONAL], ...);

CREATE [IF NOT EXISTS] CODEC <name>
  FROM WIRE JSON SCHEMA <wire_schema>
  TO SCHEMA <schema>
  [ENCODE <field> AS RFC3339, ...];

CREATE [IF NOT EXISTS] CODEC <name>
  FROM WIRE CBOR SCHEMA <wire_schema>
  TO SCHEMA <schema>
  [ENCODE <field> AS RFC3339, ...];

CREATE [IF NOT EXISTS] CODEC <name>
  FROM WIRE AVRO SCHEMA <wire_schema>
  TO SCHEMA <schema>
  [ENCODE <field> AS RFC3339, ...];

CREATE [IF NOT EXISTS] CODEC <name>
  FROM JSON|YAML|TOML|XML|CBOR
  TO SCHEMA <schema>
  WITH JAQ TRANSFORMATIONS
  [ON INGESTION '<program>']
  [ON EMITTING '<program>' [ON EMITTING BATCH '<program>']];

CREATE [IF NOT EXISTS] CODEC <name>
  FROM PROTOBUF
  USING RESOURCE <resource> VERSION <n> | LATEST
  CONFIG {'file' = '<path.proto>', 'include' = '.'}
  MESSAGE '<package.Message>'
  [BATCH MESSAGE '<package.BatchMessage>']
  TO SCHEMA <schema>
  WITH JAQ TRANSFORMATIONS
  [ON INGESTION '<program>']
  [ON EMITTING '<program>' [ON EMITTING BATCH '<program>']];

CREATE [IF NOT EXISTS] CODEC <name>
  FROM SYSLOG
  TO SCHEMA <schema>;

CREATE [IF NOT EXISTS] RELAY <name> SCHEMA <schema> [CAPACITY <n>]
  [WITH MATERIALIZED STATE LAST BY TIMESTAMP];

CREATE [IF NOT EXISTS] PLACEMENT <name>
  FROM <runtime_node> [, <runtime_node> ...]
  TO <runtime_node> [, <runtime_node> ...]
  REQUIRE COLOCATION|PREFER COLOCATION|NEUTRAL|SUGGEST SEPARATION
  [RANK <n>];

JAQ-backed codecs require at least one direction. When both are present, ON INGESTION precedes ON EMITTING.

Core alter statements:

ALTER DOMAIN SET PLACEMENT
  REQUIRE COLOCATION|PREFER COLOCATION|NEUTRAL|SUGGEST SEPARATION;

ALTER RELAY <name>
  SET CAPACITY <n> |
  SET SCHEMA <schema> |
  SET BRANCHED BY <branch> |
  SET UNBRANCHED |
  SET MATERIALIZED STATE LAST BY TIMESTAMP |
  DROP MATERIALIZED STATE
  [, ...];

ALTER JUNCTION <name>
  ADD FROM <relay> [WHERE <expr>] |
  DROP FROM <relay> |
  ALTER FROM <relay> SET WHERE <expr> |
  ALTER FROM <relay> DROP WHERE |
  SET COLLECT FOR <duration> [MAX BATCH SIZE <bytes>] |
  DROP COLLECT |
  SET FILTER WHERE <expr> |
  DROP FILTER WHERE |
  SET ATTACHED | SET DETACHED |
  SET BRANCHED BY <branch> | SET UNBRANCHED |
  ADD|DROP|ALTER MATERIALIZED STATE ... |
  ADD|DROP|REPLACE ROUTE ...
  [, ...];

ALTER DEDUPLICATOR <name>
  SET DEDUPLICATE ON <expr> [, <expr> ...] |
  SET MAX TIME <duration> |
  ADD|DROP|ALTER FROM ... |
  SET|DROP COLLECT ... |
  SET|DROP FILTER WHERE ... |
  SET ATTACHED | SET DETACHED |
  SET BRANCHED BY <branch> | SET UNBRANCHED |
  ADD|DROP|ALTER MATERIALIZED STATE ... |
  ADD|DROP|REPLACE ROUTE ...
  [, ...];

ALTER REORDERER <name>
  SET BY <expr> [, <expr> ...] |
  SET MAX TIME <duration> |
  ADD|DROP|ALTER FROM ... |
  SET|DROP COLLECT ... |
  SET|DROP FILTER WHERE ... |
  SET ATTACHED | SET DETACHED |
  SET BRANCHED BY <branch> | SET UNBRANCHED |
  ADD|DROP|ALTER MATERIALIZED STATE ... |
  ADD|DROP|REPLACE ROUTE ...
  [, ...];

ALTER EMITTER <name>
  ADD FROM <relay> [WHERE <expr>] |
  DROP FROM <relay> |
  ALTER FROM <relay> SET WHERE <expr> |
  ALTER FROM <relay> DROP WHERE |
  SET TO <full sink clause including MODE> |
  SET MODE <transport-specific publishing mode body> |
  SET CLIENT <client> |
  SET ENCODE USING <codec> | DROP ENCODE |
  SET COLLECT FOR <duration> [MAX BATCH SIZE <bytes>] | DROP COLLECT |
  SET ATTACHED | SET DETACHED |
  SET BATCH MAX MESSAGES <n> MAX SIZE <bytes> | DROP BATCH |
  SET FLUSH EACH <duration> MAX BATCH SIZE <bytes> | SET FLUSH IMMEDIATE |
  SET COMMIT EACH <duration> MAX SIZE <bytes>
  [, ...];

ALTER <CONNECTOR> <ENTITY> INGESTOR <name>
  SET FROM <body> |
  SET QUIESCE <quiesce body> |
  SET DECODE USING <codec> |
  SET TIMESTAMP NOW | SET TIMESTAMP AT <field> | DROP TIMESTAMP |
  SET FILTER WHERE <expr> | DROP FILTER WHERE |
  ADD|DROP|REPLACE ROUTE ... |
  SET GENERAL ERROR IGNORE|LOG
  [, ...];

ALTER REINGESTOR <name>
  ADD|DROP|ALTER FROM ... |
  SET|DROP COLLECT ... |
  SET|DROP FILTER WHERE ... |
  SET ATTACHED | SET DETACHED |
  ADD|DROP|ALTER MATERIALIZED STATE ... |
  ADD|DROP|REPLACE ROUTE TO <relay> <construction> BRANCHED BY <branch>|UNBRANCHED ...
  [, ...];

ALTER GENERATOR <name>
  SET MATERIALIZED STATE <relay> |
  SET EACH <duration> |
  SET BRANCHED BY <branch> | SET UNBRANCHED |
  ADD|DROP|REPLACE ROUTE ...
  [, ...];

ALTER PLACEMENT <placement>
  SET POLICY REQUIRE COLOCATION|PREFER COLOCATION|NEUTRAL|SUGGEST SEPARATION |
  SET RANK <n> |
  DROP RANK |
  SET FROM <runtime_node> [, ...] TO <runtime_node> [, ...] |
  RENAME TO <name>
  [, ...];

ALTER SCHEMA <name>
  ADD FIELD <field> <type> [OPTIONAL] [SENSITIVE],
  DROP FIELD <field>,
  RENAME FIELD <field> TO <field>,
  ALTER FIELD <field> SET TYPE <type>,
  ALTER FIELD <field> SET|DROP OPTIONAL,
  ALTER FIELD <field> SET|DROP SENSITIVE;

ALTER WIRE JSON|CBOR|AVRO SCHEMA <name>
  MODE STRICT|LOOSE,
  ADD FIELD <field> <wire_type> [OPTIONAL],
  DROP FIELD <field>,
  RENAME FIELD <field> TO <field>,
  ALTER FIELD <field> SET TYPE <wire_type>,
  ALTER FIELD <field> SET|DROP OPTIONAL;

Operations in one ALTER RELAY, ALTER JUNCTION, ALTER DEDUPLICATOR, ALTER REORDERER, ALTER EMITTER, ALTER <CONNECTOR> <ENTITY> INGESTOR, ALTER REINGESTOR, ALTER GENERATOR, or ALTER PLACEMENT execute in written order. An ingestor’s ALTER repeats the connector and entity the ingestor reads, such as ALTER KAFKA TOPIC INGESTOR, and never changes them. See Streams And State for relay operations, Processors for the full processor operation shapes, and Emitters and Ingestors for boundary-node operations. See Schemas And Codecs for ordered schema changes and atomic migrations, and Placement Policies for placement rank, path coverage, and enforcement.

All CREATE statements may optionally insert IF NOT EXISTS immediately after CREATE.

When IF NOT EXISTS is present and the named entity already exists, the command succeeds as a no-op instead of failing. Command responses also mark that condition explicitly with already_existed = true.

The rest of the graph is built with:

  • CREATE DOMAIN, CREATE UNPACED DOMAIN, CREATE PACED DOMAIN
  • CREATE USER
  • CREATE CLIENT
  • CREATE VHOST
  • CREATE ENDPOINT
  • CREATE <CONNECTOR> <ENTITY> INGESTOR, such as CREATE KAFKA TOPIC INGESTOR or CREATE CLIENT PRODUCER INGESTOR; see Ingestors
  • CREATE GENERATOR
  • CREATE INFERENCER
  • CREATE JUNCTION
  • CREATE DEDUPLICATOR
  • CREATE REINGESTOR
  • CREATE EMITTER
  • CREATE PLACEMENT
  • CREATE HASH MAP

CREATE DOMAIN <name> is the short spelling for CREATE UNPACED DOMAIN <name>.

Domain creation may declare a placement default, and named placement rules may overlay paths in the active domain. Omission means NEUTRAL, which preserves ordinary scheduler heuristics. See Placement Policies for the four policy levels and lifecycle commands.

Multiple NSPL statements in one request must be wrapped in an explicit transaction. BEGIN creates a replicated transaction and returns its id, COMMIT executes the queued statements, and REVERT drops them without applying them. A transaction survives an unclean disconnect or leader failover; the CLI and web console re-attach before sending another command. Sending multiple statements without BEGIN is rejected.

CREATE DOMAIN production;
USE production;

BEGIN;
CREATE SCHEMA notification (user_id I64);
COMMIT;

BEGIN binds the transaction to the selected domain, which must already exist, and every queued statement must select that same domain. BEGIN inside an active transaction is an error. COMMIT and REVERT also require an active transaction. Queueable content is limited to that domain’s model mutations, including REBIND RESOURCE, domain configuration and lifecycle, and CREATE RESOURCE. CREATE DOMAIN, CREATE USER, most read-only statements, subscriptions, resource uploads, and node administration are not valid inside a transaction and must be sent separately. SHOW TRANSACTIONS; and DESCRIBE TRANSACTION run separately beside an attached transaction without entering its queue or changing its binding. Use SHOW TRANSACTIONS; to inspect live transactions and retained outcomes. See Control Plane for attach, failover, commit-step, expiry, and limit semantics.

DESCRIBE TRANSACTION reads one transaction’s impact report without changing that transaction:

Transaction Quiescence And Impact Inspection explains the planned and actual scopes, affected topology, and retained outcomes reported by this statement.

DESCRIBE TRANSACTION [ '<id>' ] [ OPERATION <n> ] [ FORMAT TEXT | JSON ];

Without an id it reads the transaction attached to the session. A quoted id, as BEGIN and SHOW TRANSACTIONS print it, reads that transaction instead; it must belong to the same user, and reading it never attaches it or selects its domain. OPERATION <n> names an accepted operation, numbered from 1 in the order the statements were queued, and leads the report with that operation and the execution step it belongs to without narrowing the rest. TEXT is the default; FORMAT JSON prints the same report as one JSON document. Both formats contain the complete report. Large affected graphs and repeated execution steps are not paginated or shortened; plan client response memory and output handling for the transaction’s accepted operation count and affected topology. In the Rust client, reading the attached transaction at its current queue position refreshes the preview supplied with a later COMMIT. An inspection of another id leaves that preview and the session binding untouched. After a stale-preview refusal, inspect the attached transaction again before retrying the commit.

The statement is the one read-only statement allowed while a transaction is open. It is answered before queueing, so it never becomes transaction content and never consumes an operation number, but it must be sent on its own rather than inside a multi-statement request:

// The transaction attached to this session.
DESCRIBE TRANSACTION;
// Its second queued operation first.
DESCRIBE TRANSACTION OPERATION 2;
// A transaction by id, as one JSON document.
DESCRIBE TRANSACTION '01a0ca64-7062-7302-ac1e-43138ccc2067' FORMAT JSON;

For two queued schemas, the text report reads:

transaction: 01a0ca64-7062-7302-ac1e-43138ccc2067
domain: production
state: OPEN
operations: 2 accepted, 0 applied, 2 pending
report: COMPLETE
planning basis: 10b2c6ecf022ec0f2d9149fe98039cc065533d326e5dba7ea74660f64b893ef8
quiesce level: DYNAMIC
pause: NO_PAUSE
operation 1: CREATE_CONFIGURATION kind=schema name=notification
  execution step: 1-2
  reason: CONFIGURATION kind=schema name=notification aspect=ENTITY_CREATED
operation 2: CREATE_CONFIGURATION kind=schema name=delivery
  execution step: 1-2
  reason: CONFIGURATION kind=schema name=delivery aspect=ENTITY_CREATED
execution step 1-2: planned DYNAMIC, actual DYNAMIC, outcome UNATTEMPTED
  pause: NO_PAUSE
  effect: configuration CREATED kind=schema name=delivery operations=2
  effect: configuration CREATED kind=schema name=notification operations=1
  effect: topology before nodes=0 edges=0 after nodes=2 edges=0

A paused subgraph lists each paused node and admission gate under its pause: line with the operations that require it, and a finished transaction reports the APPLIED or FAILED outcome each execution step recorded, for as long as its outcome is retained. An inspection that cannot be answered says why: no transaction is attached, the id is unknown or its retention has expired, the transaction belongs to another user, the operation number is past the operations it accepted, or no coherent report can be read for it, as for a transaction that ended before planning any operation. See Control Plane for what each transaction state reports.

Ingestors, relay-consuming processors, and generated-output processors use optional node-level arrival filters and route-local construction. Relay-consuming processors may also attach a source-level filter to FROM:

FROM <relay> [WHERE <expr>], ...
[COLLECT FOR <duration> [MAX BATCH SIZE <bytes>]]
[FILTER WHERE <expr>]
TO <relay>
  [INHERIT ...]
  [SET <field> = <expr>, ...]
  [WHERE <expr>]
  FLUSH ...
  ON MESSAGE ERROR ...
[TO <relay> ...]

COLLECT FOR is optional on graph nodes that consume relay input through FROM. It is unavailable to ingestors, which read external sources rather than relays. When omitted, each incoming Arrow batch proceeds directly to node execution. When present, Nervix maintains an input batch independently for each source relay and concrete branch, then executes the node when the duration expires or the optional size boundary is reached. Correlators configure the clause independently after each LEFT FROM and RIGHT FROM relay list. Emitters place it after their complete FROM <relay> [WHERE ...] [, ...] list. All emitter inputs declare the same payload schema, but they may belong to differently named branches; collection remains independent for each source relay and concrete branch. Generators do not read a FROM relay and therefore have no input collection clause. The duration is measured on the domain clock and starts when the source and branch collector changes from empty to non-empty. A paced domain’s TIME RATE scales that wait.

FROM ... WHERE runs first. FILTER WHERE runs next, before the node accepts rows into its state, buffer, inferencer, or guest. Every route then creates a new empty output, performs its own ordered construction, finalizes the declared schema, and evaluates its route WHERE. Required fields must be initialized; omitted optional fields become typed nulls. There is no implicit identity transformation and no global SET or INHERIT.

Input collection assembles relay batches before that execution sequence. Its time and size include rows that a later source or node filter may reject. COLLECT FOR controls input delivery to the node; route-local FLUSH controls output delivery from the node. The two policies are independent.

Transforming routes use one working message from input through finalization. Transforming routes—ingestors after decoding, reingestors, junctions, deduplicators, reorderers, and codec emitters—may use INHERIT. Generators, windows, inferencers, WASM processors, correlators, and direct emitters are set-only and reject INHERIT. Generated inferencer and WASM state is an immutable read source shared independently by every route; it is not an automatically initialized output and it is not exposed as input or message.

This surface is available on:

  • CREATE <CONNECTOR> <ENTITY> INGESTOR
  • CREATE INFERENCER
  • CREATE JUNCTION
  • CREATE DEDUPLICATOR
  • CREATE REINGESTOR
  • CREATE WASM PROCESSOR
  • CREATE WINDOW PROCESSOR
  • CREATE EMITTER

Every TO destination on a flush-based node requires FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE; there are no hidden defaults. Each concrete output branch owns its buffer and deadline. FLUSH EACH measures its duration on the domain clock, beginning when data first enters an empty buffer. Window processors use WIDTH and STEP, and WASM processors use guest-owned output cadence instead of FLUSH.

This is the authoritative FLUSH IMMEDIATE timing rule. During normal processing, FLUSH IMMEDIATE starts a system-owned physical 100 µs minimum batching timeout when data first enters an empty route buffer. Domain pacing never scales this monotonic wait. The route flushes when that timeout expires, allowing nearby arrivals to remain in one Arrow batch instead of collapsing to one batch per message. FLUSH IMMEDIATE has no size boundary; shutdown and error handling may still force pending data out.

Treat each required FLUSH clause as workload-specific operational tuning. FLUSH IMMEDIATE minimizes time spent waiting beyond the system-owned batching window, while FLUSH EACH <duration> MAX BATCH SIZE <bytes> emits when either configured boundary is reached. Choose both values for the route’s traffic, downstream behavior, and branch cardinality. Values in examples are illustrative, not recommended defaults. Two measured facts decide what a FLUSH clause can buy.

MAX BATCH SIZE only clamps a batch; it never grows one. The batch a route emits is roughly its arrival rate multiplied by the FLUSH EACH interval, cut off at the byte cap, so raising a cap that never fires changes nothing and the only way to grow batches is a longer interval, paid in latency. Upstream of every route, ingestors build their first Arrow batches from source groups that close at 1,024 messages, or earlier after a 5 ms idle gap, independent of any FLUSH policy; a payload a JAQ-backed codec unfolds is never split, so its group may close above 1,024. Larger batches downstream come only from route buffering across an interval. Read actual batch sizes from the nervix_messages_per_batch histogram instead of inferring them.

Larger batches do not make program execution faster beyond about a thousand rows. Execution is columnar, so its fixed per-batch cost is amortized quickly. The table shows per-row throughput of the VM batch-size sweep (crates/nervix-vm/benches/vm.rs, criterion, one development machine, September 2026) relative to the 1,024-row value for each program shape: at 64 rows it is 12–60% of that value and climbs steeply; above 1,024 rows most shapes are flat within measurement noise, float arithmetic dips, and only the cheapest shape, nullable casts, keeps gaining, because batches above 1,024 rows execute on the node’s data workers and their per-batch admission and hand-off is no longer amortized by more work. Absolute rates are machine-specific; the shape is not.

program shape642561,0244,09616,38465,536
integer arithmetic + filter21%53%100%98%97%100%
integer comparison19%52%100%96%98%108%
float arithmetic28%64%100%84%71%77%
nullable casts12%41%100%70%133%137%
string kernels24%61%100%82%105%106%
text transforms47%82%100%89%97%105%
list builtins60%87%100%82%93%99%

Larger batches genuinely pay only at boundaries that do work per batch rather than per record:

  • OTEL emitters send one export request per batch, so batch size sets request count and the compression ratio.
  • Iceberg emitters write one staging file per flush and one commit per COMMIT EACH ... MAX SIZE boundary, so batch and commit sizes set file counts and Parquet file sizes.
  • Inferencers whose tensor shapes declare a batch dimension run one model invocation per batch.
  • WASM processors serialize one Arrow IPC payload per batch, and cluster interconnect sends one frame per batch, with a fixed 8 MiB frame limit.

Broker emitters — Kafka, Pulsar, NATS, RabbitMQ, MQTT, Redis, ZeroMQ, Sentry, and syslog — encode and publish one record at a time whatever the batch size; wire batching there belongs to the client, such as Kafka’s linger.ms and batch.size. SQS BATCH groups at most ten records per request, and database sinks split every flush into statements of at most the batching clause’s MAX MESSAGES records. On such routes a larger MAX BATCH SIZE or a longer interval buys only fewer flush cycles, at the cost of latency, memory, and coarser failure and retry granularity: prefer the shortest interval the sink tolerates and let the byte cap protect memory.

MAX BATCH SIZE measures the logical Arrow data in the current batch slice: value buffers plus the offsets and validity data needed to represent those values. It does not count unused buffer capacity or Rust and Arrow object overhead.

SET assignments execute left to right and repeated targets are valid. A later assignment may read an earlier value through the bare field or output.<field>. INHERIT ALL, INHERIT ALL EXCEPT ..., and explicit INHERIT field, ... copy compatible same-named input fields. UNSET is not part of NSPL.

Supported expression surface:

  • literals: I64 integers, F64 floats, BOOL, and STRING
  • identifiers: field references from the current row
  • arithmetic: +, -, *, /, %
  • comparisons: =, !=, >, <, >=, <=
  • null-safe equality: IS DISTINCT FROM, IS NOT DISTINCT FROM
  • membership and ranges: value [NOT] IN (constant, ...) and value [NOT] BETWEEN low AND high
  • boolean logic: AND, OR, NOT
  • conditionals: IF condition THEN value ELSE value END, searched CASE WHEN condition THEN value ... [ELSE value] END, and simple CASE operand WHEN match THEN value ... [ELSE value] END
  • parentheses for nesting and precedence control
  • explicit conversions only: expr AS TYPE, which fails a message whose value does not convert, and TRY_CAST(expr AS TYPE), which yields a typed null for it instead
  • JSON extraction from STRING values holding JSON: JSON_VALUE(doc, '$.path' AS TYPE), which fails a message whose document or value does not read as TYPE, TRY_JSON_VALUE(doc, '$.path' AS TYPE), which yields a typed null for it instead, and JSON_EXISTS(doc, '$.path'); TYPE may be a scalar, VEC<...>, or ARRAY<..., n> type

Expression Functions owns the semantics of every form above, including conditional typing, the reserved words, and the rules for where each function may run.

Expression types match the full Nervix internal schema type set:

  • integers: U8, I8, U16, I16, U32, I32, U64, I64
  • floating point: F32, F64
  • other scalars: BOOL, STRING, BYTES, DATETIME
  • lists: ARRAY<...> and VEC<...>

AS and TRY_CAST(expr AS TYPE) convert between scalar types and accept long and short spellings, such as AS UINT8 or AS U8; see Conversions for every spelling and the values each conversion rejects.

Supported built-ins include string, null-handling, numeric, regex, and contextual functions such as:

  • string transforms: lower, upper, trim, length, concat
  • null handling: coalesce, is_null, nullif
  • numeric and predicates: abs, contains, starts_with, ends_with
  • extrema: greatest, least, clamp
  • datetime: date_part, date_trunc, date_bin, date_add, date_diff, to_unix, from_unix, format_datetime, parse_datetime, with calendar units and explicit time zones
  • IP addresses and networks: ip_from_string, ip_to_string, ip_family, ip_trunc, ip_in_network, ip_unmap, is_ip_address, over 4- or 16-octet BYTES addresses
  • URLs: url_scheme, url_host, url_port, url_path, url_query, url_fragment, url_query_value, url_query_values, url_decode, is_url
  • contextual functions: now, uuid_v4, uuid_v7

See Expression Functions for the full current function list, signatures, and aliases.

User-defined calls always use udf::<name>(...). The explicit namespace means adding a builtin can never shadow a UDF or change existing user code. See Choosing An Extension Tier.

General expression rules:

  • builtin calls may be nested or chained, for example lower(trim(raw))
  • arithmetic and predicate expressions may also be nested with parentheses
  • there is no implicit cast insertion; type mismatches must be resolved with an explicit AS ... or TRY_CAST(... AS ...)
  • relay names are graph references, never expression qualifiers
  • language scopes are message, input, output, branch, left, right, relay_state.<relay>, metadata, partial_output, and error; availability depends on context
  • transforming construction reads the working message; output.field reads only an already initialized output field
  • generated routes allow bare reads from immutable generated state until the same-named output is initialized; message and input are unavailable
  • branch.field must be explicit and is unavailable in successful emitter expressions
  • supported ingestors read headers with read_header(name) and read_headers(name); Kafka exposes typed metadata.topic, metadata.partition, and metadata.offset, while Syslog exposes optional metadata.peer_addr
  • supported codec emitters stage ordered write_header(name, value) calls in INVOKE

Example:

CREATE BRANCH by_tenant
  SCHEMA tenant_branch TTL 5m;

CREATE HTTP ENDPOINT INGESTOR notifications_in
  FROM ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING notification_codec
  FILTER WHERE input.active
  TO notifications
    INHERIT ALL EXCEPT raw
    SET amount = message.amount + 1,
        normalized = lower(input.raw)
    BRANCHED BY by_tenant SET tenant = message.tenant
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Another example showing nested conditions and chained calls:

CREATE EMITTER outbound
  FROM notifications
  TO KAFKA kafka_main TOPIC notifications_out
    MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING notification_codec
  INHERIT ALL EXCEPT raw
  SET normalized = lower(trim(input.raw)), magnitude = abs(input.amount)
  WHERE (output.active AND output.amount > 5) OR contains(lower(trim(input.raw)), 'urgent')
  INVOKE write_header('tenant', input.tenant)
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The leader parses and validates these structured expressions immediately when the statement is applied. Models never store raw executable NSPL, and runtime execution never reparses expressions.

Generators use a narrower surface:

CREATE GENERATOR synth_notifications
  USING MATERIALIZED STATE notifications
  EACH 100ms
  BRANCHED BY by_tenant
  TO generated_notifications
    SET user_id = relay_state.notifications.user_id,
        amount = relay_state.notifications.amount
    FLUSH EACH 1s MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG;

Generator-specific rules:

  • only SET is allowed
  • exactly one materialized relay is declared and is accessed as relay_state.<relay>.<field>
  • every route sees the same immutable state snapshot for one tick
  • FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE is mandatory and controls buffered emission
  • paced domains evaluate both generator cadence and flush cadence against the bound logical clock; unpaced domains receive actual UTC through the same domain capability, with no missing-clock fallback

Runtime Node Error Policies

The Errors And Diagnostics chapter explains how failures are classified before these policies receive them.

Every TO route on an ingestor or relay-consuming processor must declare its message error policy after that route’s construction clauses:

ON MESSAGE ERROR IGNORE | LOG | SEND TO error_stream
SET error_reference = error.reference,
    error_code = error.code,
    source_id = input.id,
    attempted_total = partial_output.total

An ingestor additionally declares its node-level general policy after the source configuration:

ON GENERAL ERROR IGNORE | LOG

Emitters attach ON MESSAGE ERROR to their single external route and retain ON GENERAL ERROR at node level. WASM processors keep ON GLOBAL ERROR at node level for guest failures that are not tied to a message.

MESSAGE errors carry a stable UUIDv7 reference, code, operation, optional operation index, sorted affected field paths, timestamp, and a non-sensitive message. Error construction can read the eligible original input, the exact materialized-state snapshot, an all-optional partial_output, and the structured error scope. The error route preserves the branch in which the failure occurred. On flush-based nodes, SEND TO records are buffered independently for the owning TO route and concrete branch, then emitted when that route’s FLUSH interval or maximum batch size fires. They are not dispatched immediately. Error-record construction failures are logged and no-acked without recursively invoking the same policy. General and global errors remain node-wide and do not inherit a route-local FLUSH policy.

Client definitions are key-value based and may optionally mount a resource for file-backed settings such as TLS material:

CREATE [IF NOT EXISTS] CLIENT <name>
  TYPE <client_type>
  [MOUNT <resource> VERSION <n>|LATEST]
  CONFIG {
    '<key>' = '<value>'
  };

WebSocket clients and endpoints may also reference a signaling protocol:

CREATE [IF NOT EXISTS] SIGNALING PROTOCOL <name>
  FORMAT JSON | YAML | TOML | XML | CBOR | RAW
       | PROTOBUF USING RESOURCE <resource> VERSION <n> | LATEST
         CONFIG { '<key>' = '<value>' }
         SEND MESSAGE '<message_type>' WAIT MESSAGE '<message_type>'
  ON CONNECT
  ( SEND JAQ '<program>'[, '<program>'...]
  | WAIT JAQ '<matcher>'[, '<matcher>'...]
  | WAIT JAQ '<matcher>' [CAPTURE '<program>'] [ACCEPT DATA] )+
  [FAIL JAQ '<matcher>'[, '<matcher>'...]]
  TIMEOUT <duration>;

CREATE [IF NOT EXISTS] CLIENT <name>
  TYPE WEBSOCKETS WITH SIGNALING PROTOCOL <name>
  CONFIG {
    'endpoint' = 'wss://example.com/ws'
  };

Each SEND JAQ program must produce exactly one value, which is serialized in the declared format. Each WAIT JAQ matcher is satisfied by any output that is neither null nor false, so it can assert the fields that matter and ignore the connection ids and timestamps real services add. FAIL JAQ matchers abort the handshake immediately with the matched value as the reason.

Steps run strictly in written order: a step completes before the next starts, so a request can depend on an earlier reply. A WAIT step completes when every matcher it lists is satisfied, in any arrival order. CAPTURE records values from the matched frame, which every later program reads through $state.

ACCEPT DATA says where payload starts flowing to the relay — on ON CONNECT for the first frame, or on the WAIT step whose completion proves the peer is streaming. Frames arriving before that are dropped rather than buffered. A FAIL JAQ guard on a step aborts during that step; the optional one before ON CONNECT applies throughout.

Current built-in client transport kinds include:

  • KAFKA
  • PULSAR
  • HTTP
  • SENTRY
  • OTEL
  • PROMETHEUS
  • RABBITMQ
  • REDIS
  • MQTT
  • NATS
  • ZEROMQ
  • SYSLOG
  • SQS
  • WEBSOCKETS
  • S3
  • GCS
  • AZURE_BLOB
  • CLICKHOUSE
  • POSTGRES
  • MYSQL
  • MONGODB
  • ICEBERG_REST

Resource management commands:

CREATE [IF NOT EXISTS] RESOURCE <name>;
UPLOAD RESOURCE <name> VERSION '<local_directory>';
DESCRIBE RESOURCE <name>;
DESCRIBE RESOURCE <name> VERSION <n>;
REBIND RESOURCE <name> TO VERSION <n> | LATEST
  [FOR <kind> <name> [, <kind> <name> ...]];

TLS-capable VHOSTs:

CREATE [IF NOT EXISTS] VHOST <name> <hostname>, ...
  [WITH TLS <resource> VERSION <n> | LATEST];

Every resource binding names its version: WITH TLS on a VHOST, USING RESOURCE on a protobuf codec, protobuf signaling protocol, inferencer, or WASM processor, FROM RESOURCE on a hash map, and MOUNT on a client. VERSION <n> binds that completed version. VERSION LATEST binds the highest completed version when the statement is applied, and the stored model keeps that number, so a later upload never moves the binding. See Versioning.

REBIND RESOURCE atomically moves all current usages, or an exact kind-qualified FOR selection, to one completed version. The resource itself is unchanged. Every selected replacement validates before any model is written; a no-op selection succeeds without a write. Resource descriptions list all bound usages, while a version-qualified description lists only usages pinned there.

Backup and restore commands, which read and write archive files on the client’s machine:

BACKUP CLUSTER TO '<file>' [WITHOUT RESOURCES] [WITHOUT STATE | WITHOUT PAUSE | TIMEOUT <duration>];
BACKUP DOMAIN [<name>] TO '<file>' [WITHOUT RESOURCES] [WITHOUT STATE | WITHOUT PAUSE | TIMEOUT <duration>];
DESCRIBE BACKUP '<file>' [FORMAT TEXT | JSON];
RESTORE CLUSTER FROM '<file>' [RESUME] [ON EXISTING USER FAIL | SKIP | REPLACE] [DRY RUN] [WITHOUT STATE | WITHOUT SOURCE OFFSETS];
RESTORE DOMAIN <name> [AS <new_name>] FROM '<file>' [RESUME] [DRY RUN] [WITHOUT STATE | WITHOUT SOURCE OFFSETS];

BACKUP is admitted and run by the leader like other persistent commands, and the client downloads the archive it assembles. DESCRIBE BACKUP runs in nervix-cli without a server. RESTORE streams the archive to the leader, which verifies and plans the whole restore before it changes anything, and then recreates the users, the domains stopped, their resource versions under their archived numbers, their models, and their compatible runtime checkpoints, including materialized relay rows. RESUME makes a complete restored domain running at its archived start generation and clock mapping. A normal START advances that generation and clears materialized state. A normal backup quiesces each running domain for the state cut. See Backup And Restore.

Session-only commands:

CREATE SUBSCRIPTION <name> TO <relay> [BLOCKING|DROPPING] [BATCH SAMPLE RATE <rate>] [WHERE ...];
DELETE SUBSCRIPTION <name>;
DESCRIBE RELAY <relay> WHERE (...);
DESCRIBE INGESTOR <ingestor>;
DESCRIBE JUNCTION <junction>;
DESCRIBE DEDUPLICATOR <deduplicator>;
DESCRIBE REORDERER <reorderer>;
DESCRIBE WINDOW PROCESSOR <window_processor>;
DESCRIBE HASH MAP <hash_map>;
DESCRIBE PLACEMENT <placement>;
DESCRIBE RELOCATION <selection> ONTO NODE <node_id>
  FOLLOW PREFERENCES | IGNORE PREFERENCES
  [FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];
DESCRIBE DOMAIN;
LOOKUP <hash_map> KEY '<key>';
ATTACH DOMAIN CLOCK;
DETACH DOMAIN CLOCK;

Show commands:

SHOW CREATE <kind> <name>;
SHOW CREATE BRANCH by_tenant;
SHOW PLACEMENTS;
SHOW RELAY <name> MATERIALIZED STATE;
SHOW CLUSTER STATUS;
DROP NODE <node_id>;
CORDON NODE <node_id>;
UNCORDON NODE <node_id>;
DRAIN NODE <node_id>;
RELOCATE <selection> ONTO NODE <node_id>
  FOLLOW PREFERENCES | IGNORE PREFERENCES
  [FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];

A relocation <selection> is a kind-qualified list, <kind> <name>[, ...], or a directed corridor, FROM <kind> <name>[, ...] TO <kind> <name>[, ...].

For a wire schema, <kind> is the exact multi-word kind: WIRE JSON SCHEMA, WIRE CBOR SCHEMA, or WIRE AVRO SCHEMA. The same exact kind is required by DROP.

General notes:

  • keywords are case-insensitive
  • autocomplete is derived from the parser surface
  • transport/client configs are generally preserved as pass-through string key/value pairs
  • native schema fields may use the SENSITIVE modifier; session subscription output masks those values as <masked>, while emitters may send sensitive values to their configured external sink
  • CREATE SUBSCRIPTION and DELETE SUBSCRIPTION are not persisted in the registry
  • ATTACH DOMAIN CLOCK and DETACH DOMAIN CLOCK follow and stop following the active domain’s clock for the session alone and are not persisted either; see Domain Clock Attachment
  • session subscription names are unique within a connected session; one session may subscribe to relays from multiple domains, and DELETE SUBSCRIPTION uses the name rather than repeating subscription parameters
  • every RELAY is scheduled with one owner; only that owner instantiates its buffer, concrete branch presence, fan-out, subscriptions, and metrics
  • relay CAPACITY bounds the single owner buffer cluster-wide; each producer node and remote consumer node adds one fixed in-flight dispatch slot, independent of branch count
  • materialized state adds scheduler-selected state replicas to the relay rather than a separate runtime-node kind; ordinary relays report no replicas
  • ingestors and reingestors construct branch identities, and a relay owner applies branch TTL and MAX INSTANCES across all producers for that relay
  • DESCRIBE INGESTOR exposes runtime-facing ingestor state, including memory-backpressure state and committed Kafka OFFSET BY DOMAIN partition assignment

The Working Message

A transforming route operates on one working message. It begins as the route’s input view. The route constructs a new output shape: INHERIT carries fields into that shape, ordered SET assignments rewrite it step by step, and finalization fixes it as the route output. message.<field> always reads the working message as it currently stands.

Three scopes pin reads to points on that timeline:

  • input.<field> pins the original record. It never changes.
  • message.<field> reads the live current value.
  • output.<field> reads only a value that this route has already explicitly initialized.

The precise resolution rule is:

During transforming construction, message.field and a bare RHS field read the working output and fall back to the exact-compatible input.field only while the output field is uninitialized. After finalization, route WHERE sees the finalized output and performs no fallback.

Set-only output-construction routes have no message or input.

One Record, Step By Step

Assume the input record contains x = 2 and untouched = 9. Both fields are required I64. This route constructs an output with x, copied_untouched, and other required fields:

INHERIT x
SET x = message.x + 3,
    copied_untouched = message.untouched,
    x = message.x * 2

The working message changes in written order:

PointInitialized outputReadResult
Before INHERITnothingmessage.x2 from the exact-compatible input view
After INHERIT xx = 2message.x2 from the initialized output
After the first SETx = 5message.x5
While setting copied_untouchedx = 5message.untouched9 from input; untouched was never inherited or set
While repeating the x targetx = 5message.x5, so the final x is 10
After finalizationx = 10, copied_untouched = 9route WHEREonly the finalized output; no input fallback

This is not an implicit identity transformation. Enrichment is a basic operation of Nervix, and the working message is the native consequence of native inheritance: a route can carry input values forward while progressively replacing selected values. Use input and output when every read should be pinned to the original record or to explicit output initialization.

Scope Boundaries And Edge Cases

Exact compatibility means the same Nervix type and the same nullability. If the input and output both declare a field name but their types or nullability differ, an uninitialized output field does not make the incompatible input field available through message or a bare read. Use an explicit, valid conversion from input.<field> where the language supports one.

Set-only output-construction routes begin with an empty output, reject INHERIT, and expose neither message nor input. They must initialize every required output field. Omitted optional fields finalize as typed nulls. Direct-emitter VALUES is a separate source-row mapping surface: input, message, and bare fields read the source row, while output is unavailable.

Correlators have no default input scope. They expose left and right, reject bare RHS field reads, and do not expose input or message.

Generated inferencer and WASM routes are set-only. Bare reads resolve against immutable generated state until a same-named output field has been initialized. Generated state is independently visible to every route and never initializes route output automatically.

Error routes expose the original eligible input, or left and right for correlators; the exact captured relay_state snapshot; structured error; and the all-optional partial_output view. Their ordered assignments may read values initialized by earlier error-record assignments. They do not turn a partial output into a transforming working message.

See Runtime Nodes for route families and VM Functions for the implementation boundary.

Expression Functions

This chapter is the reference for NSPL expressions: the operators, conversions, builtin functions, and window aggregates they can use, with each one’s exact types, null handling, errors, and limits. Expressions appear on ingestors, processors, routes, and emitters, most often in route construction:

[INHERIT ...]
[SET <field> = <expr>, ...]
[WHERE <expr>]
[INVOKE write_header(<name-expr>, <value-expr>), ...]

SET assignments and INVOKE calls execute left to right. A transforming route begins empty and may initialize fields with INHERIT and SET; a set-only route begins empty and supports only SET. Route WHERE runs after output finalization. INVOKE and write_header are emitter-only; side-effect functions are invalid inside ordinary expressions. See The Working Message for the field scopes a route reads and how they resolve, and Where Expressions Run for every other place an expression appears.

General rules:

  • function names are case-insensitive, and an alias such as ceiling or substring behaves as, and is reported under the name of, the function it stands for
  • there is no implicit cast insertion; expr AS TYPE and TRY_CAST(expr AS TYPE) convert explicitly, as Conversions describes, and JSON_VALUE reads a value of a declared type from JSON text, as JSON Documents describes
  • argument and result types are validated when the statement is applied, except for the few expressions Where Expressions Run lists as checked later
  • sensitive values retain their sensitivity through expression evaluation; an output field that is not sensitive can hold one only through an explicit leak_sensitive(...), as Function Properties describes
  • now() returns the execution-local domain time as DATETIME, and uuid_v7() uses that same time when building the UUID

Domain-owned Roto UDFs use the same call syntax and exact typing rules. Builtins take the unqualified call surface. UDFs use the explicit udf:: namespace.

The Catalog at the end of this chapter lists every operator, builtin, and aggregate with its signature, result, and the section that describes it.

Choosing An Extension Tier

Use the smallest extension tier that fits the operation:

TierTrust requiredStatefulnessUnit of workExecutionFailure containment
BuiltinsNone beyond Nervix itselfStatelessOne expression callAsync-safe runtime executionPer-row error channel
Roto UDFsOperator-trusted native codeStatelessVectorized column function with 1–8 argumentsThe node’s extension workersPer-row errors, whole-batch errors, and a post-return watchdog
WASM processorsIsolation suitable for third-party codeBranch-local guest stateBatch-and-route processor with guest-owned emissionOne guest instance per branchMessage errors, global errors, and guest traps

Tenant-supplied or potentially non-terminating logic belongs on the WASM processor path. Roto UDF creation is operator-trusted administration. See User-Defined Functions for the native execution contract and Module Sharing And Branch Memory for the WASM isolation and memory boundary.

Where Expressions Run

Every operator and builtin on this page is available wherever an expression is, except for the functions that depend on their context. This table lists those, by the context an expression sits in:

ContextWindow aggregatesread_header, read_headersLOOKUP_HASH_MAPnow(), uuid_v4(), uuid_v7()
Ingestor FILTER WHERE and route INHERIT, SET, and WHERENoOn a header-capable sourceYesYes
BRANCHED BY ... SET on ingestors and reingestorsNoNoYesYes
Processor FROM ... WHERE and FILTER WHERE, and the routes of junctions, deduplicators, reorderers, reingestors, inferencers, and WASM processorsNoNoYesYes
Window route SETYes, and only hereNoNoYes
Window route WHERENoNoYesYes
Generator routes, correlator routes, and CORRELATE WHERENoNoNoYes
DEDUPLICATE ON, reorderer BY, and inferencer INPUTSNoNoNoYes
Emitter FROM ... WHERE, route SET, WHERE, and INVOKE, HTTP METHOD and PATH, and SQS FIFO GROUPNoNoYesYes
Emitter VALUES and OpenTelemetry ATTRIBUTESNoNoNoYes
ON MESSAGE ERROR SEND TO ... SETNoFor a failure on a header-capable ingestorNoYes
Materialized-state DEFAULTNoNoNoNo
Session subscription WHERENoNoNoYes
  • write_header is a statement of an emitter route’s INVOKE clause, never part of an expression; see Header Functions.
  • A udf:: call is available in every context. A materialized-state DEFAULT accepts only a UDF that is not VOLATILE, and reads no field at all: it is built from constants.
  • Outside a window route, count, sum, first, last, min, and max are the array and vector functions, and every other aggregate name is an unknown function, such as unknown function 'avg' with arity 1.
  • Set-only routes, which are the routes of windows, generators, correlators, inferencers, and WASM processors, accept no INHERIT and no INVOKE.

Reorderer BY expressions and emitter VALUES and ATTRIBUTES mappings are compiled when their domain’s execution is built rather than when the statement is applied, and a subscription’s WHERE when the subscription is created, so a type error in one of them is reported then.

Evaluation Model

Builtins are evaluated over Arrow columns: one call computes its result for every message in a batch together. A message’s result never depends on the other messages in the batch, so batching does not change any value. The one limit a batch’s messages share is the bytes one STRING or BYTES column holds; see Result Size. How a function traverses its column, whether through an Arrow compute kernel, one pass over the column’s value buffer, or a loop over its rows, is internal to the function and does not change its results.

Several builtins run on libraries that choose SIMD instructions at run time from the ones the node’s CPU offers: JSON extraction finds a document’s structure that way, base64 and hexadecimal encoding and decoding process their octets that way, sha256 uses the SHA instructions of x86-64 CPUs that have them, and substring searches such as contains_any and regular expressions scan text that way. xxh3_64 uses the SIMD instructions of the CPU target the binary is built for. Integer + and -, and * over every integer type but I64 and U64, run on Nervix’s own kernels, which also choose vector instructions at run time from the ones the node’s CPU offers. The other checked numeric kernels and passes over value buffers are written as loops the compiler can turn into vector instructions for the CPU target the binary is built for, and Nervix makes no claim about which of them it does. None of these choices changes a result. The one exception is the transcendental functions, whose last places come from the platform’s C math library; see Numeric Functions.

The compiler applies three optimizations that preserve results in the same way:

  • A deterministic call that cannot fail and whose arguments are all literals may be computed once instead of for every batch. It produces exactly the value that evaluating it for each message would, so upper('grüßen') and upper(input.text) agree when input.text holds grüßen.
  • Identical deterministic expressions that cannot fail may be computed once and shared within one assignment or clause. Calls that return a new value for every message, such as uuid_v4(), and calls that can report a per-message error are evaluated at each occurrence, so each occurrence reports its own error.
  • A literal, and any expression whose arguments are all literals or now(), is carried through a batch as one value rather than as a column of copies. A function reads it as one value where it can and expands it to a column only where a message-by-message operation or an output field needs one, so the result is the same either way. When such an expression fails, such as 1 / 0, every message that evaluates it reports the error.

Every operand of an operator or a function is evaluated for every message the expression evaluates, so AND, OR, coalesce, and the other operators never skip an operand. Only a conditional expression evaluates an operand for some messages and not others.

VM Functions describes the implementation behind this model: how an expression is compiled and executed over a batch, and which kernels each function family uses.

Function Properties

Every builtin follows these rules unless its own description says otherwise:

PropertyContract
TypesArguments are never converted implicitly. A function that accepts several types, such as abs over every numeric type, takes each of them as it is.
NullsA null argument produces a null result. coalesce, nullif, concat, concat_ws, is_null, greatest, and least define their own null handling, and a function can also yield null for present arguments where its description says so, such as regexp_substr without a match, nth past the end of a list, or url_host of a URL without a host.
Optional resultsA result is optional when any argument is. is_null, IS [NOT] DISTINCT FROM, now, uuid_v4, uuid_v7, and read_headers are never optional; coalesce, greatest, and least are optional only when every argument is; and TRY_CAST, JSON_VALUE, TRY_JSON_VALUE, nullif, LOOKUP_HASH_MAP, read_header, url_host, url_port, url_query, url_fragment, url_query_value, and the list functions first, last, nth, sum, min, max, and mean are always optional. An optional result initializes a required field only through an expression that is never null, such as coalesce(<result>, <default>); assigning it directly is rejected when the statement is applied with SET field '<field>' may be null but the output field is required.
SensitivityA result is sensitive when any argument is sensitive, including results such as length(...), is_null(...), and count(...) that do not contain the argument’s value. A conditional is sensitive when its operand, any condition, or any result is. Literals and calls without arguments are not sensitive. Only leak_sensitive(...) removes sensitivity.
VolatilityEvery builtin is deterministic except now(), which returns one value for an execution, and uuid_v4() and uuid_v7(), which return a new value for every message.
ErrorsA function that can fail reports a per-message error and yields null for that message. Inside a conditional, only the selected arm can report one. Errors describes how an error reaches the message’s error policy.

Sensitivity is checked where a value is stored, never where it is only tested. Assigning a sensitive value to an output field that is not sensitive is rejected with SET field '<field>' would store sensitive data in a non-sensitive output field; use leak_sensitive(...) to explicitly remove sensitivity, while a WHERE clause, a deduplication or ordering key, or a subscription filter may read sensitive fields freely. INHERIT <field> LEAK SENSITIVE, written in an explicit field list, copies a field as leak_sensitive(input.<field>) does. Emitters apply their own rules to the values they send; see Emitters.

Errors

A function that cannot compute its result for a message reports an error of one of four kinds:

KindMeaningExamples
division_by_zeroAn integer division or remainder by zerointeger division by zero, integer remainder by zero
overflowA result its type or its column cannot holdinteger addition overflowed, date_add result is outside the DATETIME range, repeat result exceeds the text one STRING column holds
cast_failedA value that does not read as the requested typecannot cast value to Int64, base64_decode input is not valid encoded bytes, JSON_VALUE document is not valid JSON
invalid_argumentAn argument outside the function’s domain, or a result that is not finitefloating-point operation produced a non-finite result, invalid regular expression: ..., vector lengths differ: left has 3, right has 2

The sections below name the kind and message of every failure. A failed message yields null for the failed operation, and the message then takes its context’s error handling:

  • A failure in route construction (INHERIT, SET, route WHERE, and INVOKE), in an ingestor’s FILTER WHERE or BRANCHED BY ... SET, in a generator route, and in an emitter’s FROM ... WHERE, route, or VALUES hands the message to the route’s ON MESSAGE ERROR policy; an ingestor FILTER WHERE failure goes to the policy of every route. SET runs before route WHERE, so a SET failure is reported even for a message that WHERE would drop. A failed branch SET reports the set operation and the message branch SET failed with, the kind, the reason and the operation’s range, and the error route reads the route’s finalized output as partial_output.
  • A failure in a processor’s FROM ... WHERE or FILTER WHERE, or in the argument of a window aggregate, is logged, and the message is not acknowledged.
  • A failure in a session subscription’s WHERE skips the record, and the session is told how many records the filter skipped.
  • Deduplication keys, reorderer BY expressions, CORRELATE WHERE conditions, inferencer INPUTS mappings, and a reingestor’s BRANCHED BY ... SET have no message-error route. A reingestor whose branch construction fails for one message fails every message of that batch: the failure is logged, and none of them is acknowledged. Keep expressions that can fail out of them, or guard them with a conditional, TRY_CAST, or TRY_JSON_VALUE.

In an ON MESSAGE ERROR SEND TO ... SET, a function failure has the error scope below. Every failure an expression reports has the code evaluation; the kind appears in the message, which names the node, the program, the kind, and the reason, such as junction 'enrich' FILTER-MAP side error division_by_zero: integer division by zero at 12..18. The range at the end locates the failed operation within the route’s program. When several operations fail for one message, the record reports the first.

FieldTypeHolds
error.referenceSTRINGA stable UUIDv7 that identifies this failure
error.codeSTRINGevaluation for every expression failure
error.messageSTRINGThe message above. It never contains a value of the message, except as the regular expressions section describes
error.operationSTRINGThe clause that failed, such as set, inherit, route_where, filter_where, source_where, values, or invoke
error.operation_indexoptional U32The zero-based position of the failed assignment, counting INHERIT fields first, or null for a WHERE
error.fieldsVEC<STRING>The assigned field and every field the failed expression reads
error.occurred_atDATETIMEThe execution’s domain time

Some failures belong to a batch rather than to a message: a UDF trap, an invalid UDF result, or a UDF that exceeds its watchdog, a failed key expression of LOOKUP_HASH_MAP, and a limit of a column that a whole batch exceeds, such as the one format_datetime checks. Such a failure fails every message of the batch: a processor reports an internal error and leaves the batch unacknowledged, and an emitter applies its ON GENERAL ERROR policy.

A statement whose expression does not type-check is rejected when it is applied, with a message such as function 'trim' requires Utf8 input, found Int64. These messages name types by their Arrow names:

NSPL typeName in messages
U8 through I64UInt8, Int8, UInt16, Int16, UInt32, Int32, UInt64, Int64
F32, F64Float32, Float64
BOOLBoolean
STRINGUtf8
BYTESBinary
DATETIMEDatetime, or Timestamp(Nanosecond, Some("+00:00"))
ARRAY, VECFixedSizeList(...), List(...), or Generic

Literals

LiteralTypeForm
IntegerI64Decimal digits, such as 42. A value above the largest I64 is rejected. There is no negative literal: -5 applies unary minus to 5
FloatF64Decimal digits with a fraction, written as one word, such as 2.5. An exponent, a leading ., a trailing ., and a space beside the ., as in 2 .5, are not float literals; write '1e5' AS F64
StringSTRING'text', "text", or dollar-quoted $$text$$ and $tag$text$tag$, where the tag is letters, digits, and underscores. A quoted string cannot hold its own quote or a line break; a dollar-quoted string can hold anything but its closing delimiter, line breaks included. No escape sequence is interpreted, so 'a\nb' is four characters
BooleanBOOLTRUE, FALSE, in any letter case
Nullthe type its destination suppliesNULL, only as the whole value of an assignment to an optional field or as a result of a conditional whose other results give it a type

A literal reads the same wherever its expression is written: in a statement, in a web console form, and in nervix-cli subscribe --where.

There is no DATETIME, BYTES, NaN, or infinity literal. Convert a string instead: '2024-02-29T12:00:00Z' AS DATETIME, hex_decode('00ff'), 'NaN' AS F64, or 'inf' AS F64. A NULL anywhere else is rejected with NULL requires a declared optional assignment target; a typed null comes from a conditional without ELSE or from TRY_CAST.

Integer literals are I64 and float literals F64, and operands are never converted, so a literal combined with a value of another width is written with a cast: input.priority > 5 AS I32, bitwise_and(input.flags, 255 AS U16), or input.ratio * 0.5 AS F32. Arguments that take a count of any integer type, such as the digits of round and the counts of the string and shift functions, accept an I64 literal as it is.

Operator Precedence

Operators bind in this order, tightest first:

LevelOperatorsGrouping
1Literals, fields, calls, [...], IF ... END, CASE ... END, TRY_CAST(...), JSON_VALUE(...), TRY_JSON_VALUE(...), JSON_EXISTS(...), and parentheses
2<expr> AS <type>Repeats from the left: x AS I64 AS STRING
3Unary - and NOTRepeat, the innermost applying first: - -x is -(-x)
4*, /, %From the left
5+, -From the left
6=, !=, <, <=, >, >=, IS [NOT] DISTINCT FROM, [NOT] IN (...), [NOT] BETWEEN ... AND ...From the left
7ANDFrom the left
8ORFrom the left

Three consequences are easy to miss:

  • NOT binds tighter than comparisons, so NOT input.count > 5 negates input.count and is rejected with operator Not is not valid for Int64. Write NOT (input.count > 5) or input.count <= 5.
  • AS binds tighter than unary minus, so -128 AS I8 casts 128, which fails. Write (-128) AS I8.
  • An AS applies only to the operand it follows, so input.low + input.high AS STRING converts input.high alone, and input.low + 1 AS I32 adds 1 AS I32.

Comparisons group from the left like the arithmetic operators, so a = b = c compares a = b with c, and a < b < c is rejected because a < b is a BOOL. A trailing comma is accepted in the arguments of a call, the elements of an IN set, and the elements of [...].

Reserved Words

These words are reserved in expressions: WHERE, SET, INHERIT, ALL, EXCEPT, LEAK, SENSITIVE, INVOKE, AS, TRY_CAST, JSON_VALUE, TRY_JSON_VALUE, JSON_EXISTS, AND, OR, NOT, TRUE, FALSE, NULL, IF, CASE, WHEN, THEN, ELSE, END, IN, BETWEEN, IS, DISTINCT, FROM, and UDF. Wherever a keyword could stand, a reserved word reads as that keyword, so a bare field, a call, or a SET, DEFAULT or INHERIT field named with one is written between backticks: SET `end` = input.end. After a field scope such as input. and after udf::, no keyword can stand, so the name is written as it is: input.end, left.from and udf::case(input.value) read without backticks.

Every other word, including a statement keyword such as to, on, by, or max, is an ordinary name wherever an expression writes one: SET to = input.to writes a field named to.

Inside an expression, a name between backticks may hold any character a name may, such as -, ~, ., or a leading digit, which a bare word cannot: input.`user-id`. Canonical NSPL, as SHOW CREATE and nervix-nspl-format write it, puts a name between backticks exactly where the name would not read back otherwise. It also quotes a bare field named like an ALTER operation keyword, ADD, DROP, ALTER, REPLACE, or RENAME, because inside an ALTER statement a comma before such a word begins the next operation.

Expressions Inside Statements

A statement reads an expression it embeds for as long as the expression can go on, and the statement’s next clause begins where it cannot, so an expression needs no parentheses to end. After WHERE input.total > 10, a TO, MAX TIME, or correlator RIGHT FROM continues no expression, so it begins the next clause. A word written where an operand, a call, or a field belongs is part of the expression, whatever keyword it spells: in DEDUPLICATE ON max(input.readings), max MAX TIME 10m the first max is a call, the second a field, and the third begins MAX TIME, and a correlator’s left input can test right(left.name, 2) = right before its RIGHT FROM.

When the expression goes on with a token and then cannot finish, as an IN ( that is never closed cannot, the statement is rejected where the expression fails rather than read as a shorter expression. Inside an ALTER statement, a comma followed by a bare operation keyword begins the next operation, while a comma inside a call’s parentheses never does, so SET FILTER WHERE concat(input.kind, replace) holds one expression.

A statement reads the expression it embeds from its own words, with the same lexer and grammar that read an expression in a web console form, in nervix-cli subscribe --where, and through the client library. Keywords, literals, comments, and spacing therefore mean the same wherever an expression is written: text one of them accepts reads as the same expression in all of them, and text one of them rejects all of them reject. A rejected expression is reported at the word of the statement where it went wrong. When the expression itself cannot finish, the message is the one a web console form shows for the same text; when a complete expression is followed by a word no clause of the statement expects, the statement names the clauses it expected there.

Logical Operators

AND, OR, and NOT take BOOL operands and return BOOL. They follow three-valued logic, in which null means an unknown truth value:

aba AND ba OR b
TRUEnullnullTRUE
FALSEnullFALSEnull
nullnullnullnull

NOT of null is null. A WHERE selects only the messages whose condition is TRUE, so a null condition drops the message. The result is optional when either operand is, even where the other operand decides it.

Both operands are evaluated for every message, whatever the first one holds. A failure in the second operand therefore fails the message even where the first operand already decides the result: input.divisor != 0 AND input.total / input.divisor > 10 reports division_by_zero for a message whose divisor is 0. Guard such an operand with a conditional, which evaluates it only for the messages that select it:

WHERE CASE WHEN input.divisor = 0 THEN FALSE ELSE input.total / input.divisor > 10 END

An operand of another type is rejected: operator And is not valid for Int64, or binary operator And requires matching operand types, found Int64 and Boolean for operands of two types.

Conditional Expressions

NSPL provides three self-delimited conditional forms:

IF <bool-condition> THEN <result> ELSE <result> END

CASE
  WHEN <bool-condition> THEN <result>
  [WHEN <bool-condition> THEN <result> ...]
  [ELSE <result>]
END

CASE <operand>
  WHEN <match-value> THEN <result>
  [WHEN <match-value> THEN <result> ...]
  [ELSE <result>]
END

Every form uses first-match-wins ordering. A null condition is not a match, and a null operand never equals a simple-CASE match value. Conditions are BOOL, and a simple-CASE operand and its match values have one exact type; the operand is computed once, before any arm. All non-null results must have the same exact type, which may be any scalar type but not ARRAY or VEC; implicit casts are not inserted. An omitted ELSE is a typed null and therefore requires an optional destination. An IF always includes ELSE. A statement that breaks these rules is rejected with a message such as CASE WHEN condition must evaluate to Boolean, found Int64, CASE results must have one exact type, found Int64 and Utf8, or CASE result type List(...) is not supported by conditional selection, which IF reports too.

Conditional values are computed in the columnar batch engine, and every arm is evaluated only for the messages that select it: a condition is evaluated for the messages no earlier arm answered, and a result for the messages its condition selected. A function in an arm therefore never reports an error for a message that selects another arm, so an error in an unselected arm never reaches the message’s error handling, and a CASE guard shields a function from the messages it cannot handle. Context-injected operations such as window aggregates, header reads, and UDFs are invoked for the selected messages only, and not at all in a batch where no message selects their arm; a whole-batch failure of such an invocation still fails the batch it was invoked for.

The words IF, CASE, WHEN, THEN, ELSE, and END are reserved.

Comparison And Equality

=, !=, <, <=, >, and >= require both operands to have the same exact type. = and != compare any scalar type, BYTES included, which compares octet by octet. The ordering comparisons <, <=, >, and >= accept numeric, STRING, and DATETIME operands. A null operand makes the comparison null. No comparison is defined for ARRAY and VEC values: compare their elements with the array and vector functions instead.

  • STRING values compare by Unicode code point, which is also the order of their UTF-8 bytes. Comparison is case-sensitive and never depends on a locale.
  • Floating-point comparisons follow IEEE 754. NaN is unequal to every value, including another NaN, so = is false, != is true, and every ordering comparison with NaN is false. 0.0 and -0.0 are equal.
  • nullif(a, b), a simple CASE <operand> WHEN <value>, IS [NOT] DISTINCT FROM, and IN decide equality exactly as = does.

a IS NOT DISTINCT FROM b is equality under which two nulls are equal: it is true when both operands are null or both are present and a = b, and false otherwise. a IS DISTINCT FROM b is its negation, a null-safe !=. Neither is ever null, so a required BOOL field can hold either, and input.region IS DISTINCT FROM input.home_region is true where exactly one region is null, where input.region != input.home_region is null. Both operands must have the same exact scalar type. Present floats compare as = does, so NaN is distinct from every value, another NaN included, and 0.0 is not distinct from -0.0.

IN, BETWEEN, and IS [NOT] DISTINCT FROM bind like the comparison operators and group from the left with them, so a = b IN (TRUE) tests whether a = b is in the set. Unary NOT binds more tightly than any comparison, so NOT x IN (1, 2) negates x before testing it; write x NOT IN (1, 2) or NOT (x IN (1, 2)) instead. See Operator Precedence. There is no IS NULL operator: test for null with is_null(x) and NOT is_null(x).

Membership And Ranges

<value> IN (<element>, ...) tests whether a value equals an element of a written set, and <value> NOT IN (<element>, ...) is its negation. <value> BETWEEN <low> AND <high> tests whether a value lies in an inclusive range, and <value> NOT BETWEEN <low> AND <high> is its negation.

input.status IN ('open', 'held')
input.priority NOT IN (1 AS I32, 2 AS I32)
input.weight BETWEEN 1.0 AND 50.0
input.observed_at NOT BETWEEN input.window_start AND input.window_end

Sets

A set lists its elements in parentheses, separated by commas. Every element is a constant: a literal, optionally negated with - or NOT and cast with AS or TRY_CAST, such as -1 AS I32 or '2026-01-01T00:00:00Z' AS DATETIME. Each element is evaluated once, when the statement is applied, exactly as the same expression evaluates for a message, so an element that cannot be evaluated, such as 300 AS U8, rejects the statement, and so does a TRY_CAST that cannot convert its literal, whose typed null is no valid element. So does an element that reads a field or calls a function; to test a value against other fields, compare it with = and combine the comparisons with OR.

The operand may have any numeric type, BOOL, STRING, or DATETIME, and every element must have exactly the operand’s type. Integer literals are I64 and float literals F64, so a set tested against an I32 or F32 value casts each element: input.priority IN (1 AS I32, 2 AS I32).

A value is an element of a set when it equals one of its elements under =:

  • NULL is not a valid element, because no value equals it; test for null with is_null(...) or IS NOT DISTINCT FROM.
  • An empty set is valid. No value is an element of it, a null one included, so x IN () is false and x NOT IN () true for every message.
  • Otherwise a null operand makes both IN and NOT IN null, so a WHERE selects a message with a null operand for neither.
  • Writing an element twice changes nothing.
  • NaN equals no value, so a NaN element matches nothing and a NaN operand is an element of no set, while 0.0 and -0.0 are the same element.

A set is prepared once for its program rather than for every batch or message. A small set of numbers, BOOL, or DATETIME values is tested by comparing the value with each of its elements, while a larger set, and every set of STRING values, is looked up by key, so a set of thousands of elements still costs each message one lookup.

Ranges

x BETWEEN low AND high is x >= low AND x <= high with x computed once, so an operand that fails reports its error once. The operand and both bounds must have one exact type: any numeric type, STRING, or DATETIME, the types < orders. Both bounds are inclusive, and a range whose low bound is above its high bound holds no value; the bounds are never swapped.

Nulls follow AND: a null operand makes the result null, and a null bound makes it null unless the comparison with the other bound is false, which makes it false. NaN lies in no range, so BETWEEN is false for a NaN operand or bound and NOT BETWEEN is true. NOT BETWEEN negates BETWEEN; it is not x < low OR x > high, which is false for a NaN operand.

The AND that closes a low bound belongs to the range, so x BETWEEN 1 AND 5 AND y is (x BETWEEN 1 AND 5) AND y. A bound that is itself a comparison needs parentheses.

Arithmetic

+, -, *, /, and % require both operands to have the same exact numeric type, and the result has that type. Unary - applies to signed integer and floating-point operands; an unsigned operand is rejected with operator Neg is not valid for UInt8. A null operand makes the result null. Integer literals are I64, so input.count + 1 over a U32 field is rejected with binary operator Add requires matching operand types, found UInt32 and Int64; write input.count + 1 AS U32. + does not join text, which concat does, and DATETIME values have no arithmetic operators: move and measure them with date_add and date_diff, or convert them to I64 nanoseconds explicitly.

Arithmetic is checked. An operation whose result its type cannot hold reports a per-message error and yields null for that message, instead of wrapping, saturating, or producing NaN or an infinity. Only that message fails: every other message in the batch is computed as usual, and a message’s result never depends on the other messages in its batch.

  • Integer +, -, and * report an overflow error when the exact result does not fit the operand type, so - over U8, U16, U32, or U64 operands fails when the right operand is larger.
  • Integer / truncates toward zero. A zero divisor reports a division_by_zero error, and dividing the minimum value of a signed type by -1 reports an overflow error.
  • Integer % returns the remainder with the sign of the dividend. A zero divisor reports a division_by_zero error. Every other remainder exists, so the remainder of the minimum value of a signed type by -1 is 0.
  • Unary - on the minimum value of a signed integer type reports an overflow error.
  • F32 and F64 arithmetic is IEEE 754 arithmetic at the operand’s own width, and % returns the remainder with the sign of the dividend. A result that is NaN or an infinity reports an invalid_argument error, which includes every division by zero and every operation on NaN. The rule applies to the result, so an infinite operand fails only where the result is not finite: 1.0 / x is 0.0 for an infinite x. Unary - on a float never fails: it changes the sign of every value, including zero, NaN, and the infinities.

The error’s message names the failure: integer addition overflowed, integer subtraction overflowed, integer multiplication overflowed, integer division overflowed, or integer negation overflowed for an overflow; integer division by zero or integer remainder by zero for a division_by_zero; and floating-point operation produced a non-finite result for an invalid_argument.

Conversions

A value never changes its type implicitly. Two explicit forms convert it to another scalar type. They accept the same types and convert every value the same way, and differ only in what a value that does not convert does:

FormResultA value that does not convert
<expr> AS <type><type>, optional exactly when <expr> isReports a per-message cast_failed error, such as cannot cast value to Int64, and yields null for that message, which takes the error handling of its context
TRY_CAST(<expr> AS <type>)Optional <type>Yields a typed null, and the message continues without an error

<type> is a scalar type written with the same spellings in both forms, in any letter case: U8 or UINT8, I8 or INT8, U16 or UINT16, I16 or INT16, U32 or UINT32, I32 or INT32, U64 or UINT64, I64 or INT64, F32 or FLOAT32, F64 or FLOAT64, BOOL or BOOLEAN, STRING or UTF8, BYTES, and DATETIME. Any other name is rejected with unsupported type '<name>'. Conversions are defined between scalar values only: an ARRAY or VEC type cannot be written as a target, and no conversion is defined for an ARRAY or VEC operand. A BYTES value converts only to BYTES, which returns it unchanged, and no other type converts to BYTES; a statement that asks for either is rejected with BYTES conversions require bytes_from_utf8, bytes_to_utf8, base64 or hex functions, which name the functions that convert explicitly. A null operand is not a failure: it converts to a typed null in both forms, so input.amount AS I64 over a message without an amount yields null and reports nothing. Test the operand to tell a missing value from one that did not convert:

SET amount = TRY_CAST(input.amount AS I64),
    amount_state = CASE
      WHEN is_null(input.amount) THEN 'missing'
      WHEN is_null(TRY_CAST(input.amount AS I64)) THEN 'malformed'
      ELSE 'converted'
    END

The result of TRY_CAST is optional even for a conversion that cannot fail, so it initializes a required field only through an expression that is never null, such as coalesce(TRY_CAST(input.amount AS I64), 0), and a statement that assigns it to a required field directly is rejected when it is applied. It keeps the sensitivity of its operand, as every conversion does.

TRY_CAST suppresses only the failure of the conversion it performs. Every other failure of its operand still fails the message with its own error:

  • TRY_CAST(100 / input.divisor AS STRING) reports division_by_zero for a zero divisor.
  • TRY_CAST(input.raw AS I64 AS STRING) converts input.raw AS I64, which reports cast_failed for text that is not an integer.
  • A UDF called in the operand reports its own per-message errors, and a failure of the whole batch still fails the batch.

A conversion of a TRY_CAST result is an ordinary one: TRY_CAST(input.raw AS I64) AS U8 fails a message whose number does not fit U8, and yields null without an error for one whose text is not an integer. Inside a conditional, a TRY_CAST converts only the messages that select its arm, as every operation does.

The operand of TRY_CAST is the whole expression before its final AS, so TRY_CAST(input.low + input.high AS STRING) converts the sum, while in input.low + input.high AS STRING the AS applies to input.high alone.

A conversion between scalar types other than BYTES succeeds for every value, except for the values the table below lists. Converting a value to its own type returns it unchanged.

  • Every value other than BYTES converts to STRING. An integer is written in decimal, and a BOOL as true or false. A float is written in decimal without an exponent, with the fewest digits that read back as the same value: 1.0 is written 1, 1e21 as 1 and 21 zeros, NaN as NaN, the infinities as inf and -inf, and negative zero as -0. A DATETIME is written in RFC 3339 with a +00:00 offset and a fraction of three, six, or nine digits when its fraction is not zero, such as 2024-02-29T12:00:00+00:00 and 2024-02-29T12:00:00.500+00:00.
  • A number converts to BOOL as false for zero and true for every other value, including NaN, and a BOOL converts to a number as 0 or 1.
  • A number converts to F32 or F64 as the nearest value of that type, so an F64 beyond the F32 range becomes an infinity.
  • A DATETIME converts to I64 as its count of nanoseconds since 1970-01-01T00:00:00Z, and to F32 or F64 as the nearest value to that count. An integer converts to the DATETIME that many nanoseconds after that instant, and a float to the DATETIME its count of nanoseconds names, cut toward zero, so 1.5 AS DATETIME is one nanosecond after the epoch.
FromToFails for
An integer typeAnother integer typeA value outside the target type’s range
U64DATETIMEA value above the largest I64
F32, F64An integer typeNaN, an infinity, and a value whose integer part, which it is rounded to toward zero, is outside the target type’s range
F32, F64DATETIMENaN, an infinity, and a value outside the DATETIME range when read as nanoseconds since 1970-01-01T00:00:00Z
STRINGAn integer typeText other than an optional + or - followed by decimal digits, with no surrounding whitespace, and a number outside the target type’s range
STRINGF32, F64Text other than a decimal number with an optional sign, fraction, and exponent, such as 1e3, .5, or +1.5, or NaN, inf, or infinity in any letter case and with an optional sign. A number beyond the type’s range reads as an infinity
STRINGBOOLText other than t, tr, tru, true, y, ye, yes, on, or 1, which read as true, and f, fa, fal, fals, false, n, no, of, off, or 0, which read as false, in any letter case and with any surrounding whitespace
STRINGDATETIMEText that is not an RFC 3339 date and time with a UTC offset, such as 2024-02-29T12:00:00Z or 2024-02-29 13:00:00+01:00, where a space may take the place of the T, and an instant outside the DATETIME range. Read other forms with parse_datetime
DATETIMEAn integer type other than I64A count of nanoseconds since 1970-01-01T00:00:00Z outside the target type’s range
BOOLDATETIMEEvery value
DATETIMEBOOLEvery value

JSON Documents

A STRING value holding a JSON document is read into typed values explicitly. Three forms read it, each naming a path to one value in the document:

FormResultA document or value that cannot be read
JSON_VALUE(<document>, '<path>' AS <type>)Optional <type>Reports a per-message error that names the defect and yields null for that message, which takes the error handling of its context
TRY_JSON_VALUE(<document>, '<path>' AS <type>)Optional <type>Yields a typed null, and the message continues without an error
JSON_EXISTS(<document>, '<path>')BOOL, optional exactly when <document> isReports a per-message error, as JSON_VALUE does

<document> is any STRING expression; any other type rejects the statement when it is applied, with an error such as JSON_VALUE document must be STRING, found Int64. A null document yields null in all three forms and reports nothing. <path> is a string literal in single or double quotes, parsed and checked when the statement is parsed, so a malformed path rejects the statement; see Paths. JSON_VALUE, TRY_JSON_VALUE, and JSON_EXISTS are reserved.

SET customer_id = JSON_VALUE(input.payload, '$.customer.id' AS I64),
    tags = JSON_VALUE(input.payload, '$.tags' AS VEC<STRING>),
    first_quantity = TRY_JSON_VALUE(input.payload, '$.items[0].qty' AS U32),
    has_note = JSON_EXISTS(input.payload, '$.note')

A value read from a document keeps the document’s sensitivity, as every function result does.

Paths

A path starts with $, which names the whole document, followed by any number of steps, at most 64:

StepLeads to
.nameThe member of an object named name, where name is ASCII letters, digits, and underscores not starting with a digit
["name"]The member of an object with any name, written as a JSON string in double quotes with its escapes, such as ["unit price"] or ["café"]
[n]The element of an array at the zero-based index n, from 0 to 4294967295, written without leading zeros

A path holds no whitespace, and has no wildcards, slices, filters, recursive descent, negative indexes, or single-quoted names. Member names are compared exactly, letter case included, with no Unicode normalization. Where an object repeats a member name, JSON_VALUE reads the last of them, and JSON_EXISTS finds any of them. A path finds no value when an object lacks the member, an array is shorter than the index, a member step meets an array, an index step meets an object, or a step leads into a value that is neither: $.a.b finds nothing where a is a number, a string, JSON null, or an array, and $[0] finds nothing in an object.

A malformed path rejects the statement with invalid JSON path '<path>': followed by the defect, whose byte positions count from zero:

  • a path starts with '$'
  • expected '.' or '[' at byte <n>
  • expected a member name of letters, digits and underscores not starting with a digit at byte <n>; write any other name as ["name"]
  • expected an array index or a double-quoted member name at byte <n>
  • array index at byte <n> is not an integer from 0 to 4294967295 without leading zeros
  • member name at byte <n> is not a valid JSON string
  • expected ']' at byte <n>
  • a path takes at most 64 steps

A message error writes its path in one form, with .name where a name allows it and ["name"] otherwise, so $["plain"] appears as $.plain.

Declared Types

<type> is a scalar type written with the same spellings as in Conversions, or a VEC<...> or ARRAY<..., n> of declared types written as a schema field declares them, nested to any depth, such as VEC<VEC<I32>> or ARRAY<F32, 2, 3>, where ARRAY<F32, 2, 3> is two arrays of three and every fixed length is from 1 to 2,147,483,647. JSON has no date or byte type, so DATETIME and BYTES, including as elements, reject the statement with an error such as JSON_VALUE cannot read DATETIME, which no JSON value is; read the text as STRING and convert it explicitly; read the text as a STRING and convert it with parse_datetime or base64_decode. A value is never converted between kinds: a number is not read as text, and text is not read as a number.

Declared typeReadsFails for
BOOLtrue and falseEvery other value
STRINGA JSON string, with its escapes decodedEvery other value, numbers included
An integer typeA JSON number with no fractional part, so 2, 2.0, and 2e0 read as 2A number with a fractional part, a number outside the type’s range, and every value that is not a number
F64A JSON number, as the nearest F64Every value that is not a number
F32A JSON number, as the nearest F32A number beyond the F32 range, and every value that is not a number
VEC<type>A JSON array of any length, each element read as typeEvery value that is not an array, and an array holding an element that type does not read
ARRAY<type, n>A JSON array of exactly n elements, each read as typeEvery value that is not an array of n elements, and an array holding an element that type does not read

VEC and ARRAY elements are never null, so an array holding JSON null fails as any other element of the wrong kind does. A number written as digits with an optional -, and no fraction or exponent, is read exactly across the whole 64-bit range. Every other number is read as the nearest F64 first, and an integer type accepts it when that F64 is an integer in its range: 9007199254740993.0 reads as 9007199254740992, and 1.0000000000000001 reads as 1. An integer written as digits beyond the 64-bit range reads as the nearest F64 too, so it is out of range for every integer type, except that the negative integers down to -9223372036854776832 read as the F64 -2^63, which is the smallest I64. A number whose magnitude is beyond the F64 range, such as 1e400, makes the document unreadable. -0 reads as 0 for every type, and -0.0 reads as 0 for an integer type and as -0.0 for F64.

Missing Values, JSON Null, And Failures

What each form yields depends on what the path finds:

The documentJSON_VALUETRY_JSON_VALUEJSON_EXISTS
is not valid JSONError cast_failed: JSON_VALUE document is not valid JSONNullError cast_failed: JSON_EXISTS document is not valid JSON
has no value at the pathNullNullFALSE
holds JSON null at the pathNullNullTRUE
holds a value of another kindError cast_failed, such as JSON_VALUE found a JSON string at $.count where I64 is declaredNullTRUE
holds a number the type cannot holdError cast_failed, such as JSON_VALUE number at $.level does not fit U8NullTRUE
holds an array of the wrong length for an ARRAYError cast_failed, such as JSON_VALUE array at $.point has 3 elements where 2 are declaredNullTRUE

A number with a fraction where an integer is declared is a value of another kind, reported as JSON_VALUE found a fractional JSON number at $.count where I64 is declared. A defect inside a collection names the collection with in and the innermost declared element type, such as JSON_VALUE found JSON null in $.tags where I64 elements are declared or JSON_VALUE array in $.p has 1 elements where 2 are declared. An error names the path and the declared type but never a value from the document, so it can be reported for a sensitive document.

JSON_VALUE and TRY_JSON_VALUE yield null both where the path finds nothing and where it finds JSON null; JSON_EXISTS tells the two apart. Test the forms together to classify a document without failing the message:

SET amount = TRY_JSON_VALUE(input.payload, '$.amount' AS F64),
    amount_state = CASE
      WHEN is_null(input.payload) THEN 'no document'
      WHEN NOT is_null(TRY_JSON_VALUE(input.payload, '$.amount' AS F64)) THEN 'read'
      WHEN NOT is_null(TRY_JSON_VALUE(input.payload, '$.amount' AS STRING)) THEN 'text'
      ELSE 'missing, null, or unreadable'
    END

To tell a malformed document from a missing value, route the message through JSON_EXISTS or JSON_VALUE and read the error in ON MESSAGE ERROR, where the error names the defect.

As with TRY_CAST, the result of TRY_JSON_VALUE and JSON_VALUE is optional, so it initializes a required field only through an expression that is never null, such as coalesce(TRY_JSON_VALUE(input.payload, '$.qty' AS U32), 0 AS U32), and a statement that assigns it to a required field directly is rejected when it is applied. TRY_JSON_VALUE suppresses only the failures of reading its document: a failure of its document expression still fails the message.

Document Limits

A document is read only when it holds at most 16,777,216 bytes and nests objects and arrays at most 128 deep. A larger or deeper document fails JSON_VALUE and JSON_EXISTS with an invalid_argument error, such as JSON_VALUE document exceeds 16777216 bytes or JSON_VALUE document nests deeper than 128 levels, and yields null from TRY_JSON_VALUE. A result whose text or elements do not fit one column, as with repeat, reports an overflow error such as JSON_VALUE result exceeds what one VEC<STRING> column holds, from TRY_JSON_VALUE too, as TRY_JSON_VALUE result exceeds ..., since it is a limit of the batch rather than a defect of the document. JSON_EXISTS builds no text and never reports it.

Reading a document is bounded by these limits. Like every builtin, an extraction runs to completion for the batch it is evaluating, so stopping a node or a processor takes effect between batches.

Reading A Document Once

Every extraction a route makes from the same document field reads one parse of each document, whether the extractions are in one SET assignment or several, in WHERE, or a mix of JSON_VALUE, TRY_JSON_VALUE, and JSON_EXISTS, so reading ten fields costs one parse rather than ten. A document computed by an expression, such as coalesce(input.payload, '{}'), shares one parse among the extractions of one assignment, and is parsed again for each assignment that reads it. A document is parsed with SIMD instructions that find its structure, chosen at run time from the ones the node’s CPU offers, and each path is then followed and its value written straight into a typed column of the declared type; no document is held as an untyped value. A document field that an earlier SET assignment rewrites is a new column, whose documents are parsed again.

Inside a conditional arm, an extraction parses only the documents of the messages that select the arm, and an extraction in the arm does not share its parse with one outside it. A document written as a literal is parsed once for each batch and shared by every message of it, so a malformed literal document fails every message that evaluates it.

Null Handling

FunctionReturnsNotes
coalesce(a, b, ...)same type as inputsReturns the first non-null argument, or a typed null when every argument is null. Takes one or more arguments of one exact type, which may be any type, ARRAY, VEC, and BYTES included
is_null(x)BOOLTrue when the input is null. Takes one argument of any type. Never null
nullif(a, b)same type as inputsReturns a typed null when a = b, and a otherwise, including when b is null. Both arguments must have the same type

coalesce is optional only when every argument is, so a required last argument makes its result required: coalesce(input.nickname, input.name) initializes a required field when name is required. It evaluates every argument for every message, including the arguments after the first present one, so a failing later argument fails the message; guard such an argument with a conditional. There is no IS NULL operator and no is_not_null function: write is_null(x) and NOT is_null(x). A bare NULL is not an argument of any of these functions; see Literals.

Extrema

FunctionReturnsNotes
greatest(a, ...)same type as inputsThe largest present argument, or a typed null when every argument is null
least(a, ...)same type as inputsThe smallest present argument, or a typed null when every argument is null
clamp(value, low, high)same type as inputslow where value < low, high where value > high, and value otherwise

greatest and least take one or more arguments of one exact scalar type: any numeric type, BOOL, STRING, DATETIME, or BYTES. They skip null arguments, so their result is required when any argument is. They order values the way the window MIN and MAX aggregates do: BOOL orders false before true, STRING orders by Unicode code point, BYTES orders octet by octet, and floating-point values order NaN above every other value and treat both zeros as equal. Among equal values the earliest argument is returned, so greatest(-0.0, 0.0) is -0.0 and greatest(0.0, -0.0) is 0.0. Every argument is evaluated, whichever one is returned.

clamp takes three arguments of one exact type: any numeric type, STRING, or DATETIME. It compares exactly as < and > do, so it returns a NaN value unchanged and keeps -0.0 inside a range that starts at 0.0. A null argument produces a null result. A message whose low bound is above its high bound, or whose floating-point bound is NaN, reports an invalid_argument error, clamp lower bound is above its upper bound or clamp bound is NaN, and yields null, so give a route whose bounds can cross an ON MESSAGE ERROR policy. A message with a null argument is never failed.

Context And Identity

FunctionReturnsNotes
leak_sensitive(value)same type as inputRemoves sensitivity from a value of any type, including every element of an ARRAY or VEC
now()DATETIMEThe execution-local domain time. Never null
uuid_v4()STRINGA random UUID, new for every message
uuid_v7()STRINGA time-ordered UUID built from the execution-local domain time, new for every message. Reports an overflow error when that time is before the Unix epoch

leak_sensitive takes exactly one argument and returns it unchanged, optional exactly when the argument is; it only changes what the statement may do with the value. now(), uuid_v4(), and uuid_v7() take no arguments and are never sensitive.

now() is one snapshot of the domain clock for each unit of work, shared by every expression of that unit: an ingested group of messages, a batch a processor accepts, and the timer, flush, or wait release that resumes work. It is logical time in a paced domain, which can be before 1970, and actual UTC in an unpaced one. See Domain Clock for how snapshots are taken.

Both UUID functions return the canonical 36-character lowercase form, such as 0192f2b5-6c1d-7e3a-9f4b-2a7c1e5d8b90, and every occurrence of a call in an expression returns its own value. A version 7 UUID encodes its time as milliseconds since 1970-01-01T00:00:00Z and fills the remaining 74 bits at random, so UUIDs of different milliseconds order by time, while UUIDs of one millisecond, such as those of one batch, have no order among themselves. In a paced domain whose logical time is earlier than the epoch, such as one started with START AT '1969-07-20T20:17:00Z', uuid_v7() reports uuid_v7 execution time is before the Unix epoch for every message that evaluates it until domain time reaches the epoch. It never encodes another instant instead.

Header Functions

FunctionReturnsNotes
read_header(name)optional STRINGIngestor-only. Returns the first value, or NULL when absent
read_headers(name)VEC<STRING>Ingestor-only. Returns all values in order, or an empty vector when absent. Never null
write_header(name, value)nothingEmitter-only side effect. Valid only as a top-level call in the final INVOKE block

Header names may be dynamic STRING expressions, and a name matches exactly, letter case included, the names the connector recorded. A null name reads as absent. Header reads are available on HTTP ENDPOINT, WEBSOCKETS ENDPOINT, HTTP REQUEST, KAFKA TOPIC, NATS SUBJECT, PULSAR TOPIC, RABBITMQ QUEUE, and SQS QUEUE ingestors, in their FILTER WHERE and route construction, and in an error route’s construction for a failure on such an ingestor. Header writes are available on HTTP, Kafka, NATS, Pulsar, RabbitMQ, and SQS emitters. Anywhere else a statement is rejected when it is validated, with a message such as function 'read_header' is only available to ingestors whose connector supports headers, MQTT TOPIC ingestors do not support read_header or read_headers, or MQTT emitters do not support write_header.

write_header arguments must be statically non-null STRING expressions, and a sensitive argument requires leak_sensitive(...). They are evaluated after payload construction and route filtering. Calls are staged in source order in a route-local envelope; if any call fails, no payload or partial header envelope is published. See Emitters for each sink’s header rules.

Lookups And UDF Calls

CallReturnsNotes
LOOKUP_HASH_MAP('<hash_map>', <key>, '<field>')the field’s type, always optionalThe named field of the record whose key equals <key> in a hash map, or null when the key is missing or null or the field is null
udf::<name>(<arg>, ...)the UDF’s declared result typeA domain-owned Roto UDF with 1–8 exactly typed arguments

The hash-map name and the field are string literals, checked when the statement is applied; a key expression that fails for any message fails its whole batch. Identical lookups of one program are answered once. Where Expressions Run lists the contexts that accept a lookup, and Lookups describes hash maps and their resource versions.

A UDF call is optional when the UDF declares an OPTIONAL result or when a required parameter receives an optional argument, is sensitive when any argument is, and is deterministic unless the UDF is declared VOLATILE. It never runs for a message whose required argument is null or whose earlier operation already failed, and yields null for it. See Nulls, errors, and volatility for its per-message and whole-batch errors.

String Functions

String functions count and select characters, meaning Unicode scalar values, rather than bytes or grapheme clusters. A combining mark counts as a separate character. Positions count from 1. Every text argument is exactly STRING: a BYTES value is rejected, so no string function measures, joins, or searches octets. Convert between the two with the bytes functions.

FunctionReturnsNotes
lower(text)STRINGLowercases with Unicode full case mapping
upper(text)STRINGUppercases with Unicode full case mapping
trim(text)STRINGRemoves leading and trailing Unicode whitespace
btrim(text)STRINGSame as trim
ltrim(text)STRINGRemoves leading Unicode whitespace
rtrim(text)STRINGRemoves trailing Unicode whitespace
length(text)I64Character count
char_length(text)I64Same as length
bit_length(text)I64Eight times the UTF-8 byte length
octet_length(text)I64UTF-8 byte length, read from the Arrow string offsets
ascii(text)I64Unicode code point of the first character, or 0 for an empty string
initcap(text)STRINGUppercases the first character of each run of letters and digits and lowercases the rest
left(text, count)STRINGThe first count characters. A negative count removes that many characters from the end
right(text, count)STRINGThe last count characters. A negative count removes that many characters from the start
substr(text, start)STRINGCharacters from position start to the end
substr(text, start, length)STRINGAt most length characters from position start
substring(text, start)STRINGAlias for substr
substring(text, start, length)STRINGAlias for substr
concat(a, b, ...)STRINGJoins one or more STRING arguments in order. A null argument contributes nothing, so no message’s result is null. See the note on its type below
concat_ws(separator, a, ...)STRINGJoins the non-null values after separator, keeping empty strings, with separator between them. A null separator makes the result null
repeat(text, count)STRINGThe text repeated count times, or an empty string when count is at most 0
replace(text, from, to)STRINGReplaces every occurrence of from, matched as plain text from left to right without overlaps. An empty from inserts to before every character and at the end
reverse(text)STRINGReverses the characters
lpad(text, length, fill)STRINGPads on the left with repetitions of fill to length characters
rpad(text, length, fill)STRINGPads on the right with repetitions of fill to length characters
split_part(text, delimiter, index)STRINGThe part at position index
split(text, delimiter)VEC<STRING>All parts in order, including empty parts at the ends or between adjacent delimiters
join(parts, separator)STRINGJoins an ARRAY<STRING> or VEC<STRING>; null elements contribute nothing
normalize_nfc(text)STRINGUnicode canonical composition (NFC); does not change case or apply compatibility folding
strpos(text, needle)I64Position of the first occurrence of needle, 1 for an empty needle, or 0 when it does not occur
translate(text, from_chars, to_chars)STRINGReplaces each character found in from_chars with the character at the same position in to_chars, and removes a character that has no counterpart. A character that from_chars repeats takes its first position, and characters of to_chars past the length of from_chars are ignored
to_hex(value)STRINGLowercase hexadecimal digits without a prefix. Integer input only; a negative value is written as its two’s complement at the input’s width, so an integer literal, which is I64, is written at 64 bits
md5(text)STRINGThe 32 lowercase hexadecimal digits of the MD5 digest of the text’s UTF-8 bytes

lower, upper, and initcap use Unicode case mappings, which never depend on the node’s locale. A mapping can change a value’s length: upper('Grüßen') is GRÜSSEN. A literal, a field, and a computed value holding the same text always convert to the same result. initcap starts a run at every letter or digit that follows a character that is neither, and maps each character on its own: a run that starts with ß starts with SS, no character takes a titlecase form, and a run may start with a digit, so initcap('3rd') is 3rd.

count, start, length, and index may be any integer type and are never narrowed to another: an unsigned count above the I64 range reaches past the end of any text, exactly as the largest I64 count does. A floating-point count is rejected.

substr treats a start at or before 1 as the first character and counts length from there. A start past the end, or a negative length, returns an empty string.

lpad and rpad shorten text longer than length to its first length characters, return an empty string when length is at most 0, and return shorter text unchanged when fill is empty.

split_part returns an empty string when index is at most 0 or past the last part; it never counts from the end. With an empty delimiter, the whole text is part 1.

split uses the same empty-delimiter rule: it returns one part containing the whole text. An empty input also produces one empty part. join on an empty list produces an empty string; concat_ws with no non-null values after the separator does the same. These functions preserve the written order.

concat never yields null for a message, but a call that has an optional argument has an optional result, so it initializes a required field only through an expression that is never null, such as concat(coalesce(input.first, ''), ' ', coalesce(input.last, '')). concat_ws follows the same rule.

Result Size

The values one call produces for a batch share one STRING column, which holds at most 2,147,483,647 bytes of text. A function whose result can be longer than its arguments computes how long each result is before it builds it, and a result that does not fit in what its column has left reports an overflow error and yields null instead of being built:

FunctionError
repeat, lpad, rpad, concat_ws, join, normalize_nfc<function> result exceeds the text one STRING column holds, such as repeat result exceeds the text one STRING column holds
splitsplit result exceeds 65,536 parts for a result of more than 65,536 parts, and split result exceeds the text one STRING column holds
base64_encode, hex_encode<function> result exceeds the bytes one column holds

Inside a conditional arm the column holds only the results of the messages that select the arm, so a message that selects another arm uses none of its text. A call whose arguments are all literals computes one value that every message in the batch holds, so that value must fit once for each of them: repeat('ab', 600000000) fits a batch of one message but reports the error on every message of a batch of two. JSON extraction, URL and IP address functions, and format_datetime, bound their results in the same column limit.

concat, replace, regexp_replace, translate, lower, upper, and initcap build their column without this check, so no message reports an overflow error for them. Keep the text they produce for one batch within the column limit: for example, a replace with an empty from multiplies the length of its text.

String Predicates

Plain substring matching is exact and case-sensitive, and compares UTF-8 bytes, so it never normalizes text.

FunctionReturnsNotes
contains(text, needle)BOOLTrue when needle occurs in text
starts_with(text, prefix)BOOLTrue when text begins with prefix
ends_with(text, suffix)BOOLTrue when text ends with suffix
contains_any(text, patterns)BOOLTrue when any non-null string in an ARRAY<STRING> or VEC<STRING> occurs in text; an empty set is false and an empty pattern matches every non-null text
like(text, pattern)BOOLSQL LIKE: % matches zero or more Unicode characters and _ matches one, newlines included; a backslash makes the character after it literal
ilike(text, pattern)BOOLThe same wildcard rules with Unicode loose case-insensitive matching

like and ilike match the entire text. A trailing backslash is a literal backslash. ilike follows Arrow’s Unicode loose matching: it ignores case without expanding characters, so ß does not equal SS. For full Unicode case mapping, apply lower or upper explicitly; neither matching function normalizes text. Each LIKE pattern is limited to 4,096 bytes of UTF-8 text: a longer pattern reports an invalid_argument error, LIKE pattern exceeds 4 KiB, on every message that evaluates it, whatever its text holds.

contains_any compares exact case-sensitive text, including combining marks. A set written in the call as array(...), vec(...), or [...] whose elements are all non-null STRING constants is prepared once with the program. Every other set is read for each message, and the distinct sets of one batch are prepared once each, keeping at most 64 of them for the batch and replacing the one prepared first when more are read. A set has at most 128 non-null patterns and 65,536 bytes of combined pattern text; a larger set reports an invalid_argument error, contains_any pattern set exceeds 128 patterns or 64 KiB, on each message with a non-null text and set that uses it, even when the set is a constant.

Regular Expressions

Regular-expression functions take STRING arguments and use Rust regex syntax. A pattern that does not compile reports a per-message invalid_argument error whose message is invalid regular expression: followed by the parser’s description of the defect.

A pattern written as a literal, or an expression that compiles to a constant, such as a CASE with constant conditions or lower, upper, trim, or coalesce over literals, is compiled once when the node is activated and reused by every batch. It is still not a configuration error: a constant pattern that does not compile reports its per-message error only for the messages that evaluate it, so an invalid pattern in a conditional arm no message selects reports nothing. Any other pattern, including one built with concat, is compiled when a message first uses it and kept in a cache of the 64 most recently compiled patterns per call, which evicts the pattern compiled longest ago and keeps the outcome of a pattern that did not compile. A compiled pattern is limited to 10 MiB; a pattern that compiles past that limit reports a per-message error like any other invalid pattern. A message whose text, replacement, or group is null yields null without compiling its pattern, so an invalid pattern reports nothing for it.

FunctionReturnsNotes
regexp_like(text, pattern)BOOLTrue when the pattern matches anywhere in the text
regexp_replace(text, pattern, replacement)STRINGReplaces every match. $1 and ${name} in replacement insert a capture group, and $$ inserts $. A group that did not participate inserts nothing
regexp_substr(text, pattern)optional STRINGThe first match, or null when the pattern does not match
regexp_extract(text, pattern, group)optional STRINGNumbered capture from the first leftmost match; group 0 is the complete match, and an absent group or match produces null

regexp_extract accepts any integer type for group. A negative index produces null. Regex syntax uses Unicode classes by default, while POSIX classes such as [[:alpha:]] are ASCII only. Matching is leftmost-first and supports no backreferences or look-around; the engine keeps linear-time search guarantees rather than enabling backtracking features. In a replacement, a group name runs as far as letters, digits, and underscores continue, so $1a names a group 1a; write ${1}a to follow group 1 with a.

regexp_substr and regexp_extract are null for a message the pattern does not match even when every argument is required, so assign them to an OPTIONAL field or give them a value with coalesce.

The description of an invalid pattern quotes the pattern. A pattern read from a field is part of the message, so the error message of such a pattern carries its text: validate patterns before they reach a route whose error records leave the node, or write patterns as literals.

Bytes, Encodings And Hashes

BYTES values hold arbitrary octets, including zero and non UTF-8 bytes. These functions accept exact types, propagate nulls, and preserve sensitivity. An encoded or hashed sensitive input stays sensitive; emitting it still requires explicit leakage. A cast never converts between BYTES and STRING: input.raw AS BYTES is rejected with BYTES conversions require bytes_from_utf8, bytes_to_utf8, base64 or hex functions.

FunctionReturnsNotes
bytes_from_utf8(text)BYTESThe exact UTF-8 bytes of a STRING, with no terminator or normalization
bytes_to_utf8(bytes)STRINGDecodes UTF-8; invalid sequences fail that message
base64_encode(bytes)STRINGRFC 4648 standard alphabet with required = padding
base64_decode(text)BYTESAccepts canonical padded standard base64; the URL-safe alphabet, missing or extra padding, nonzero trailing bits, and whitespace fail that message
hex_encode(bytes)STRINGTwo lowercase hexadecimal digits per byte, without a prefix
hex_decode(text)BYTESAccepts hexadecimal pairs in either letter case, mixed in one value; odd length or any other character fails that message
sha256(bytes)BYTESThe 32 raw SHA-256 digest bytes
xxh3_64(bytes)U64Stable XXH3 64-bit hash with seed zero

An empty input encodes and decodes to an empty output. md5 reads STRING text and returns hexadecimal text, while sha256 reads BYTES and returns raw bytes: hash text with sha256(bytes_from_utf8(input.text)) and write the digest with hex_encode.

xxh3_64 hashes the input octets in their given order and returns the algorithm’s unsigned 64-bit number. The value is independent of host byte order and stays the same across nodes and restarts. It is a noncryptographic hash, so use sha256 where collision resistance matters.

A decoding failure reports a cast_failed error: bytes_to_utf8 input is not valid UTF-8, base64_decode input is not valid encoded bytes, or hex_decode input is not valid encoded bytes. Like every message error, it names the failed function without exposing its input.

IP Addresses And Networks

An IP address is a BYTES value in network byte order: four octets for an IPv4 address and sixteen for an IPv6 address, so its length is its family. ip_from_string and is_ip_address read an address from text, ip_in_network reads its network from text, and every other address function reads the octets as one 32-bit or 128-bit number. Parse an address once into a BYTES field and mask and test that field, so each further test costs no second parse: ip_from_string can fail, so two calls of it are two parses even in one expression. A function over an address that is not a constant reports its own errors and is evaluated at each occurrence.

FunctionReturnsNotes
ip_from_string(text)BYTESThe address text writes: 4 octets for IPv4 and 16 for IPv6
ip_to_string(address)STRINGThe address in its canonical text form
ip_family(address)I644 for an IPv4 address and 6 for an IPv6 address
ip_trunc(address, prefix_length)BYTESThe address with every bit after its first prefix_length bits cleared: the first address of the network of that length that holds it. prefix_length may be any integer type
ip_in_network(address, network)BOOLTrue when address lies in network, a STRING in CIDR notation such as '10.0.0.0/8'
ip_unmap(address)BYTESThe IPv4 address an IPv4-mapped IPv6 address carries, and every other address unchanged
is_ip_address(text)BOOLTrue when ip_from_string reads an address from text. Never fails

ip_from_string reads an IPv4 address as four decimal numbers from 0 to 255 separated by dots, each without leading zeros, so 010.0.0.1 is rejected rather than read as octal. It reads an IPv6 address in the text forms of RFC 4291: eight groups of one to four hexadecimal digits in either letter case, :: in place of one run of zero groups, and optionally the last two groups written as an IPv4 address, as in ::ffff:192.0.2.1. The text is the address alone: surrounding whitespace, brackets, a port, a prefix length, and an IPv6 zone index such as %eth0 are rejected. No function resolves a name, so ip_from_string('localhost') fails like any other text that is not an address.

ip_to_string writes an IPv4 address in dotted decimal and an IPv6 address in the canonical form of RFC 5952: lowercase hexadecimal groups without leading zeros, with the longest run of two or more zero groups, or the first of two equally long runs, written as ::. An IPv4-mapped address is written with its IPv4 address in dotted decimal. ip_to_string(ip_from_string('2001:DB8:0:0:0:0:0:1')) is 2001:db8::1, and ip_to_string(ip_from_string('::FFFF:C000:201')) is ::ffff:192.0.2.1. A BYTES address renders as base64 in subscriptions and JSON payloads, so write it with ip_to_string to show it as text.

A network is written as an address, /, and a prefix length in decimal without leading zeros: 0 to 32 for an IPv4 network and 0 to 128 for an IPv6 network. The address may set no bit after the prefix, so '10.0.0.1/8' is rejected rather than read as '10.0.0.0/8'. '0.0.0.0/0' holds every IPv4 address and '::/0' every IPv6 address. A network written as a literal, or an expression that compiles to a constant as a constant pattern does, is read once when the statement is applied, and one that does not read rejects the statement with a message that names its defect, such as function 'ip_in_network' network '10.0.0.1/8' has host bits set past its prefix length. Any other network, including one built with concat, is read for each message, a message whose network is the text last read reuses that reading, and a network that does not read fails its message.

Each address has exactly one family, and no function converts between families unless it says so:

  • ip_in_network is false for an address of the other family than its network, so no IPv6 address lies in an IPv4 network.
  • ip_from_string('::ffff:10.0.0.1') is a 16-octet IPv6 address, the IPv4-mapped form of 10.0.0.1 that dual-stack sockets report for IPv4 clients. It does not lie in '10.0.0.0/8', and ip_unmap of it does. Test ip_in_network(ip_unmap(address), '10.0.0.0/8') to treat such clients as IPv4.
  • ip_unmap converts only IPv4-mapped addresses, the ::ffff:0:0/96 range. An IPv4-compatible address such as ::a09:807, and every other IPv6 or IPv4 address, is returned unchanged.
  • A prefix length counts bits at the address’s own width, so ip_trunc(address, 24) keeps the first 24 of the 32 bits of an IPv4 address and the first 24 of the 128 bits of an IPv6 address. Where both families occur, choose the length by family: ip_trunc(address, IF ip_family(address) = 4 THEN 24 ELSE 48 END).

A null argument produces a null result. Otherwise a function fails only the messages it cannot answer:

FunctionFails whenError
ip_from_stringtext is not an address in the forms abovecast_failed: ip_from_string input is not an IPv4 or IPv6 address
ip_to_string, ip_family, ip_trunc, ip_in_network, ip_unmapaddress is neither 4 nor 16 octets longinvalid_argument, such as ip_family input is not a 4 or 16 byte IP address
ip_truncprefix_length is negative or longer than the addressinvalid_argument, such as ip_trunc prefix length must be 0 to 32 for an IPv4 address
ip_in_networkA network that is not a constant does not readinvalid_argument: ip_in_network network followed by the defect: is not written as address/prefix, address is not an IPv4 or IPv6 address, prefix length is not a decimal number without leading zeros, prefix length must be 0 to 32 for an IPv4 network, or has host bits set past its prefix length
ip_from_string, ip_trunc, ip_to_stringThe result does not fit in what its column has left, as in Result Sizeoverflow, such as ip_from_string result exceeds the bytes one column holds or ip_to_string result exceeds the text one STRING column holds

ip_trunc checks its prefix_length first, so a null prefix length yields null whatever the address holds. An address function over a literal, such as ip_from_string('192.0.2.300'), is computed once for each batch, so a literal that does not read fails every message that evaluates it rather than the statement. A statement that passes an argument of another type is rejected, such as function 'ip_in_network' requires BYTES input, found Utf8.

To route malformed text explicitly instead of failing the message, test it first: CASE WHEN is_ip_address(input.client) THEN ip_from_string(input.client) END reads only the messages that hold an address and is null for the others. Address functions keep the sensitivity of their arguments, so whether a sensitive address lies in a network is sensitive.

SET client = ip_from_string(input.client_ip),
    client_subnet = ip_to_string(ip_trunc(output.client, IF ip_family(output.client) = 4 THEN 24 ELSE 48 END)),
    internal = ip_in_network(ip_unmap(output.client), '10.0.0.0/8')
WHERE NOT output.internal

URLs

URL functions read their STRING argument as an absolute URL under the WHATWG URL Standard, the rules web browsers apply, through the Rust url crate. They compute only from the text: none fetches a URL, resolves a host, or consults the public suffix list.

FunctionReturnsNotes
url_scheme(url)STRINGThe scheme, lowercased, without its :
url_host(url)optional STRINGThe host, or null when the URL has none
url_port(url)optional I64The port, the scheme’s default port when the URL names none, or null when it has neither
url_path(url)STRINGThe path, percent-encoded
url_query(url)optional STRINGThe query without its ?, percent-encoded, or null when the URL has no ?
url_fragment(url)optional STRINGThe fragment without its #, percent-encoded, or null when the URL has no #
url_query_value(url, name)optional STRINGThe decoded value of the first query parameter named name, or null when there is none
url_query_values(url, name)VEC<STRING>The decoded values of every query parameter named name, in query order, or an empty vector when there is none
url_decode(text)STRINGtext with every percent escape decoded
is_url(text)BOOLTrue when the other URL functions read text as a URL. Never fails

A URL is read and normalized as the URL Standard specifies:

  • Leading and trailing spaces and control characters are removed, and so are tabs and newlines anywhere in the text.
  • The scheme is lowercased. For the schemes http, https, ws, wss, ftp, and file, the host is lowercased and an internationalized domain name is written in its ASCII form, so the host of http://Bücher.example/ is xn--bcher-kva.example. An IPv4 host is written in dotted decimal, including the forms browsers accept, so the host of http://0x7f.1/ is 127.0.0.1.
  • An IPv6 host is written as the URL Standard writes it, lowercase, compressed, and never in dotted decimal, and url_host returns it without its brackets: the host of http://[::FFFF:192.0.2.1]/ is ::ffff:c000:201, which ip_from_string reads.
  • . and .. path segments are resolved, and a space or any other character a path cannot hold is percent-encoded: url_path('https://example.com/a/./b/../c d') is /a/c%20d. The path of a URL of those six schemes starts with /.
  • A port equal to the scheme’s default is removed, so url_port answers 80 for http://example.com:80/ as for http://example.com/. The defaults are 80 for http and ws, 443 for https and wss, and 21 for ftp. A URL of any other scheme that names no port has a null port.
  • Other schemes have no default port and may have no host: the path of mailto:ops@example.com is ops@example.com, and its host is null. Their hosts are kept as written, letter case included, without the ASCII form of an internationalized name.

A component the URL lacks is null: the host of a mailto: or urn: URL and of a file: URL with an empty host, the query of a URL without ?, and the fragment of a URL without #. A URL that ends in ? or # has an empty query or fragment rather than a null one.

The query is read as application/x-www-form-urlencoded parameters. It is split at every &, an empty parameter is skipped, and each parameter is split at its first = into a name and a value; a parameter without = has an empty value. Names and values are percent-decoded with + read as a space, so the query q=hello+world%21&tag=x&tag=y%26z has the value hello world! for q and the values x and y&z for tag. A parameter matches when its decoded name equals name exactly, letter case included. A parameter whose name does not decode matches no name. url_query_value reads only the first matching parameter, so only its value must decode, while url_query_values fails the message when any matching value does not decode.

url_decode replaces each % followed by two hexadecimal digits of either case with the octet they name, and keeps every other character as it is, + included, so url_decode(url_path(input.url)) is the readable path. Rather than guess, it fails a message whose text has a % not followed by two hexadecimal digits, or whose decoded octets are not UTF-8.

A null argument produces a null result. Otherwise a function fails only the messages it cannot answer:

FunctionFails whenError
Every URL function except url_decode and is_urlurl is not an absolute URLcast_failed, naming the function and the URL Standard’s reason, such as url_host input is not an absolute URL: relative URL without a base; other reasons include empty host, invalid port number, invalid IPv4 address, invalid IPv6 address, invalid domain character, and invalid international domain name, which a space in the host of an http URL reports
url_query_value, url_query_valuesThe value of a matching parameter is not percent-encoded UTF-8cast_failed, such as url_query_value input is not valid percent-encoded UTF-8
url_decodetext is not percent-encoded UTF-8cast_failed: url_decode input is not valid percent-encoded UTF-8
Every URL function except url_port and is_urlThe result does not fit in what its column has leftoverflow, such as url_path result exceeds the text one STRING column holds or url_query_values result exceeds what one VEC<STRING> column holds

A relative reference, such as the request target /search?q=x of an HTTP request line, is not an absolute URL. Prefix it with a base explicitly: url_query_value(concat('http://localhost', input.target), 'q'). To route text that may not be a URL, test it first: CASE WHEN is_url(input.referrer) THEN url_host(input.referrer) END is null for the messages whose referrer is not a URL.

Each call reads its own URL, so url_host(input.url) and url_path(input.url) each parse it, while a URL that every message shares, such as a literal, is read once per batch, and url_query_value and url_query_values reuse one reading while consecutive messages hold the same URL. is_url over a constant is answered when the statement is applied. Percent-encoding and ASCII domain names can make a component longer than the text it came from, and like the results of repeat, a component that does not fit in what its STRING column has left reports an overflow error. URL functions keep the sensitivity of their arguments, so the host of a sensitive URL is sensitive.

SET scheme = url_scheme(input.referrer),
    referrer_host = url_host(input.referrer),
    campaign = url_query_value(input.referrer, 'utm_campaign'),
    tags = url_query_values(input.referrer, 'tag'),
    landing_path = url_decode(url_path(input.referrer))

Numeric Functions

Numeric functions accept every integer and floating-point type unless a description below narrows it. A function that returns F64 reads an integer argument as the nearest F64, which is exact for every type up to 32 bits and rounds I64 and U64 values beyond 2^53, and reads an F32 argument exactly, so its result follows the F32’s stored value. atan2, log(base, x), and pow take two arguments that may have different numeric types, and always return F64: pow(-8, 3) is the F64 -512.0. A floating-point result that is not finite, such as sqrt(-1.0), ln(0.0), or exp(1000.0), reports a per-message invalid_argument error and yields null instead of producing NaN or an infinity. The error names the function by its canonical name, such as pow produced a non-finite result for a call written power(...).

FunctionReturnsNotes
abs(x)same numeric type as inputThe absolute value. An unsigned value is returned unchanged, and abs(-0.0) is 0.0. The minimum value of a signed integer type has none and reports an overflow
acos(x)F64Arc cosine in radians
asin(x)F64Arc sine in radians
atan(x)F64Arc tangent in radians
atan2(y, x)F64The angle in radians, from -π to π, of the point (x, y). The signs of both arguments select the quadrant, so atan2(0.0, -1.0) is π. y comes first, and the two arguments may have different numeric types
ceil(x)same numeric type as inputRounds up. Integer input is returned unchanged
ceiling(x)same numeric type as inputAlias for ceil
cos(x)F64Cosine of an angle in radians
degrees(x)F64Converts an angle from radians to degrees
exp(x)F64e raised to x
floor(x)same numeric type as inputRounds down. Integer input is returned unchanged
ln(x)F64Natural logarithm
log(x)F64Base-10 logarithm
log(base, x)F64Logarithm with explicit base
log2(x)F64Base-2 logarithm
pow(x, y)F64x raised to y
power(x, y)F64Alias for pow
radians(x)F64Converts an angle from degrees to radians
round(x)same numeric type as inputRounds to the nearest integer, with halves rounded away from zero. Integer input is returned unchanged
round(x, digits)same numeric type as xRounds to digits decimal places, or for a negative digits to a multiple of 10^-digits. See Precision Rounding
sign(x)same numeric type as input-1 for a negative value, 1 for a positive one, and 0 for zero, so an unsigned value has the sign 0 or 1. A float zero keeps its sign, an infinity has the sign -1.0 or 1.0, and NaN has no sign and reports an error
sin(x)F64Sine of an angle in radians
sqrt(x)F64Square root
tan(x)F64Tangent of an angle in radians
trunc(x)same numeric type as inputRounds toward zero. Integer input is returned unchanged

abs, ceil, floor, round, sign, sqrt, and trunc are exact: each returns the correctly rounded result, which is the same on every node. Rounding keeps the sign of a zero result, so ceil(-0.4), round(-0.4), and trunc(-0.5) are -0.0, and sqrt(-0.0) is -0.0 without an error. radians and degrees multiply by the F64 nearest to π/180 and to 180/π, so their result is the same on every node and within two units in the last place of the exact conversion.

acos, asin, atan, atan2, cos, exp, ln, log(x), log2, pow, sin, and tan evaluate in IEEE 754 double precision through the C math library of the platform a node runs on, which is glibc for the published Linux builds. Their precision is that library’s: Nervix states no bound of its own, and a node with another C library, another version of it, or a CPU for which the library selects another implementation can differ in the last places. log(base, x) computes ln(x) / ln(base), so it carries the rounding of two logarithms and a division, which exceeds two units in the last place for some arguments.

A function reports an error exactly where its result is not a finite value of its type. For finite arguments, that is:

FunctionArguments that report an error
abs, ceil, floor, round(x), sign, truncNone. abs over the minimum value of a signed integer type reports an overflow error
round(x, digits)A negative digits whose rounded multiple exceeds the range of x’s type. A float reports an invalid_argument error and an integer an overflow error
sqrt(x)x < 0
ln(x), log(x), log2(x)x <= 0
log(base, x)x <= 0, base < 0, and base = 1. A base of 0.0 or -0.0 gives 0.0 or -0.0, since ln(0) is an infinity
acos(x), asin(x)x < -1 and x > 1
exp(x)x above about 709.78, where the result overflows. A result too small to represent is 0.0
degrees(x)x beyond about ±3.14e306, where the result overflows
pow(x, y)A result that overflows, x < 0 with a y that is not an integer, and x = 0 with y < 0
atan(x), atan2(y, x), cos(x), radians(x), sin(x), tan(x)None

A NaN or infinite argument reports an error unless a finite result is defined for it, as IEEE 754 defines, for example, for atan of an infinity, atan2 of any arguments that are not NaN, exp of negative infinity, log(base, x) with an infinite base and a finite positive x, and these pow cases: pow(1, y) is 1 for every y, NaN included; pow(x, 0) and pow(x, -0.0) are 1; pow(-1, ±inf) is 1; pow(x, -inf) is 0 where |x| > 1 and pow(x, inf) is 0 where |x| < 1; and pow(±inf, y) is a zero for a negative y. sign of an infinity is also defined. abs, ceil, floor, round, and trunc report an invalid_argument error for a NaN or infinite argument, such as floating-point absolute value produced a non-finite result or round produced a non-finite result.

Precision Rounding

round(x, digits) takes any integer type for digits, independently of the numeric type of x, and returns x’s type. A positive digits counts decimal places after the point, 0 rounds to the nearest integer as round(x) does, and a negative digits rounds to a multiple of a power of ten: round(1250, -2) is 1300. Halves round away from zero at every position, so round(-0.125, 2) is -0.13. Every count is accepted. When 10^-digits is finer than the smallest step between values of x’s type near x, the result is x itself, and when x is less than half of 10^-digits in magnitude, the result is zero.

A float is rounded exactly. The result is the value of x’s type nearest to the stored value of x rounded to digits decimal places, so it is the same on every node. The stored value of a decimal literal is the nearest binary float, which can lie on either side of a decimal tie:

  • round(0.125, 2) is 0.13, because 0.125 is stored exactly and is a tie.
  • round(2.675, 2) is 2.67, because the F64 nearest to 2.675 is slightly below it.
  • An F32 rounds its own stored value: the F32 nearest to 0.15 is slightly above 0.15, so round(x, 1) of that F32 is 0.2.

A float result that is zero keeps the sign of x, as round(-0.004, 2) is -0.0. Rounding to a multiple of a power of ten can exceed the largest finite value, as round(1.7976931348623157e308, -308) does, and reports an invalid_argument error.

An integer has no decimal places, so a digits of 0 or more returns it unchanged. A negative digits rounds it to a multiple of 10^-digits, and a multiple that does not fit its type reports an overflow error: round(x, -1) over the I32 value 2147483647 would be 2147483650.

Floating-Point Classification

Classification functions take an F32 or F64 argument. An integer argument is rejected when the statement is applied, since no integer is NaN or infinite. A null argument produces a null result, and no value makes them fail.

FunctionReturnsNotes
is_nan(x)BOOLTrue for NaN, whatever its sign or payload
is_finite(x)BOOLTrue for every value that is neither NaN nor an infinity, including both zeros
is_infinite(x)BOOLTrue for positive and negative infinity

A decoded float or a cast from STRING can hold NaN or an infinity, so a route can test for them before calling a function that would report an error: IF is_finite(input.reading) THEN sqrt(abs(input.reading)) ELSE 0.0 END.

Bitwise Functions

Bitwise functions take integer arguments and read each value as its two’s complement bits at the width of its type, so bitwise_not over the U8 value 0 is 255 and over the I8 value 0 is -1. A null argument produces a null result.

FunctionReturnsNotes
bitwise_and(a, b)same integer type as inputsBoth arguments must have the same integer type
bitwise_or(a, b)same integer type as inputsBoth arguments must have the same integer type
bitwise_xor(a, b)same integer type as inputsBoth arguments must have the same integer type
bitwise_not(a)same integer type as inputInverts every bit
bit_count(a)I64The number of set bits. A negative value counts its sign bits, so bit_count of the I64 value -1 is 64
shift_left(value, count)same integer type as valuevalue multiplied by 2^count
shift_right(value, count)same integer type as valuevalue divided by 2^count, rounded toward negative infinity

bitwise_and, bitwise_or, bitwise_xor, bitwise_not, and bit_count never fail.

A shift takes count from any integer type, independently of the type of value, and a negative count reports an invalid_argument error. Shifts are checked like arithmetic:

  • shift_left reports an overflow error when the product does not fit the type of value, including a signed value whose sign would change: shift_left of the I8 value 64 by 1 fails, and of the I8 value -1 by 7 is -128. A count at or beyond the type’s width moves every bit out, so it returns 0 for a zero value and fails for any other.
  • shift_right never overflows. A signed value keeps its sign, so shift_right(-5, 1) is -3, and a count at or beyond the type’s width returns -1 for a negative value and 0 for any other.

Datetime Functions

A DATETIME is a UTC instant with nanosecond precision. Its range is the signed Unix-nanosecond range, 1677-09-21T00:12:43.145224192Z through 2262-04-11T23:47:16.854775807Z. Datetime functions compute exactly from the values they are given and never read a clock, so a call gives the same result for the same arguments whenever and on whichever node it runs. The current time enters an expression as a value only through now(), which returns the execution-local domain time: date_trunc('day', now(), 'Europe/Berlin') follows a paced domain’s logical clock at any TIME RATE, while the work a call does, and how long it takes, never depends on the domain’s pace.

FunctionReturnsNotes
date_part(part, value[, zone])I64One part of value’s local date and time in zone, named by part from the table of date parts below
date_trunc(unit, value[, zone])DATETIMEThe first instant of the local unit that holds value in zone. See Calendar Arithmetic
date_bin(unit, width, value, origin)DATETIMEThe start of the bin that holds value, for bins width units wide that start at origin and at every whole number of widths before and after it
date_add(unit, amount, value[, zone])DATETIMEvalue moved by amount units in zone, backward when amount is negative. amount may be any integer type
date_diff(unit, start, end[, zone])I64The whole units from start to end in zone, rounded toward zero. Negative when end is before start
to_unix(unit, value)I64The whole units from 1970-01-01T00:00:00Z to value, rounded down
from_unix(unit, count)DATETIMEThe instant count units after 1970-01-01T00:00:00Z, or before it when count is negative. count may be any integer type
format_datetime(format, value[, zone])STRINGvalue’s local date and time in zone, written in format. See Datetime Formats
parse_datetime(format, text[, zone[, disambiguation]])DATETIMEThe instant text names when read in format. See Datetime Formats

unit, part, width, zone, format, and disambiguation are literals written in the call, not expressions, and they are checked when the statement is applied: a zone is resolved and a format is compiled once then, never for each message. unit, part, zone, and disambiguation are STRING literals whose names are case-insensitive, and format is a STRING literal. width is a positive integer literal, and width units must not exceed 9,223,372,036,854,775,807 nanoseconds, about 292 years. A name must match exactly apart from letter case, so plurals and abbreviations such as days or ms are unknown. A call with an unknown name, a non-literal argument, an invalid format, or a width that is not positive is rejected, and its message names what the call accepts, such as function 'date_bin' does not accept time unit 'month'; expected one of nanosecond, microsecond, millisecond, second, minute, hour, day, week or function 'date_part' requires its date part to be a STRING literal. A call that names no zone reads in UTC; see Time Zones for the zones a call can name.

The units nanosecond through week have a fixed length. A DATETIME has no leap seconds, so every UTC day is exactly 86,400 seconds long. month, quarter, and year are calendar units, whose length depends on the month they count from. date_trunc, date_add, and date_diff accept every unit; date_bin, to_unix, and from_unix accept only units of fixed length.

UnitLength
nanosecond1 nanosecond
microsecond1,000 nanoseconds
millisecond1,000 microseconds
second1,000 milliseconds
minute60 seconds
hour60 minutes
day24 hours, or one local calendar day in an IANA time zone
week7 days, or seven local calendar days in an IANA time zone
monthOne calendar month
quarterThree calendar months, starting in January, April, July, and October
yearTwelve calendar months, starting in January

A date part reads value’s local date and time in zone:

PartRangeMeaning
year1677–2262The proleptic Gregorian year
quarter1–4The quarter of the year
month1–12The month
day1–31The day of the month
hour0–23The hour of the day
minute0–59The minute of the hour
second0–59The whole seconds of the minute
millisecond0–999The whole milliseconds past the second
microsecond0–999,999The whole microseconds past the second
nanosecond0–999,999,999The nanoseconds past the second
day_of_week0–6The day of the week, from Sunday as 0
day_of_year1–366The day of the year
iso_year1677–2262The ISO 8601 week-numbering year that iso_week belongs to
iso_week1–53The ISO 8601 week, whose week 1 holds the year’s first Thursday
iso_day_of_week1–7The ISO 8601 day of the week, from Monday as 1

Results are exact to the nanosecond, and each function rounds in one fixed direction:

  • date_trunc, date_bin, and to_unix round toward negative infinity, including before the Unix epoch and before an origin, so a value belongs to the unit or bin that starts at or before it. date_trunc('day', ...) of 1969-12-31T23:59:59.999999999Z is 1969-12-31T00:00:00Z, and to_unix('millisecond', ...) of the same value is -1.
  • In UTC, a day, and every shorter unit, starts a whole number of units after the Unix epoch. A week starts on Monday in date_trunc, while to_unix('week', ...) counts whole weeks from the epoch itself, which was a Thursday.
  • date_bin accepts an origin before or after value. A value exactly on a bin boundary starts its own bin.
  • date_diff rounds toward zero. For units that count elapsed time, exchanging start and end only changes the sign of the result, and date_diff('second', start, end) is 0 when the two values are less than a second apart in either direction.

A null argument produces a null result. date_part, to_unix, and format_datetime never fail a message; see Writing Values for the one batch-wide limit of format_datetime. Another function reports a per-message error and yields null exactly where it cannot produce a result:

FunctionFails when
date_trunc, date_binThe unit or bin that holds value starts before the DATETIME range, with an overflow error
date_addThe moved instant is outside the DATETIME range, with an overflow error. Only the result is checked, so an amount whose units alone are longer than the range fails only when the moved instant is outside it
from_unixThe instant is outside the DATETIME range, with an overflow error
date_diffThe whole units do not fit I64, which only a count of nanoseconds between values more than about 292 years apart can reach, with an overflow error
parse_datetimetext does not name an instant in format, with a cast_failed error; its local time does not exist or is ambiguous in zone, with an invalid_argument error; or the instant is outside the DATETIME range, with an overflow error. See Reading Text

The error’s message names the function, such as date_add result is outside the DATETIME range, date_diff result does not fit I64, or parse_datetime local time is ambiguous in America/New_York.

SET hour = date_part('hour', input.occurred_at),
    local_hour = date_part('hour', input.occurred_at, 'America/New_York'),
    quarter_hour = date_bin('minute', 15, input.occurred_at, from_unix('second', 0)),
    local_day = date_trunc('day', input.occurred_at, 'Europe/Berlin'),
    renewal = date_add('month', 1, input.subscribed_at),
    deadline = date_add('millisecond', input.timeout_ms, input.occurred_at),
    age_seconds = date_diff('second', input.occurred_at, now()),
    received_at = from_unix('millisecond', input.epoch_ms),
    label = format_datetime('%a %d %b %Y %H:%M %Z', input.occurred_at, 'Asia/Tokyo'),
    logged_at = parse_datetime('%d/%b/%Y:%H:%M:%S %z', input.log_time)

Calendar Arithmetic

date_trunc, date_add, and date_diff work on the local calendar of their zone. In UTC and at a fixed offset every day is 24 hours long. Under the rules of an IANA time zone a local day can be shorter or longer, because daylight saving time and other transitions skip or repeat local times. An hour or any shorter unit is always elapsed time: date_add('hour', 1, ...) is one hour later in every zone.

date_trunc returns the first instant of the local unit that holds value: the earliest instant from which the zone’s clock showed times inside that unit without interruption until value. A day starts at local midnight, a week on Monday, a month on its first day, a quarter on the first day of January, April, July, or October, and a year on January 1.

  • date_trunc('day', ...) of 2024-03-10T11:00:00Z in America/New_York is 2024-03-10T05:00:00Z, midnight in New York, even though that day is only 23 hours long.
  • A unit whose local start a transition skips starts when the skipped span ends. America/Sao_Paulo skipped midnight on 2018-11-04, so that day starts at 2018-11-04T03:00:00Z, local 01:00.
  • A local time a transition repeats stays in one unit. New York showed 01:00 through 02:00 twice on 2024-11-03, and date_trunc('hour', ...) of both 2024-11-03T05:30:00Z and 2024-11-03T06:30:00Z is 2024-11-03T05:00:00Z, where the repeated hour began.
  • In an IANA zone every unit follows the zone’s clock, including local mean time before standard time: New York ran 4:56:02 behind UTC until 1883-11-18T17:00:00Z, when its clocks went back from 12:03:58 local mean time to 12:00 Eastern Standard Time, so date_trunc('day', ...) of 1883-11-18T17:01:00Z there is 1883-11-18T04:56:02Z.

date_add moves value in local time:

  • An hour and every shorter unit move by elapsed time.
  • A day or a week moves by 24 hours or 7 days in UTC and at a fixed offset. In an IANA zone it moves the local date and keeps the local time of day, so date_add('day', 1, ...) of 2024-03-09T12:00:00Z in America/New_York is 2024-03-10T11:00:00Z, 23 hours later.
  • A month, quarter, or year moves the local month and keeps the day of the month and the local time of day, unless the new month is shorter, in which case the result is on its last day: date_add('month', 1, ...) of 2024-01-31T23:30:00Z is 2024-02-29T23:30:00Z, and date_add('year', 1, ...) of 2024-02-29T12:00:00Z is 2025-02-28T12:00:00Z. Each call moves once from value, so adding one month twice to January 31, 2024 lands on March 29, while adding two months at once lands on March 31.
  • When the moved local time falls in a span the zone skips, the result moves forward by the length of that span, and when the zone shows the moved local time twice, the result is the earlier instant. Adding a day to 2024-03-09T07:30:00Z in New York lands on the skipped 02:30, so the result is 2024-03-10T07:30:00Z, local 03:30.
  • An amount of 0 returns value itself.

date_diff counts elapsed time for an hour and every shorter unit, and for a day or a week in UTC or at a fixed offset. It counts whole calendar units for a day or a week in an IANA zone and for a month, quarter, or year in every zone. A calendar count measures from start:

  1. The count runs to end’s local date, moved toward start by as few days as it takes for start’s local time of day on that date to not lie past end. date_diff('day', ...) from 2024-03-09T12:00:00Z to 2024-03-10T11:00:00Z in America/New_York is 1, because the start’s local time of day, 07:00, on 2024-03-10 in New York is 11:00 in UTC, and it is 0 in UTC, where the two instants are 23 hours apart.
  2. Days count the dates between. Weeks are whole sevens of those days.
  3. Months count from start’s local date, and a month is complete once the date has reached start’s day of the month. date_diff('month', ...) from 2024-01-31T00:00:00Z to 2024-02-29T00:00:00Z is 0, even though date_add('month', 1, ...) of the start is the end. Quarters and years are whole threes and twelves of those months.

Because a calendar count measures from start, exchanging start and end can change its magnitude as well as its sign. Two instants on the same local date are 0 days apart.

Time Zones

A zone is one of:

  • 'UTC'.
  • An IANA time zone name, such as 'Europe/Berlin', 'America/New_York', or 'Asia/Kolkata', matched without regard to letter case.
  • A fixed UTC offset written '+HH:MM' or '-HH:MM', from '-23:59' to '+23:59'. '-00:00' is the same zone as '+00:00', which reads as UTC does but is written +00:00 by %Z and %Q.

Anything else, including an abbreviation such as 'CEST', 'PST', or 'EDT' that is not also an IANA name, the host’s local zone, or an offset written another way, is rejected when the statement is applied, with function '<name>' does not accept time zone '<zone>'; expected an IANA time zone name, UTC, or a UTC offset such as '+05:30'. Some names that look like abbreviations are IANA names, and are accepted with the rules the database gives them: 'EST', 'MST', 'HST', 'CET', 'EET', 'WET', 'MET', 'GMT', 'UCT', 'Universal', and 'Zulu', as well as the 'Etc/GMT+5' family, whose sign is the reverse of the offset’s. A zone only decides how an instant reads as a local date and time: every DATETIME a function returns is a UTC instant.

Time zone rules come from the IANA Time Zone Database bundled into Nervix, release 2026c in this version. Nervix never reads the time zone database, the time zone, or the locale of the host it runs on, so every node of a cluster computes the same local times. A Nervix upgrade can bundle a newer release, and a zone whose rules the release changed then reads differently in every computation after the upgrade, including computations over instants in the past.

An IANA zone keeps the name the database gives it: %Q writes America/New_York for 'america/new_york', and US/Eastern for the alias 'US/Eastern'. A fixed offset is written in its +HH:MM form by both %Q and %Z, and UTC as UTC.

Datetime Formats

format_datetime writes a local date and time in a format, and parse_datetime reads one. A format is a STRING literal in which % starts a directive and every other character, including whitespace, is literal text written as it is and read only where it appears exactly. Names are English and never depend on a locale.

DirectiveWrites and readsLongest
%YThe year, in four digits4
%mThe month, 01–122
%b, %BThe month’s name, abbreviated as Jan or in full as January3, 9
%dThe day of the month, 01–312
%eThe day of the month padded with a space, 1–312
%jThe day of the year, 001–3663
%a, %AThe day of the week’s name, abbreviated as Mon or in full as Monday3, 9
%uThe ISO 8601 day of the week, from Monday as 1 to Sunday as 71
%wThe day of the week, from Sunday as 0 to Saturday as 61
%GThe ISO 8601 week-numbering year, in four digits4
%VThe ISO 8601 week, 01–532
%HThe hour of the 24-hour clock, 00–232
%IThe hour of the 12-hour clock, 01–122
%pAM before noon and PM from noon2
%MThe minute, 00–592
%SThe second, 00–592
%fThe fraction of the second in nine digits9
%1f–%9fThe fraction of the second in exactly that many digits, truncated1–9
%.fNothing for a whole second, and otherwise . and the fraction without trailing zeros10
%zThe UTC offset as +hhmm, followed by ss when the offset has seconds7
%:zThe UTC offset as +hh:mm, followed by :ss when the offset has seconds9
%::zThe UTC offset as +hh:mm:ss9
%sThe whole seconds since 1970-01-01T00:00:00Z, rounded down20
%ZThe abbreviation the zone shows, such as EST; written onlyThe zone’s longest
%QThe zone’s name, such as Europe/Berlin; written onlyThe name’s length
%F%Y-%m-%d10
%T%H:%M:%S8
%R%H:%M5
%%, %n, %tA %, a newline, and a tab1

The - flag writes a number without padding: %-m, %-d, %-j, %-V, %-H, %-I, %-M, and %-S. Any other directive, including a two-digit year, a locale’s date or time such as %c, a fraction written %.3f rather than %3f, and a flag on a directive that does not take it, such as %-e, is rejected when the statement is applied, and the message names the directive and the byte of the format it starts at, such as unknown directive '%q' at byte 3. A format that ends inside a directive, such as a trailing % or %-, is rejected with incomplete directive at byte <n>. Offsets from local mean time can have seconds, as New York’s -04:56:02 before 1883 does. %s counts whole seconds rounded down, so %s%.f writes 1969-12-31T23:59:59.5Z as -1.5: one second before the epoch plus half a second.

Every value a format describes must fit in 256 bytes, counting every directive at its longest in the call’s zone. A format that could describe a longer value is rejected when the statement is applied, so each value of a formatted column holds at most 256 bytes.

Writing Values

format_datetime writes value’s local date and time in zone, and writes a null value as null. It never fails a message. format_datetime('%FT%T%:z', ...) of 2024-11-03T06:30:00Z in America/New_York is 2024-11-03T01:30:00-05:00, and an empty format writes an empty string.

A call sizes its column for every message it formats at the format’s longest value before it writes any, so a batch whose messages could together exceed the 2,147,483,647 bytes one STRING column holds fails as a whole with format_datetime values for <n> messages of up to <m> bytes each could exceed the text one STRING column holds. At the 256-byte maximum that takes more than 8,388,607 formatted messages in one batch.

Reading Text

parse_datetime reads text from its first byte to its last and never guesses or normalizes what it reads:

  • Literal text must appear exactly, and a directive reads exactly its field: %m reads two digits, and %e reads a space and a digit, or two digits. A - flag reads as many digits as the field’s width allows, with or without a leading zero. %.f reads nothing, or . and one to nine digits; a . without a digit after it does not match. %s reads an optional -, never a +, and one to nineteen digits. %Y and %G read any four digits, and a year outside the DATETIME range fails as that range does.
  • Month and weekday names, and AM and PM, are read without regard to letter case. %z, %:z, and %::z read the forms they write, and also read an uppercase Z as UTC. Each part of an offset is at most 23:59:59.
  • Every field must lie in its range: a 13th month, hour 24, and second 60 are rejected, since a DATETIME has no leap seconds. The date must exist, so 2023-02-29 is rejected rather than read as March 1, and a day of the week read with a calendar or ordinal date must be that date’s day.

The first field that fails is reported, checking the time of day before the date: a text such as 2023-02-29 24:00:00 reports its hour. A day of the year 366 in a common year and an ISO week 53 in a year of 52 weeks are dates that do not exist.

A readable format reads each field at most once and names exactly one instant. An empty format writes an empty string but reads no instant:

  • A date, as %Y with a month and a day of the month, %Y with %j, %G with %V and a day of the week, or %s with nothing but a fraction of a second. %Z and %Q cannot be read.
  • Optionally a time of day, as %H, or %I with %p. %M requires an hour, %S requires %M, and a fraction of a second requires %S. A time of day the format does not read is midnight.
  • Optionally a UTC offset. A format that reads %z, %:z, or %::z places its local date and time at that offset, and one that reads %s names its instant directly: both take no zone. Every other format reads a local date and time and requires a zone to place it.

disambiguation decides the instant of a local date and time that zone skips or repeats:

DisambiguationSkipped local timeRepeated local time
rejectFails with an invalid_argument errorFails with an invalid_argument error
earlierThe earlier of the two instants it could mean, read at the offset after the transitionThe earlier instant
laterThe later instant, read at the offset before the transitionThe later instant
compatibleThe later instant, as date_add moves local timesThe earlier instant, as date_add moves local times

A call without disambiguation rejects. In America/New_York, 2024-03-10 02:30:00 does not exist and 2024-11-03 01:30:00 happens twice: earlier reads them as 2024-03-10T06:30:00Z and 2024-11-03T05:30:00Z, and later as 2024-03-10T07:30:00Z and 2024-11-03T06:30:00Z.

A null text produces a null result. Every other failure fails only its message:

ErrorMessage
cast_failedparse_datetime input does not match its format at byte 4: expected '-', naming the literal text or directive that did not match
cast_failedparse_datetime input continues past its format at byte 19
cast_failedparse_datetime hour is out of range, naming the field
cast_failedparse_datetime date does not exist
cast_failedparse_datetime day of the week does not match the date
invalid_argumentparse_datetime local time does not exist in America/New_York
invalid_argumentparse_datetime local time is ambiguous in America/New_York
overflowparse_datetime result is outside the DATETIME range

A message never quotes the text it could not read. It names byte positions, the format’s own literal text and directives, field names, and the zone.

Array And Vector Functions

[a, b, ...] and array(a, b, ...) construct an ARRAY whose fixed width is the number of arguments. vec(a, b, ...) constructs a VEC; vec() constructs an empty vector when it is the whole value assigned to a declared VEC field, which supplies its element type. Constructor elements must have exactly the same declared type, which may be any type, BYTES, ARRAY, and VEC included, so a literal element beside a narrower field is cast: [input.low, 0 AS I32]. If any element is null, the constructed container is null; schema elements themselves remain required. A fixed ARRAY has at least one element, so [] and array() are rejected.

The functions below take ARRAY or VEC values described in Schemas And Codecs, and a function of two lists accepts an ARRAY and a VEC, or fixed arrays of different widths, together. The elements of a multidimensional ARRAY are its outermost items. A null container produces a null result, including for binary functions when either container is null.

FunctionElementsReturnsNotes
count(list)anyI64Number of elements
sum(list)numericoptional element typeSum of the elements. An empty list returns null
first(list)numeric, BOOL, STRING, DATETIMEoptional element typeThe first element, or null for an empty list
last(list)numeric, BOOL, STRING, DATETIMEoptional element typeThe last element, or null for an empty list
nth(list, index)numeric, BOOL, STRING, DATETIMEoptional element typeThe element at index, counting from 0, or null when index is negative or past the end. index may be any integer type
contains(list, element)any scalarBOOLWhether element, of exactly the element type, occurs in the list. Empty lists return false; a null element returns null
overlap(left, right)any scalarBOOLWhether two lists of the same exact element type share an element. An empty list returns false
slice(list, start, length)anyVEC<element>Selects up to length elements from the zero-based start; negative bounds act as zero and bounds past the end are clipped. Null bounds return null
concat(list, ...)anyARRAY or VECConcatenates lists with one exact element type. All fixed arrays produce a fixed array whose width is their sum; any vector input produces a vector
min(list), max(list)numeric, BOOL, STRING, DATETIMEoptional element typeLeast or greatest element. Empty lists return null
mean(list)numericoptional F64Arithmetic mean. An empty list returns null; integer inputs round to F64 for the calculation
dot(left, right)numericelement typeDot product of two lists of one element type. Empty lists return zero. Integer multiplication and accumulation are checked at the element type
distance(left, right)numericF64Euclidean distance of two lists of one element type. Empty lists return zero; integer inputs round to F64 for the calculation

nth counts from 0, unlike string positions: nth(input.values, 0) is the same element as first(input.values). A function whose elements the table narrows rejects any other element type when the statement is validated, such as a list whose elements are themselves ARRAY or VEC values, with function 'first' requires ARRAY or VEC elements of a scalar type. first, last, nth, min, and max do not take BYTES elements.

min and max order floating-point elements by the IEEE 754 total order, in which -0.0 is below 0.0, a NaN with the sign bit set is below every other value, and any other NaN is above every other value. This differs from greatest and least and from the window MIN and MAX aggregates, which treat both zeros as equal and order every NaN above every other value. Among equal elements, the first is returned.

sum follows the arithmetic operators: an integer sum that overflows its type reports integer sum overflowed with the kind overflow, and a floating-point sum that is not finite reports floating-point sum produced a non-finite result with the kind invalid_argument. dot checks its products and their sum at the element type, so a dot product of two U8 lists fails once it reaches 256, with integer dot product overflowed. dot and distance require equal lengths in every message; a mismatch reports invalid_argument, vector lengths differ: left has 3, right has 2, for that message. A non-finite mean, dot, or distance reports invalid_argument, such as mean produced a non-finite result; mean sums its elements in F64 before it divides, so a sum beyond the F64 range fails even where the mean itself would be finite. contains and overlap use the scalar equality contract: NaN equals no element, and positive and negative zero are equal. These functions compare exact element types; they never cast or stringify list elements.

In a window route, count, sum, first, last, min, and max are always window aggregates, like every other aggregate name. They take a per-row expression over input and aggregate it across the rows the window retained, so COUNT(input.values) aggregates retained rows rather than counting the elements of one list. A window route therefore cannot use these six list functions, even inside an aggregate’s argument, where SUM(count(input.values)) is rejected as a nested aggregate. Everywhere else these names are the list functions above.

Window Aggregates

A window processor route computes its output from aggregates over the rows its window retains when it emits. Aggregates appear directly in the route’s SET, and every route uses at least one. An aggregate reads per-row arguments from input, and input is available only inside aggregate arguments, which read nothing else. Aggregate calls may take part in larger scalar expressions, such as arithmetic, a conditional, a datetime function, or a [...] over aggregates, combined with constants, but aggregates cannot be nested. Route WHERE reads the finalized output. A statement that breaks these rules is rejected when it is applied, with a message such as input.latency is available only inside a window aggregate argument, aggregate functions must not be nested inside aggregate arguments, CORR expects 2 argument(s), found 1, or window output 'summary' must contain at least one aggregate function.

Aggregate names are case-insensitive, and argument and result types are exact and checked when the processor is applied. No aggregate argument may be BYTES or contain BYTES, which is rejected with ... cannot retain BYTES in aggregate demand <n> argument <m>.

FunctionArgumentsReturnsTyped null when
COUNT(value)anyI64never; counts every retained row
COUNT_IF(condition)BOOLI64never; counts rows whose condition is true
BOOL_AND(condition)BOOLBOOLno row has a condition
BOOL_OR(condition)BOOLBOOLno row has a condition
SUM(value)numericthe argument’s typeno row has a value
AVG(value)numericF64no row has a value
MIN(value), MAX(value)numeric, BOOL, STRING, or DATETIMEthe argument’s typeno row has a value
FIRST(value), LAST(value)anythe argument’s typeno row has a value
ARG_MIN(value, key), ARG_MAX(value, key)any value; a numeric, BOOL, STRING, or DATETIME keythe value’s typeno row has both a value and a key
VAR_POP(value), STDDEV_POP(value)numericF64no row has a value
VAR_SAMP(value), STDDEV_SAMP(value)numericF64fewer than two rows have a value
COVAR_POP(first, second)numeric, numericF64no row has both values
COVAR_SAMP(first, second)numeric, numericF64fewer than two rows have both values
CORR(first, second)numeric, numericF64fewer than two rows have both values, or either variable is constant across them
PERCENTILE_LINEAR_HISTOGRAM(value, percentile, buckets, min, max, delay)numeric value, then constantsF64the histogram counts no value
APPROX_COUNT_DISTINCT(value, precision)numeric, BOOL, STRING, or DATETIME; constant precision 4–16I64never; zero when no row contributes
APPROX_QUANTILE(value, percentile, capacity)numeric; constant percentile 0–100 and capacity 32–4096F64no row has a value
APPROX_TOP_K(value, k, capacity)numeric, BOOL, STRING, or DATETIME; constants 1 <= k <= capacity <= 4096VEC<value type>never; empty when no row contributes

The approximate sketches describe the last three.

Nulls. A row contributes to an aggregate only when every argument that aggregate reads is present, so a null argument contributes nothing. COUNT is the exception: it counts every retained row whatever its argument holds, so SUM(input.amount) / COUNT(input.amount) is not the mean of an optional field; use AVG. A window emits only while it retains at least one row, so an aggregate whose arguments are all required always has a value, except the sample statistics and CORR, which can be undefined in any window. COUNT, COUNT_IF, APPROX_COUNT_DISTINCT, and APPROX_TOP_K are never optional; VAR_SAMP, STDDEV_SAMP, COVAR_SAMP, and CORR are always optional; every other aggregate is optional exactly when an argument it reads is. Assign an aggregate that can be null to an OPTIONAL field or give it a value with COALESCE; assigning it to a required field is rejected when the processor is applied.

Order and ties. MIN, MAX, ARG_MIN, and ARG_MAX return the earliest admitted row among rows with equal keys. FIRST and LAST order rows by their ingestion low watermark, then by admission. BOOL orders false before true, STRING orders by bytes, and floating-point keys order NaN above every other value and treat both zeros as equal.

Population and sample. VAR_POP, STDDEV_POP, and COVAR_POP divide by the number of contributing rows n; VAR_SAMP, STDDEV_SAMP, and COVAR_SAMP divide by n - 1. Standard deviations are the square roots of the matching variances. CORR is the Pearson correlation, kept within [-1, 1].

Numerical behavior. COUNT, COUNT_IF, BOOL_AND, BOOL_OR, and SUM over integers are exact: an integer SUM is accumulated without overflow and checked against its argument’s type only when the window emits. SUM over floating-point values carries the rounding error of every addition beside the running total, and an F32 SUM accumulates in F64 and rounds to F32 when it emits. AVG, the variances, standard deviations, covariances, and CORR convert each argument to the nearest F64 and keep centered moments, so a variance is never the difference of two large sums of squares. Admission computes a compensated mean and then centered second moments over each typed run; one merge combines that run with the retained aggregate. Floating-point results can vary slightly with run and SIMD lane grouping because addition and merging round in a different order. Integer sums and counts are independent of grouping. Stepping a window never subtracts a floating-point value from a statistic: the statistic of the rows that remain is rebuilt from the rows themselves, so even a large value that left the window leaves no rounding behind.

Errors. A row is refused at admission, and never changes the window, when an argument expression fails for it, when a floating-point argument of SUM, AVG, a variance, standard deviation, covariance, CORR, PERCENTILE_LINEAR_HISTOGRAM, or a sketch is NaN or infinite, or when it would exceed the window’s MAX STATE SIZE or pane limit. A refused row is logged and not acknowledged, whatever the route’s ON MESSAGE ERROR policy says; a non-finite argument is reported by the first function, in alphabetical order, of the structure that reads it, such as AVG requires finite floating-point arguments. At emission, a SUM that does not fit its type, such as SUM of the window does not fit Float32, or a statistic that overflows F64 fails the window: every retained row’s acknowledgement fails, the window is cleared, and routes the window emitted before the failing one keep their output.

Sensitivity. An aggregate is sensitive when an argument reads a sensitive field, including COUNT, COUNT_IF, APPROX_COUNT_DISTINCT, and APPROX_TOP_K, whose results do not contain the field’s values. Wrap the aggregate, or its argument, in leak_sensitive(...) to assign it to a field that is not sensitive.

Shared structures. Aggregates of one route that can be answered from one structure over the same arguments share it: AVG, the variances, and the standard deviations of one argument share a moments structure; the covariances and CORR of one argument pair, in the same order, share co_moments; COUNT_IF, BOOL_AND, and BOOL_OR share a truth_counter; ARG_MIN and ARG_MAX share arg_extremes; MIN and MAX share extremes; FIRST and LAST share a sequence; APPROX_QUANTILE calls with one argument and capacity share a quantile_sketch; and PERCENTILE_LINEAR_HISTOGRAM calls with one argument, bucket count, bounds, and delay share a linear_histogram. DESCRIBE WINDOW PROCESSOR lists every structure with the functions it serves and the arguments it reads.

Linear Histogram Percentiles

PERCENTILE_LINEAR_HISTOGRAM(value, percentile, buckets, min, max, delay) counts value in buckets buckets of equal width from min to max, and returns the midpoint of the bucket that holds the value of rank round(percentile / 100 * (n - 1)) among the n counted values, clamped to [min, max]. It does not interpolate within a bucket, so its precision is the bucket width, (max - min) / buckets. A value at or below min counts in the first bucket and one at or above max in the last, rather than being dropped.

percentile is an integer or float literal from 0 to 100, buckets a positive integer literal, and min and max numeric literals with min below max. A negative number is written with unary minus, which is not a literal, so min and max are zero or above. delay is a duration literal such as '2s': a row that stepping removes from the window stays counted until the watermark or the domain clock passes its removal time plus delay, and '0s' removes it at once. A delay that names no duration, or whose spans could add up to 18446744073709551615 seconds, is rejected when the window processor is created, with invalid PERCENTILE_LINEAR_HISTOGRAM delay duration '<delay>': <reason>. Each branch holds eight bytes per bucket for each histogram, which MAX STATE SIZE does not count.

Approximate Sketches

APPROX_COUNT_DISTINCT, APPROX_QUANTILE, and APPROX_TOP_K answer from bounded sketches instead of the retained values. A route that uses one needs its window processor to declare MAX STATE SIZE, duration WIDTH and STEP, and, when it is branched, a branch with MAX INSTANCES ... EVICT LRU, as Window processor describes. The sketches ignore nulls and refuse non-finite floating-point values as described under Errors above.

SketchAlgorithmAccuracyReserved per pane
APPROX_COUNT_DISTINCT(value, precision)HyperLogLog with 2^precision registers over a stable, type-tagged BLAKE3 hash of each valueRelative standard error of about 1.04 / sqrt(2^precision): 26% at precision 4, 3.25% at 10, and 0.41% at 162^precision + 128 bytes
APPROX_QUANTILE(value, percentile, capacity)t-digest with at most capacity centroids, smaller near the tailsExact, interpolating linearly at rank percentile / 100 * (n - 1), while at most capacity values contribute; beyond that, an interpolated estimate whose accuracy depends on the distribution and capacity, with no distribution-independent bound32 * capacity + 128 bytes
APPROX_TOP_K(value, k, capacity)Misra-Gries frequency candidates, at most capacity keysEvery value that occurs more than n / (capacity + 1) times among the n contributing rows remains a candidate; values near the frequency cutoff may differ from an exact top-k160 * capacity + 128 bytes

APPROX_TOP_K returns up to k values ordered by estimated frequency, then by the bytes of their type-tagged key to break ties, so ties among numbers do not follow numeric order. Each value it returns is the one the earliest retained row with that key holds. Its counts are internal. APPROX_COUNT_DISTINCT hashes -0.0 and 0.0 as one value and a STRING by its UTF-8 bytes.

Memory. A sketch window keeps one sketch for each pane, a span of time whose length is the greatest common divisor of WIDTH and STEP, aligned to the Unix epoch, and merges the panes still in the window when it emits. A window with duration WIDTH w and pane length p spans at most w / p + 2 panes. For each route, the processor is rejected when it is applied unless 1024 + (the route's reservations summed) * (w / p + 3) bytes fit its MAX STATE SIZE, with window processor '<name>' ... requires <bytes> bytes for <panes> sketch panes, above MAX STATE SIZE <limit> bytes. At run time each branch charges the reservation of every sketch in every active pane, and of the merged sketch it builds at emission, beside the retained rows and their argument columns, and a row that would exceed MAX STATE SIZE is refused. The configured worst case across branches is therefore bounded by MAX STATE SIZE * MAX INSTANCES; an unbranched window has one state.

Expiry. Panes are merged only from rows still in the active window and rebuilt after stepping, so a row that left the window never contributes to a later result. Mergeability does not make a sketch retractable: nothing is subtracted from one. When a branch’s ownership moves or a node recovers, the sketches are rebuilt from the retained input columns that the branch’s snapshots carry.

Determinism. A sketch is deterministic for the same ordered inputs, pane layout, and configuration, so every node computes the same estimate from the same rows.

Measured accuracy. In the VM Functions benchmark report, APPROX_COUNT_DISTINCT over full windows of distinct keys measured a mean absolute relative error of 1.74% for 500 keys and 1.90% for 100 keys at precision 10, whose standard error is 3.25%, and 0.26% for 500 keys at precision 16, whose standard error is 0.41%, over 31 windows each. The quantile and top-k sketches have no measured accuracy beyond the exactness above.

Limits

Every limit an expression can reach is fixed by Nervix rather than configured, except the window state size a window processor declares:

LimitBoundReached by
Text or octets one STRING or BYTES column holds2,147,483,647 bytes for the results of one call in one batchThe functions of Result Size, which fail each message that does not fit with overflow, and format_datetime, which fails the batch
Parts of one split result65,536split, with overflow
LIKE pattern4,096 byteslike and ilike, with invalid_argument
contains_any set128 non-null patterns and 65,536 bytes of pattern text; 64 distinct sets prepared per batchcontains_any, with invalid_argument
Compiled regular expression10 MiB; 64 compiled patterns cached per callRegular-expression functions, with invalid_argument
JSON document16,777,216 bytes and 128 levels of nestingJSON_VALUE and JSON_EXISTS, with invalid_argument; TRY_JSON_VALUE yields null
JSON path64 steps; array index up to 4,294,967,295Rejected when the statement is parsed
DATETIME1677-09-21T00:12:43.145224192Z through 2262-04-11T23:47:16.854775807Z, in nanosecondsDatetime functions and conversions, with overflow
Datetime format256 bytes per value, counting every directive at its longestRejected when the statement is applied
date_bin width9,223,372,036,854,775,807 nanoseconds, about 292 yearsRejected when the statement is applied
Window sketchesPrecision 4–16; capacity 32–4096; k at most the capacityRejected when the processor is applied
Window sketch stateMAX STATE SIZE per branch, and at most MAX INSTANCES branchesRows refused at admission; see Approximate Sketches

The one bound on how long a builtin runs is the size of its input: every builtin finishes the batch it is evaluating, bounded by the limits above, so stopping a node or a processor takes effect between batches. A UDF has its own watchdog; see User-Defined Functions.

Measured Performance

Execution is columnar, so a batch pays a fixed cost once and each message pays for its own work. Two measured facts, from the VM batch-size sweep and from the VM function workloads below, shape what an expression costs:

  • Per-message cost falls steeply up to about a thousand messages per batch and then flattens. Batches above 1,024 messages execute on the node’s data workers, whose admission and hand-off cost more than a small batch does to execute.

  • An expression costs what its functions do for the messages that evaluate them. Inside a conditional arm, some operations run only for the messages that select the arm, so their cost follows the share of messages it selects:

    • operations that can fail for a message and cost more than the batch’s narrowing does, such as pattern matching, text parsing, calendar arithmetic, and transcendental functions
    • UDFs
    • JSON extraction

    Every other operation runs over the whole batch, and the arm keeps only its own messages’ results.

The table shows the median time to execute a complete compiled program over one batch, measured with the VM function workloads on one 32-thread x86-64 development machine in September 2026, with other builds running on the same machine. The absolute rates are specific to that machine and are not guarantees; the ratios between rows are what carries over.

Workload, 1,024 messages unless notedMedian per batchMessages per second
Checked integer arithmetic, no failures8.09 µs127 million
Checked integer arithmetic, sparse failures15.48 µs66 million
Checked integer arithmetic, dense failures51.07 µs20 million
Arithmetic, 1 message3.44 µs0.29 million
Arithmetic, 8 messages3.38 µs2.4 million
Arithmetic, 1,025 messages, on the data workers14.40 µs71 million
Arithmetic over a sliced batch10.39 µs99 million
List function over ragged VEC values384.74 µs2.7 million
contains_any with a per-message set, 32-byte ASCII text117.76 µs8.7 million
contains_any with a per-message set, 1,024-byte UTF-8 text143.61 µs7.1 million
repeat producing 1, 8, and 64 copies19.59, 25.76, and 40.18 µs52, 40, and 25 million
Four JSON_VALUE extractions from one document field548.58 µs1.9 million
Four JSON_VALUE extractions from four document fields1,861.2 µs0.55 million
Regular expression in a conditional arm that selects no, 1%, and 50% of messages2.45, 5.25, and 30.05 µs417, 195, and 34 million

What the measurements show:

  • A message that fails costs several times what a successful one does: dense failures made checked arithmetic six times slower. Guard expected failures with a conditional, TRY_CAST, or TRY_JSON_VALUE rather than relying on the error path.
  • Extractions from one document share its parse, so four fields read from one document cost less than a third of four fields read from four documents.
  • Variable-length work such as ragged lists, text search, and JSON runs at millions of messages per second rather than the hundred million of fixed-width arithmetic.

The workloads, commands, allocation observations, and hardware are recorded in the VM functions measurement report.

Catalog

Every operator, builtin, and aggregate, with the section that describes its types, nulls, and errors. T is the argument’s type, and a list is an ARRAY or a VEC.

NameReturnsSection
+, -, *, /, %, unary -numeric TArithmetic
AND, OR, NOTBOOLLogical Operators
=, !=, <, <=, >, >=BOOLComparison And Equality
IS [NOT] DISTINCT FROMBOOL, never nullComparison And Equality
[NOT] IN (...)BOOLSets
[NOT] BETWEEN ... AND ...BOOLRanges
IF ... END, CASE ... ENDthe result typeConditional Expressions
<expr> AS <type>, TRY_CAST(<expr> AS <type>)<type>Conversions
JSON_VALUE, TRY_JSON_VALUEoptional declared typeJSON Documents
JSON_EXISTSBOOLJSON Documents
coalesce, nullifTNull Handling
is_nullBOOL, never nullNull Handling
greatest, least, clampTExtrema
leak_sensitiveTContext And Identity
nowDATETIMEContext And Identity
uuid_v4, uuid_v7STRINGContext And Identity
read_headeroptional STRINGHeader Functions
read_headersVEC<STRING>Header Functions
write_headernothing; INVOKE onlyHeader Functions
LOOKUP_HASH_MAPoptional field typeLookups And UDF Calls
udf::<name>the UDF’s result typeLookups And UDF Calls
lower, upper, initcap, trim, btrim, ltrim, rtrim, reverse, normalize_nfcSTRINGString Functions
left, right, substr, substring, repeat, lpad, rpad, replace, translate, split_partSTRINGString Functions
concat over text, concat_ws, joinSTRINGString Functions
splitVEC<STRING>String Functions
length, char_length, bit_length, octet_length, ascii, strposI64String Functions
to_hex, md5STRINGString Functions
contains over text, starts_with, ends_with, contains_any, like, ilikeBOOLString Predicates
regexp_likeBOOLRegular Expressions
regexp_replaceSTRINGRegular Expressions
regexp_substr, regexp_extractoptional STRINGRegular Expressions
bytes_from_utf8, base64_decode, hex_decode, sha256BYTESBytes, Encodings And Hashes
bytes_to_utf8, base64_encode, hex_encodeSTRINGBytes, Encodings And Hashes
xxh3_64U64Bytes, Encodings And Hashes
ip_from_string, ip_trunc, ip_unmapBYTESIP Addresses And Networks
ip_to_stringSTRINGIP Addresses And Networks
ip_familyI64IP Addresses And Networks
ip_in_network, is_ip_addressBOOLIP Addresses And Networks
url_scheme, url_path, url_decodeSTRINGURLs
url_host, url_query, url_fragment, url_query_valueoptional STRINGURLs
url_portoptional I64URLs
url_query_valuesVEC<STRING>URLs
is_urlBOOLURLs
abs, ceil, ceiling, floor, round, trunc, signnumeric TNumeric Functions
acos, asin, atan, atan2, cos, sin, tan, degrees, radians, exp, ln, log, log2, pow, power, sqrtF64Numeric Functions
round(x, digits)numeric TPrecision Rounding
is_nan, is_finite, is_infiniteBOOLFloating-Point Classification
bitwise_and, bitwise_or, bitwise_xor, bitwise_not, shift_left, shift_rightinteger TBitwise Functions
bit_countI64Bitwise Functions
date_part, date_diff, to_unixI64Datetime Functions
date_trunc, date_bin, date_add, from_unix, parse_datetimeDATETIMEDatetime Functions
format_datetimeSTRINGDatetime Formats
[...], arrayARRAYArray And Vector Functions
vec, sliceVECArray And Vector Functions
concat over listsARRAY or VECArray And Vector Functions
count over a listI64Array And Vector Functions
sum, first, last, nth, min, max over a listoptional element typeArray And Vector Functions
contains over a list, overlapBOOLArray And Vector Functions
meanoptional F64Array And Vector Functions
dotelement typeArray And Vector Functions
distanceF64Array And Vector Functions
COUNT, COUNT_IF, APPROX_COUNT_DISTINCTI64Window Aggregates
BOOL_AND, BOOL_ORBOOLWindow Aggregates
SUM, MIN, MAX, FIRST, LASTthe argument’s typeWindow Aggregates
ARG_MIN, ARG_MAXthe value’s typeWindow Aggregates
AVG, VAR_POP, VAR_SAMP, STDDEV_POP, STDDEV_SAMP, COVAR_POP, COVAR_SAMP, CORRF64Window Aggregates
PERCENTILE_LINEAR_HISTOGRAMF64Linear Histogram Percentiles
APPROX_QUANTILEF64Approximate Sketches
APPROX_TOP_KVEC of the value’s typeApproximate Sketches

Example

This graph runs without any external system: a node’s own HTTP endpoint receives access records, a junction describes each one with the address, URL, JSON, datetime, conditional, identity, and hash functions, and a window processor summarizes latency with exact and approximate aggregates. Create the domain and select it:

CREATE UNPACED DOMAIN functions_example;

USE functions_example;

Then declare the graph:

BEGIN;

CREATE SCHEMA access_event (
  client STRING,
  url STRING,
  payload STRING,
  logged STRING,
  latency_ms I64
);

CREATE WIRE JSON SCHEMA access_wire MODE STRICT (
  client string,
  url string,
  payload string,
  logged string,
  latency_ms integer
);

CREATE CODEC access_codec
  FROM WIRE JSON SCHEMA access_wire
  TO SCHEMA access_event;

CREATE SCHEMA access_summary (
  client_address BYTES,
  client_subnet STRING,
  internal BOOL,
  host STRING OPTIONAL,
  campaign STRING OPTIONAL,
  customer_id I64 OPTIONAL,
  tags VEC<STRING> OPTIONAL,
  logged_at DATETIME,
  local_hour I64,
  latency_band STRING,
  event_id STRING,
  url_digest STRING
);

CREATE SCHEMA latency_window (
  requests I64,
  mean_latency F64,
  p90_latency F64,
  distinct_clients I64
);

CREATE RELAY access_events SCHEMA access_event UNBRANCHED;

CREATE RELAY access_summaries SCHEMA access_summary UNBRANCHED;

CREATE RELAY latency_windows SCHEMA latency_window UNBRANCHED;

CREATE VHOST functions_edge access.example.com;

CREATE ENDPOINT access_ingress
  ON functions_edge
  PATH '/access'
  TYPE HTTP;

CREATE HTTP ENDPOINT INGESTOR access_intake
  FROM access_ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING access_codec
  TO access_events
    INHERIT ALL
    UNBRANCHED
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

CREATE JUNCTION describe_access
  FROM access_events
  UNBRANCHED
  TO access_summaries
    SET client_address = ip_from_string(input.client),
        client_subnet = ip_to_string(ip_trunc(output.client_address, IF ip_family(output.client_address) = 4 THEN 24 ELSE 48 END)),
        internal = ip_in_network(ip_unmap(output.client_address), '10.0.0.0/8'),
        host = url_host(input.url),
        campaign = url_query_value(input.url, 'utm_campaign'),
        customer_id = TRY_JSON_VALUE(input.payload, '$.customer.id' AS I64),
        tags = JSON_VALUE(input.payload, '$.tags' AS VEC<STRING>),
        logged_at = parse_datetime('%d/%b/%Y:%H:%M:%S %z', input.logged),
        local_hour = date_part('hour', output.logged_at, 'Europe/Berlin'),
        latency_band = CASE
          WHEN input.latency_ms < 100 THEN 'fast'
          WHEN input.latency_ms < 1000 THEN 'slow'
          ELSE 'stalled'
        END,
        event_id = uuid_v7(),
        url_digest = hex_encode(sha256(bytes_from_utf8(lower(trim(input.url)))))
    WHERE NOT output.internal OR output.latency_band != 'fast'
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

CREATE WINDOW PROCESSOR access_latency
  FROM access_events
  WIDTH 10s DURATION
  STEP 10s DURATION
  MAX STATE SIZE 1MiB
  UNBRANCHED
  TO latency_windows
    SET requests = COUNT(input.client),
        mean_latency = AVG(input.latency_ms),
        p90_latency = APPROX_QUANTILE(input.latency_ms, 90, 64),
        distinct_clients = APPROX_COUNT_DISTINCT(input.client, 12)
    ON MESSAGE ERROR LOG;

COMMIT;

describe_access parses the client address once into the BYTES field client_address and reads it back through output.client_address, so client_subnet and internal cost no second parse. TRY_JSON_VALUE yields a typed null for a customer id that is not an integer, and every extraction shares one parse of each payload. local_hour reads the local hour in Berlin from the logged_at value an earlier assignment computed. The route drops fast internal requests.

Start the domain, subscribe to both outputs, each in its own terminal, and post three records:

START;
nervix-cli --domain functions_example subscribe access_watch access_summaries
nervix-cli --domain functions_example subscribe latency_watch latency_windows

curl -X POST http://127.0.0.1:8080/access -H 'Host: access.example.com' \
  -H 'Content-Type: application/json' \
  -d '{"client":"203.0.113.9","url":"https://Shop.Example.com/landing?utm_campaign=spring&tag=a","payload":"{\"customer\":{\"id\":42},\"tags\":[\"new\",\"vip\"]}","logged":"25/Sep/2026:14:03:07 +0000","latency_ms":85}'
curl -X POST http://127.0.0.1:8080/access -H 'Host: access.example.com' \
  -H 'Content-Type: application/json' \
  -d '{"client":"10.1.2.3","url":"https://shop.example.com/cart","payload":"{\"customer\":{\"id\":\"c-7\"},\"tags\":[]}","logged":"25/Sep/2026:14:03:08 +0000","latency_ms":1450}'
curl -X POST http://127.0.0.1:8080/access -H 'Host: access.example.com' \
  -H 'Content-Type: application/json' \
  -d '{"client":"10.1.2.4","url":"https://shop.example.com/","payload":"{\"tags\":[\"x\"]}","logged":"25/Sep/2026:14:03:09 +0000","latency_ms":40}'

access_summaries receives the first two records, and the third, a fast internal request, is filtered out. The subscription renders BYTES as base64 and omits a null optional field, and event_id differs on every run:

{"campaign":"spring","client_address":"ywBxCQ==","client_subnet":"203.0.113.0","customer_id":42,"event_id":"01a0d977-422d-7530-a7df-1f7cc335ffd8","host":"shop.example.com","internal":false,"latency_band":"fast","local_hour":16,"logged_at":"2026-09-25T14:03:07+00:00","tags":["new","vip"],"url_digest":"e3573155b66ec8947e80bc6a6cf1d8dc62108261d9d8c0b64906259bd7f47302"}
{"client_address":"CgECAw==","client_subnet":"10.1.2.0","event_id":"01a0d977-423f-7d17-820b-d2be58c6d798","host":"shop.example.com","internal":true,"latency_band":"stalled","local_hour":16,"logged_at":"2026-09-25T14:03:08+00:00","tags":[],"url_digest":"aebca4f73e8e9c15a3679803b396f37c61a468bf9e24a60a95fe1d6d1078131c"}

When the ten-second window closes, latency_windows receives one summary of all three requests. Three latencies are within the quantile sketch’s capacity of 64, so p90_latency is exact: rank 0.9 * 2 interpolates between 85 and 1450.

{"distinct_clients":3,"mean_latency":525.0,"p90_latency":1177.0,"requests":3}

Domains And Time

Every runtime graph in Nervix runs inside a domain.

This chapter describes the public language and runtime behavior. See Domain Clock for the internal clock architecture.

Nervix currently supports:

  • CREATE PACED DOMAIN <id> WITH PERIOD <duration> SKEW <duration> [PLACEMENT <policy>];
  • CREATE UNPACED DOMAIN <id> [PLACEMENT <policy>];
  • CREATE DOMAIN <id> [PLACEMENT <policy>];

CREATE DOMAIN <id> is the short spelling for CREATE UNPACED DOMAIN <id>.

Domain creation is never transaction content. A transaction is bound to one already-existing domain, so CREATE DOMAIN runs on its own before BEGIN; queueing it is rejected. See Replicated NSPL Transactions.

Paced Domains

Paced domains maintain a domain clock.

PERIOD must be positive and no larger than 18446744073709551615ns. SKEW may be zero but must fit in that same 64-bit nanosecond duration range. Invalid durations are rejected before the domain is stored. Duration text anywhere in NSPL whose spans could add up to 18446744073709551615 seconds, however they are written, is rejected: a duration literal when the statement is parsed, and a duration string argument, such as the delay of PERCENTILE_LINEAR_HISTOGRAM, when the statement is validated.

While the domain is running:

  • Nervix produces domain ticks
  • paced ingestors admit records whose effective timestamp falls within SKEW of an eligible logical tick center

Centers are origin + n × PERIOD, where origin is the committed logical start and n is nonnegative. The current domain mapping determines the reached frontier. Only the newest 256 positions through that frontier are eligible; position zero is eligible immediately on start. SKEW is an inclusive logical-time distance: an event exactly at either edge is accepted. Overlapping windows admit their union, while gaps between windows reject events.

Admission reconstructs these positions from the installed mapping. Delayed, missing, or coalesced tick notifications do not change eligibility. A hypothetical future center is never eligible: an event can extend past the last reached center only by its declared SKEW. An event before the origin can be admitted within the origin’s tolerance while that position remains eligible. TIME RATE changes when the frontier advances in real time; it does not scale source timestamps, PERIOD, or SKEW.

Paced time is also important for expiration:

  • branch TTL uses domain logical time in paced domains, including the relay owner’s cluster-wide branch-presence decision
  • materialized-state cleanup follows the same logical-time rule
  • deduplication expiry, reorder and correlation retention, window completion, and guest-requested WASM timeouts are logical deadlines on the same clock
  • branch activity is the domain time at which a branch’s last input was accepted, so an idle relay owner or processor never spends part of a branch’s TTL waiting for that input

The maintenance scan that releases expired branches and the snapshot wakeups that persist branch-local state run on the physical clock, and so does the WASM executor’s own safety timeout. Their predicates still compare logical deadlines, so a scan never expires a branch early: its interval is release latency measured after the logical boundary, reported separately from the TTL itself. See TTL.

Deterministic Roto UDFs preserve reproducibility when paced input is replayed at an accelerated time rate. See the deterministic-by-default and VOLATILE contract, including common-subexpression reuse and the rule that user code is never constant-folded.

Unpaced Domains

Unpaced domains do not produce ticks.

Ingestors in an unpaced domain admit records as they arrive, and branch TTL uses wall clock time.

Placement Default

Every domain has a fallback placement policy. It applies to directly connected runtime-node pairs that no named placement rule claims. Because relays are scheduled runtime nodes, a producer-to-relay edge and a relay-to-consumer edge each receive the default independently. The clause is optional on every domain-creation form, and omission means NEUTRAL so ordinary scheduler heuristics remain active:

CREATE PACED DOMAIN production
  WITH PERIOD 1s SKEW 100ms
  PLACEMENT REQUIRE COLOCATION;

CREATE UNPACED DOMAIN archive
  PLACEMENT SUGGEST SEPARATION;

Change the default for the session’s active domain with the nameless domain alteration:

ALTER DOMAIN SET PLACEMENT PREFER COLOCATION;

A newly effective REQUIRE COLOCATION default can relocate runtime nodes during graph activation. Soft policies influence only future placement decisions and do not migrate existing assignments. See Placement Policies for named overlays, path-gating, rank resolution, and enforcement.

Start And Stop

Domain lifecycle commands apply to the active domain:

  • START;
  • START AT NOW [TIME RATE <float>];
  • START AT <rfc3339_timestamp> [TIME RATE <float>];
  • STOP;

Important runtime consequences:

  • START; resumes from persisted domain-owned runtime state when a source supports it
  • START AT NOW reinitializes paced time and domain-owned source offsets from current wall clock
  • STOP preserves persisted runtime state
  • START clears materialized relay state for the active domain before new execution proceeds

Successful lifecycle commands are completion boundaries. START returns after every current live node has prepared the same revision and every configured listener and assigned source has completed startup. STOP returns after intake and runtime work have stopped remotely, including when it is the last running domain. A dependent connection, observation, or publish can follow immediately through any live node.

The lifecycle state, active paced-clock anchor, and one clock authority are replicated. The authority identifies a concrete incarnation of a named cluster node and carries a revision that advances whenever ownership changes or is revoked. After leader failover, reconciliation uses that committed state; a completed transactional START therefore remains effective without re-executing its commit step, and leadership transfer alone does not establish a new anchor or authority.

Every live node installs that committed mapping before it builds executable work for the domain. The installed capability is bound to the domain name and START generation. A joining node uses the existing logical origin, physical UTC anchor, and rate; it does not establish a new anchor from its join time. An unpaced domain receives actual UTC through the same domain-bound capability. A missing domain, stopped clock, uninstalled paced mapping, or task bound to an earlier generation is a lifecycle error and never selects wall time as a paced fallback. Reads on one node do not move backward within a generation.

Nervix assumes every cluster host maintains UTC closely enough for the deployment’s event-time contract. Operators must run and monitor a clock-synchronization service on every node. The committed mapping prevents authority changes and joins from inventing a new logical origin, and each node clamps its own reads so they do not move backward within a generation. It does not make simultaneous reads on different hosts identical or establish a distributed total order. Host UTC offset affects a newly established START AT NOW, unpaced observations, and each node’s projection of a paced mapping; TIME RATE multiplies that projection error. SKEW is event-admission tolerance around logical tick centers, not an allowance for unsynchronized hosts.

The authority begins producing once its own node installs the mapping and fence of the consensus revision that assigns it. Each live peer receives progress once it has installed that fence. A node installs a reassigned fence ahead of its graph installation, so neither the authority nor a peer waits for its own or another node’s graph installation or activation. Join, restart, owner loss, and membership change can select a new authority without changing the clock mapping. When the authority’s node becomes unavailable, the consensus leader commits a replacement and retries a proposal its new leadership could not yet accept; if that node also led consensus, the remaining voters elect a leader first. A progress report is accepted only when its START generation, authority revision, node incarnation, and authenticated peer match the committed authority. Duplicate, delayed, reordered, or superseded progress is ignored and cannot create a domain. STOP revokes the authority in the same replicated lifecycle transition; a later START commits a new generation and mapping. Automatic ALTER quiescing keeps the authority and clock running.

Progress crosses the interconnection as a typed HTTP/2 management request in the reserved progress subquota. For each domain and each remote node that installed its clock, the authority retains one replaceable pending report and permits one delivery attempt at a time. A newer frontier replaces an older pending frontier while connection capacity or a response is delayed. The interconnection bounds all in-flight progress requests independently of the stream and memory reservations for health, cancellation, admission, and terminal outcomes. A joining or reconnected node receives the newest retained frontier once it advertises that it installed the required domain clocks.

Each progress attempt has a two-second physical deadline. A failed attempt retries the newest frontier after a 200-millisecond physical backoff, and authority shutdown cancels both the request and that backoff. The response only confirms that the authenticated receiver evaluated the report; the committed generation and authority fence decide whether it is current. Progress never replaces the committed mapping or moves a node’s logical clock. Its wire value contains typed logical and UTC timestamps and never contains a process-local monotonic instant.

An explicit START AT timestamp must fit exactly in signed Unix nanoseconds. The inclusive range is 1677-09-21T00:12:43.145224192Z through 2262-04-11T23:47:16.854775807Z; valid RFC 3339 values immediately outside those endpoints are rejected by the command. Nervix converts accepted text to a timestamp at the language boundary and carries that timestamp through persistence and runtime state without reparsing it. An offset is converted to UTC, fractional digits past the ninth are truncated, and a leap second, which RFC 3339 writes as :60, reads as the instant one second past the second before it, because Unix nanoseconds have no leap seconds.

TIME RATE accepts every positive finite f64, including scientific notation such as 5e-324 and 1.7976931348623157e308. Zero, negative values, infinities, and NaN are rejected. The committed start mapping remains the sole clock anchor for that run. Tick delivery records progress but never re-anchors logical time, so a delayed tick cannot move time backwards.

Clock projection rounds fractional logical nanoseconds down. Converting a logical target to a physical wait rounds fractional physical nanoseconds up, which prevents a deadline from firing early. Timestamp, duration, boundary, and tick-id overflow are errors. If tick production wakes after several periods have passed, it emits one tick at the latest due logical boundary and next targets the first future boundary.

Domain cadence and data-lifecycle policy create logical deadlines from a bound domain clock. Connection, retry, cancellation, drain, and other operational policy create physical deadlines on the process monotonic clock. The two deadline kinds cannot be interchanged. A logical wait revalidates its domain and generation after every wake and returns both the logical instant that became due and a fresh execution-time snapshot. Cancellation is a separate typed outcome.

HTTP EVERY polling and generator EACH scheduling have an immediate first occurrence; Prometheus EVERY polling first becomes due after one interval. Later occurrences remain anchored to that initial logical schedule. If work takes long enough to miss several occurrences, Nervix runs once for the newest due instant and advances directly to the first future boundary. It never bursts through the missed intervals. Prometheus sends the due instant as its query evaluation time, then takes a fresh domain execution snapshot when the response is decoded and evaluated. Skipped polls and generators during quiescing do not stop or re-anchor the cadence.

COLLECT FOR, FLUSH EACH, and the Iceberg COMMIT EACH are domain-logical durations, in emitters exactly as in every other flush-based node. A paced domain’s TIME RATE therefore accelerates or slows them, and a stopped clock cannot silently turn any of them into a wall-clock wait. Each source relay and concrete branch owns its collection deadline; each concrete output branch owns its route deadline. A deadline starts only when data enters an empty buffer and is cleared when that buffer is released by time, size, force flush, shutdown, or error handling. For COMMIT EACH that buffer is the Iceberg emitter’s staged files: its deadline starts when a flush stages rows while none are staged, not at the domain time those rows’ batch was accepted at, and a retry or drain commits them without reading the clock.

FLUSH IMMEDIATE instead uses a physical 100 µs minimum on the process monotonic clock, regardless of domain rate. The ingestor source-group bound of 1,024 messages and 5 ms idle close are also physical and run before route-local flushing. Connector retry and backoff timers remain separate physical controls; none of these operational waits is projected through the domain clock.

An emitter’s publish cadence and its publish attempts are separate state. Retry backoff, the acknowledgement keepalive that holds an upstream MODE ACK source open while a sink is unavailable, the sink acknowledgement timeout, the stop and drain deadlines, and an HTTP Retry-After a server returns are all physical: a paced domain never shortens or lengthens the real wait an external system asked for. A failed attempt keeps the emitter’s pending batches, their acknowledgements, and its unchanged cadence deadline; while a retry is scheduled the emitter waits for the retry instead of the cadence, and a forced flush or drain still releases the buffer. The maximum batch size releases a buffer that has reached it as soon as no retry is pending.

Following A Domain Clock

A client session can follow the active domain’s clock instead of inferring it from the domain list:

ATTACH DOMAIN CLOCK;
DETACH DOMAIN CLOCK;

The attach reply, and a frame after later changes of the installed clock, carry the START generation and the clock as the serving node has it installed: stopped, uninstalled, unpaced, or paced with its PERIOD, SKEW, logical origin, UTC anchor, and TIME RATE. Changes made before a frame is sent arrive together as the newest clock. A paced clock is the committed mapping this chapter describes, so a client projects the domain’s logical time for its own UTC observation with the same arithmetic every node uses, including rounding down, and reconstructs the admission window from the origin, PERIOD, and SKEW. A driver of a paced simulation can therefore choose TIMESTAMP AT values the ingestor admits, such as the newest reached center, and compute how long to wait until a logical instant; the Paced Simulation Drivers are two runnable such drivers. The projection uses the client host’s UTC, so the synchronization requirement above extends to such a client: its offset from the cluster’s hosts, multiplied by TIME RATE, shifts the logical instants and admission centers it projects, and the offset itself moves the end of every physical wait it computes. The session also receives each newest accepted tick as a replaceable frame with its id, logical boundary, authority UTC observation, and serving node’s logical reading. A client can pace from those accepted boundaries even if its own UTC is offset; the serving node’s reading gives it a cluster-side anchor.

Both statements are session-local and are refused while a transaction is open, and a session follows each domain clock at most once. See Domain Clock Attachment for the delivery contract and Rust Client Library for the helper that performs this arithmetic, which the shared C binding exposes to its hosts through nx_session_domain_clock.

Execution-Time Snapshots

Each accepted unit of domain work reads its domain clock once and uses that execution-time snapshot for every expression in the unit. This applies to ingestion, processor input and output programs, branching, deduplication, ordering, correlation and window expressions, inferencer mappings, emitter filters, construction, VALUES and SQS FIFO-group expressions, and subscription filters. Buffered emitter batches and retries retain the snapshot assigned when the batch was accepted. Processor flushes, scheduled callbacks, and other later executions receive a new snapshot when that work begins. A message released from REQUIRED WAIT also begins again with a fresh snapshot.

A message error records the snapshot of the operation that failed, and its error-route SET program uses that same instant. Generator output and WASM rows without a source token receive that instant as both ingestion watermarks. WASM rows with a source token retain the source metadata. Guest initialization, batch processing, timeouts, flushing, and state lifecycle calls each receive the snapshot of their owning execution. Roto and WASM hosts have no context-free time entry point: their now() or domain-time import returns that supplied snapshot and never reads UTC on its own.

Generated timestamps at external boundaries follow their declared clock class. An omitted Sentry event timestamp uses the emitter batch’s domain execution time, while an explicit timestamp is preserved. OTEL VALUES use domain execution time, while observed_time_unix_nano records actual UTC at export. HTTP-date Retry-After values are interpreted against actual UTC and converted to a physical monotonic wait.

Automatic Model-Alteration Quiescing

Nervix derives a quiesce level from the complete validated model diff. ALTER SCHEMA, ALTER WIRE ... SCHEMA, relay schema changes, and relay branching changes use an internal paused lifecycle state when their domain is running. Dynamic changes such as relay capacity do not pause the domain. There is no NSPL PAUSE or RESUME statement.

Before changing the live graph, the leader validates the complete candidate graph without writing it while holding the domain’s exclusive ALTER lock. For an entity-pause alteration, Nervix gates the affected relays or stops the affected ingestor instances on every live node, then waits only for their rings and target-node work to drain. Unrelated graph paths continue to run. For a domain-pause alteration, Nervix instead stops domain ingestion and generators on every node, keeps the processing graph and domain clock alive, force-flushes ingestor route, processor, and emitter output, and waits for ingestors, generators, ACK roots, and emitter buffers to drain. Both waits are condition-based and bounded to 60 seconds by default.

After a successful drain, Nervix atomically installs the model batch, replaces the schedule while ingestion is still withheld, and resumes the domain on the new graph. A timeout or cutover failure leaves the mutation unapplied, restores the old graph, and automatically resumes it with a clear outstanding-work error. An ALTER on a stopped domain only validates and persists the new schedule. Pure CREATE and DROP batches keep the immediate schedule-rebuild behavior. An all-no-op batch writes and publishes nothing and never pauses.

For an HTTP emitter, this means a pending request must finish at the origin selected when it was admitted before a new method, path, body mode, or client takes effect. A failed drain keeps that emitter definition active. An operator facing a persistently unavailable origin may restore it, or STOP, change the configuration while stopped, and START. Stopping leaves no prepared HTTP request or retry state to carry over; an acknowledged source may redeliver unresolved attached work, including work the destination applied before its successful response was lost.

Pause is not a restart: domain clock state, start version, broker offsets, branch identity, and eligible handoff residue are preserved across the quiesce cycle.

Ingestion Timestamps

RESTORE ... RESUME retains the archived start generation, latest start point and paced clock mapping after installing all compatible runtime state. That mapping projects the downtime, so TIMESTAMP AT admission uses the restored clock’s reached windows. Unpaced domains resume without a mapping. A default restore stays stopped. A later normal START advances the generation and clears materialized relay rows while preserving compatible WASM state, source offsets and branch lifecycle. To start from the archived frontier with a new mapping, use a default restore and explicit START AT '<frontier>' TIME RATE <rate>. DESCRIBE DOMAIN exposes the generation, start point and mapping; LIST DOMAINS exposes pace, status and generation. See Backup And Restore.

Every ingested record receives internal ingestion metadata, including mandatory low and high watermarks with nanosecond precision.

Timestamp sources:

  • TIMESTAMP NOW selects the installed domain’s logical time when the ingest group is delivered. Buffered intake released after automatic quiescing receives that delivery time. In an unpaced domain, the same clock capability supplies actual UTC. In a paced domain that delivery time is tested against the admission window like any event time: it lies less than one PERIOD after the newest reached tick center, so a SKEW smaller than PERIOD rejects the records delivered between two windows, and a SKEW of at least PERIOD admits every delivery.
  • TIMESTAMP AT <field> preserves the decoded DATETIME field as the event time. Connector timestamps selected when this clause is omitted in an unpaced domain, and broker metadata, retain their external values. None of these values is multiplied by TIME RATE.

In paced domains, ingestors must declare a timestamp source explicitly. In unpaced domains, timestamp metadata is still recorded, but it is not used to gate admission.

Both ingestion watermarks initially equal the selected event time and travel with the record through relays and across nodes. Timestamp selection and admission share one delivery snapshot. An unknown, stopped, or uninstalled domain clock produces a lifecycle error; a paused domain withholds delivery until its ingestion path resumes.

Window processors also use this metadata. Duration windows evaluate input event time from the record low watermark. Emitted aggregate records receive a low watermark equal to the minimum input low watermark in the emitted window and a high watermark equal to the current domain time at emission.

Schemas And Codecs

Nervix separates internal runtime schema from wire schema.

Internal Schemas

An internal schema describes the typed runtime record:

CREATE IF NOT EXISTS SCHEMA notification (
  user_id U32,
  created_at DATETIME,
  payload STRING OPTIONAL,
  cpu_last_64 ARRAY<F32, 64>,
  image ARRAY<F32, 3, 224, 224>,
  detections VEC<ARRAY<F32, 6>> OPTIONAL
);

Schemas must declare at least one field.

A schema may name a field with any word, keywords included. An expression writes a field named like a reserved word between backticks where it stands bare, and as it is after a scope such as input..

These types are the values Nervix stores in runtime records and uses for subscription matching and processor logic. BYTES may be a deduplication or reordering key, ordered lexicographically by octet, but branch key schemas cannot contain BYTES, including nested ARRAY or VEC elements.

BYTES holds arbitrary octets in an Arrow Binary column. It is distinct from STRING. Use bytes_from_utf8 and bytes_to_utf8 for explicit text conversion, or hex_decode and base64_decode for encoded text. A CAST never converts between BYTES and text. Lookup schemas, materialized-state dependencies, generator source relays, and window aggregate arguments cannot contain BYTES; their declarations fail validation. LOOKUP_HASH_MAP key expressions also cannot produce BYTES.

ARRAY<T, D1, ..., Dn> is a fixed rectangular array. Each dimension maps to one nested Arrow FixedSizeList level, so ARRAY<F32, 2, 3> maps to FixedSizeList<FixedSizeList<Float32, 3>, 2> and remains a 2-by-3 value. VEC<T> is a variable-length sequence and maps to Arrow List<T>. The element type is recursive, so fixed and variable axes can be mixed, for example VEC<ARRAY<F32, 6>> and ARRAY<VEC<STRING>, 4>.

ARRAY and VEC are distinct and are never implicitly converted. Every fixed axis must have a positive length no greater than 2147483647, the largest length an Arrow FixedSizeList carries, fixed arrays must contain exactly that many elements at runtime, and dense multidimensional values must retain their nested shape. JSON and CBOR represent both with nested JSON-style arrays. AVRO uses nested array schemas with item types inferred recursively from the internal schema.

Append OPTIONAL to either an internal schema field or a wire schema field when the value may be absent. Optional fields are omitted from runtime records and emitted JSON payloads when no value is present.

Wire Schemas

Wire schemas describe the serialized format on the transport side.

Declared wire schemas are either STRICT or LOOSE. Strict wire schemas reject payload fields that are not declared by the wire schema. Loose wire schemas accept extra payload fields and drop them before decoding into the internal schema.

JSON wire schema:

CREATE IF NOT EXISTS WIRE JSON SCHEMA notification_wire MODE STRICT (
  user_id integer,
  created_at string,
  payload string OPTIONAL
);

CBOR wire schema:

CREATE IF NOT EXISTS WIRE CBOR SCHEMA notification_wire MODE LOOSE (
  user_id integer,
  created_at string,
  payload string OPTIONAL
);

AVRO wire schema:

CREATE IF NOT EXISTS WIRE AVRO SCHEMA notification_wire MODE STRICT (
  user_id LONG,
  created_at STRING,
  payload STRING OPTIONAL
);

JSON, CBOR, and AVRO wire schemas must declare at least one field. JSON and CBOR also accept exact numeric and datetime wire types (U8 through I64, F32, F64, and DATETIME) alongside the generic JSON types. The exact variants retain their type in the canonical definition and in completion suggestions. A codec binds an exact wire type only to the internal type of the same name: a U8 wire field to a U8 field, and a DATETIME wire field, whose value is RFC 3339 text, to a DATETIME field without an ENCODE ... AS RFC3339 rule.

The web console’s Create menu provides structured editors for internal schemas and each of these three declared wire formats. Internal collection controls build nested ARRAY and VEC types from an exact scalar type. Field order, OPTIONAL, SENSITIVE, wire mode, and exact format are preserved in the completed Model and its canonical command. See Web Console.

SYSLOG is different: it is a predefined singleton wire schema whose shape comes from the syslog protocol. It has no user-defined name, CREATE WIRE declaration, mode, field list, ALTER, or DROP lifecycle. A codec references it directly:

CREATE CODEC syslog_codec FROM SYSLOG TO SCHEMA syslog_event;

Its fixed field contract is documented in Syslog.

Altering Schemas

Internal and declared wire schemas can be changed without dropping their whole dependent graph first. Operations in one statement run from left to right, and each operation sees the result of the previous one.

Internal schema operations are:

  • ADD FIELD <field> <type> [OPTIONAL] [SENSITIVE]
  • DROP FIELD <field>
  • RENAME FIELD <field> TO <field>
  • ALTER FIELD <field> SET TYPE <type>
  • ALTER FIELD <field> SET OPTIONAL and ALTER FIELD <field> DROP OPTIONAL
  • ALTER FIELD <field> SET SENSITIVE and ALTER FIELD <field> DROP SENSITIVE

For example:

ALTER SCHEMA notification
  ADD FIELD note STRING OPTIONAL,
  RENAME FIELD created_at TO received_at,
  ALTER FIELD payload SET SENSITIVE;

DROP SENSITIVE is the explicit way to downgrade a field. Nervix still rebuilds and validates the whole candidate graph, including every downstream leakage rule. A schema must retain at least one field, added and renamed names must be unique, and the target of every drop, rename, or field alter must exist.

Wire schema operations are:

  • ADD FIELD <field> <wire_type> [OPTIONAL]
  • DROP FIELD <field>
  • RENAME FIELD <field> TO <field>
  • ALTER FIELD <field> SET TYPE <wire_type>
  • ALTER FIELD <field> SET OPTIONAL and ALTER FIELD <field> DROP OPTIONAL

JSON, CBOR, and AVRO wire schemas are separate entity kinds. They may use the same name without colliding, and the exact format is required for every ALTER:

ALTER WIRE JSON SCHEMA notification_wire
  ADD FIELD note string OPTIONAL;

Mode changes use that same exact entity kind:

ALTER WIRE JSON SCHEMA notification_wire MODE LOOSE;

Use one explicit transaction when a type or shape change requires coordinated updates. Model mutations for one domain—CREATE, schema ALTER, relay ALTER, and DROP—are validated against one candidate graph and committed atomically. The following replacement changes the wire and internal types together and recreates their codec without exposing an intermediate invalid graph:

BEGIN;
ALTER WIRE JSON SCHEMA notification_wire
  ALTER FIELD user_id SET TYPE number;
ALTER SCHEMA notification
  ALTER FIELD user_id SET TYPE F64;
DROP CODEC notification_codec;
CREATE CODEC notification_codec
  FROM WIRE JSON SCHEMA notification_wire
  TO SCHEMA notification;
COMMIT;

If any operation or dependent model fails validation, none of the mutations are persisted. SHOW CREATE SCHEMA and exact wire forms such as SHOW CREATE WIRE JSON SCHEMA notification_wire render the resulting canonical definitions. Dropping a wire schema is exact-format too, for example DROP WIRE JSON SCHEMA notification_wire.

On a running domain, a schema ALTER is applied through an automatic quiesce cycle: Nervix validates first, stops new ingestion and generators, force-flushes buffered output, drains in-flight work, installs the new graph, and resumes. This internal state is not a user-facing lifecycle command. A drain timeout rejects the batch and resumes the old graph. On a stopped domain, Nervix validates and persists the change without a quiesce cycle.

Runtime state whose record layout derives from an altered schema is recreated. Independent stateful nodes retain their state, and so does state that depends on no schema at all: domain-owned Kafka offsets and the counters behind a node’s metrics survive a schema change of their own node. Persisted schema-bound state is keyed by the fingerprint of the schemas it was written under, so a stale layout is never restored as the new type. Relay subscriptions closed by the rebuild report that they must be recreated against the current schema.

Codecs

A codec maps one transport payload format to one internal schema.

CREATE IF NOT EXISTS CODEC notification_codec
  FROM WIRE JSON SCHEMA notification_wire
  TO SCHEMA notification;

Schemaful codecs are type-strict. A JSON string wire field does not implicitly decode into an internal DATETIME field. Declare the wire conversion explicitly:

CREATE IF NOT EXISTS CODEC notification_codec
  FROM WIRE JSON SCHEMA notification_wire
  TO SCHEMA notification
  ENCODE created_at AS RFC3339;

created_at is the internal schema field name. The matching wire field must be a string, and the internal field must be DATETIME.

For an internal BYTES field, declare BYTES in the JSON or CBOR wire schema. Its wire value is a canonical RFC 4648 standard base64 string with padding; malformed or unpadded text is a decode error. AVRO BYTES fields use Avro’s native byte sequence. A wire STRING field cannot bind an internal BYTES field. Nested ARRAY and VEC byte elements use the same representation.

Live WIRE JSON ingestion copies each connector-owned payload into mutable scratch retained by the open ingest group, then parses it with simd-json using parser buffers retained beside that scratch. The decoder walks simd-json’s borrowed values directly into the batch’s typed Arrow builders. It resolves schema field hashes and Arrow data types when the codec and batch are built, parses each datetime once, and streams base64 output into the binary builder. It does not construct a serde JSON tree or a row map. Strict and loose field behavior, optional nulls, exact integer ranges, nested sequence shapes, invalid UTF-8 and malformed escape rejection remain the public wire-schema contract.

On emission, a compiled WIRE JSON codec writes each object in schema field order directly from its typed Arrow columns. Field names are escaped when the codec is compiled. A runtime-selected SIMD classifier marks quotes, backslashes and control bytes in each string column once per batch; clean UTF-8 strings are copied without an escape pass, while marked strings use JSON escaping. Optional null fields are omitted, required null fields fail encoding, datetimes use their declared wire rule, and bytes use padded base64. Nested arrays retain their declared shape. The encoder writes into the caller’s output buffer, including a bounded buffer when MAX SIZE applies.

JAQ-native codecs parse a transport payload in a jaq-supported format and run explicitly directed JAQ transformations. An ingestion transformation turns every value the payload holds into zero or more JSON objects, and each object is decoded into the internal schema as one message (Unfolding Payloads):

CREATE IF NOT EXISTS CODEC notification_cbor
  FROM CBOR
  TO SCHEMA notification
  WITH JAQ TRANSFORMATIONS ON INGESTION '.';

CREATE IF NOT EXISTS CODEC notification_xml
  FROM XML
  TO SCHEMA notification
  WITH JAQ TRANSFORMATIONS
    ON INGESTION '{user_id: (.c[] | select(.t == "user_id").c[0] | tonumber)}';

Protobuf codecs compile .proto files from an uploaded resource, decode or encode the selected message with prost-reflect, and use JAQ to translate between the protobuf JSON view and the internal schema:

CREATE IF NOT EXISTS CODEC notification_proto
  FROM PROTOBUF
  USING RESOURCE proto_bundle VERSION 1
  CONFIG {'file' = 'notification.proto', 'include' = '.'}
  MESSAGE 'nervix.test.Notification'
  TO SCHEMA notification
  WITH JAQ TRANSFORMATIONS ON INGESTION '{user_id: .user_id, payload: .payload}';

The resource contains the .proto files. USING RESOURCE requires VERSION <n> or VERSION LATEST, and the codec compiles its descriptors from the one version it stores. CONFIG declares compile parameters; file/files select source files and include/includes select import roots, all relative to the resource root. If no file is listed, all .proto files in the resource are compiled.

A protobuf codec that a batching emitter encodes through names the message a batch is published as, after MESSAGE:

CREATE CODEC notification_proto
  FROM PROTOBUF
  USING RESOURCE proto_bundle VERSION 1
  CONFIG {'file' = 'notification.proto', 'include' = '.'}
  MESSAGE 'nervix.test.Notification'
  BATCH MESSAGE 'nervix.test.NotificationBatch'
  TO SCHEMA notification
  WITH JAQ TRANSFORMATIONS ON EMITTING '{user_id: .user_id, action: .action}';

BATCH MESSAGE names a message in the same compiled descriptors, and the domain build fails when it does not exist. Without an ON EMITTING BATCH transformation it must declare exactly one field, repeated <MESSAGE>, which holds the members. With one, it may have any shape the transformation’s output is a valid instance of.

Current schemaful codec wire formats are:

  • JSON, with an explicit JSON wire schema
  • CBOR, with an explicit CBOR wire schema
  • AVRO, with an explicit AVRO wire schema

Current predefined fixed-contract wire format:

  • SYSLOG, a singleton wire schema referenced directly by a codec

Current JAQ-native codec formats are:

  • JSON
  • YAML
  • TOML
  • XML
  • CBOR

Current protobuf codec format:

  • PROTOBUF, with resource-backed .proto files, inline compile config, and message name

Value Fidelity

A schemaful codec decodes every row it encodes back to the same values: every integer width to its extremes, booleans, any Unicode text, bytes, datetimes to the nanosecond, nested ARRAY and VEC values, and nulls of optional fields. The formats themselves set the bounds of that promise:

  • JSON has no number for a NaN or an infinity. WIRE JSON writes a non-finite float as null, which reads back as a null of an optional field and refuses a required field or a list element. WIRE CBOR writes the float itself but reads every value through the same JSON model, so it reads a non-finite float as a null.
  • A JSON or CBOR number read into an F32 field becomes the nearest F32, and the shortest decimal a writer prints for an F32 reads back as that same F32, also where the number lies exactly halfway between two of them once read as a 64-bit float. A number beyond the type’s range rounds to an infinity. Every finite float the codec writes reads back to its exact bits.
  • WIRE AVRO keeps every float bit pattern. It writes an unsigned element of an ARRAY or VEC as an Avro long, so an element above 9223372036854775807 fails to encode.
  • A datetime travels as RFC 3339 text in UTC; text written with another offset reads as the same instant.
  • A WIRE AVRO payload is one raw Avro datum, without an object container or a schema registry header, and a WIRE CBOR payload is one CBOR data item.

The JAQ-native and protobuf codecs are transformations rather than encodings of a row: a program decides the shape in each direction. A number a program passes on unchanged keeps its value: a 64-bit integer exactly, and a finite float to its bits through JSON, YAML, TOML, CBOR and the double and float fields of a protobuf message, because every decimal is read as the float nearest to it. The formats set two bounds of their own:

  • TOML holds signed 64-bit integers. A larger unsigned value is written as it stands and refused when it is read.
  • A protobuf message does not carry a field that holds its default value, so its JSON view omits such a field: a zero of either sign, false, empty text or an empty list. An ingestion program that needs one restores it, as (.level // 0) does; a negative zero comes back positive.

SYSLOG writes its timestamp to the microsecond in UTC; see Syslog.

JAQ Transformations

JAQ-backed codecs must declare a JAQ transform. The concise JAQ Reference links the full upstream manual for the exact jaq-core release Nervix embeds and summarizes the Nervix-specific boundary:

CREATE IF NOT EXISTS CODEC notification_codec
  FROM JSON
  TO SCHEMA notification
  WITH JAQ TRANSFORMATIONS
    ON INGESTION '.payload'
    ON EMITTING '{payload: .}';

Semantics:

  • no-wire codecs must use FROM JSON|YAML|TOML|XML|CBOR ... WITH JAQ ...
  • protobuf codecs must use FROM PROTOBUF USING RESOURCE ... VERSION <n> | LATEST CONFIG {...} MESSAGE ... [BATCH MESSAGE ...] WITH JAQ ...
  • codecs using declared wire schemas must use FROM WIRE JSON|CBOR|AVRO SCHEMA ... and do not carry JAQ transforms
  • codecs using the predefined SYSLOG wire schema must use FROM SYSLOG TO SCHEMA ... and do not carry JAQ transformations or field encoding rules
  • WITH JAQ TRANSFORMATIONS requires ON INGESTION, ON EMITTING, or both in that order, and ON EMITTING may be followed by ON EMITTING BATCH (Batch Transformations)
  • ON INGESTION runs on every value the parsed native or protobuf payload holds and may yield zero or more JSON objects, each of which becomes one message compatible with the internal schema (Unfolding Payloads)
  • ON EMITTING runs after the runtime record has been converted into JSON and must yield exactly one native-format or protobuf-message value

JAQ-backed encode/decode runs on the node’s extension workers, so an expensive or non-terminating transform never stalls an async ingestor or emitter task and never holds the workers that relay bodies are encoded and decoded on. A node whose extension workers cannot take a payload now refuses it without judging it: an ingested payload fails its dispatch rather than its decode, and an emitter keeps the rows and retries them. A payload no sender can present again is not refused when its source can wait: a payload an ingestor’s quiesce buffer retained waits in the buffer, and a paced poll, or a payload a source that survives a held loop handed over without an acknowledgement, waits until the extension workers take it (When The Extension Workers Are Full). JAQ JSON output intentionally preserves object member declaration order. The serde JSON preserve_order feature exists for that public JAQ behavior; schemaful WIRE JSON ingestion does not depend on serde JSON object storage.

Batch Transformations

A JAQ-backed codec — JAQ-native or protobuf — may declare a third transformation for emitters that declare a batching clause:

CREATE IF NOT EXISTS CODEC notification_envelope
  FROM JSON
  TO SCHEMA notification
  WITH JAQ TRANSFORMATIONS
    ON EMITTING '{id: .user_id, action: .action}'
    ON EMITTING BATCH '{schema_version: 2, count: length, records: .}';

ON EMITTING BATCH is written only after ON EMITTING, because its input is built from that transformation’s outputs: one array holding the member values of a batch, in publication order. It must yield exactly one value, valid in the codec’s format; for a protobuf codec, a valid instance of the declared BATCH MESSAGE. The program compiles with the codec, so a program that cannot compile fails the domain build. A batching Sentry emitter requires its codec to declare this transformation. ON EMITTING keeps its meaning exactly: it runs per record and yields one value per record.

The transformation replaces the format’s batch container, so an array mapping, an envelope and a larger value are all ordinary uses:

ON EMITTING BATCH '.'
ON EMITTING BATCH 'map({id: .user_id})'
ON EMITTING BATCH '{count: length, records: .}'

Its output is written by the codec’s format writer and measured against the emitter’s MAX SIZE like any container. Whatever it reports as a count, Nervix still counts, acknowledges and limits the source records that went in. It sees only the member values, never the source row, materialized state or headers, so it cannot reach a sensitive value that construction did not already leak.

Batch Containers

An emitter that declares a batching clause publishes several records as one payload. Each record contributes one member value, exactly the value it would have been published as alone: the wire object for WIRE JSON, WIRE CBOR and WIRE AVRO codecs, the ON EMITTING output for JAQ-native and protobuf codecs, and the complete RFC 5424 message for SYSLOG. Without an ON EMITTING BATCH transformation the members are written in the format’s own container, by the same writer, with the same whitespace and number formatting, that writes one record:

FormatContainerThree members
WIRE JSONArray[{"seq":1},{"seq":2},{"seq":3}]
JAQ-native JSONArray[{"seq": 1}, {"seq": 2}, {"seq": 3}]
WIRE CBOR, JAQ-native CBORDefinite-length arrayMajor type 4 with the member count, then the members
YAMLOne document holding a sequence[{seq: 1}, {seq: 2}, {seq: 3}]
WIRE AVROOne datum of type array whose items are the codec’s record schemaOne block: the member count, the members, then the terminating zero
TOMLOne document with a single key, batch[[batch]] sections, one per member
XMLOne root element, batch, whose children are the members<batch><event seq="1"/><event seq="2"/><event seq="3"/></batch>
PROTOBUFOne instance of the codec’s BATCH MESSAGEThe members in its single repeated <MESSAGE> field
SYSLOGOne RFC 5424 messageThe members’ common header, the first member’s timestamp, and a MSG that is the JSON array of the members’ own messages

TOML and XML have no top-level sequence, so their containers use the single key and the single root element; an XML member must therefore be an element. A SYSLOG frame can say its header once only for members that share it, so records whose PRI, HOSTNAME, APP-NAME, PROCID, MSGID or STRUCTURED-DATA differ are published in separate frames, while each member keeps its own timestamp inside the array:

<134>1 2026-09-24T10:15:00Z app-01 checkout - - - ["<134>1 2026-09-24T10:15:00Z app-01 checkout - - - order accepted","<134>1 2026-09-24T10:15:01Z app-01 checkout - - - order shipped"]

A consumer reads a container back with the same codec: a JAQ-native codec whose ON INGESTION unfolds the array, such as '.[]', yields the members as separate messages within the unfolding limit, and a protobuf codec whose MESSAGE is the batch message can unfold its repeated field, such as '.events[]'.

Unfolding Payloads

A JAQ-backed codec decodes a payload as a stream. The payload is parsed into the values its format holds, ON INGESTION runs on each value in payload order, and every object the program yields becomes one message, in the order the program yields it:

FormatValues in one payload
JSONWhitespace-separated JSON values: one document, newline-delimited JSON, or concatenated JSON
CBORConsecutive CBOR data items
YAMLThe documents of a YAML stream
XMLThe root element; a declaration, document type, comment, or processing instruction outside it is not a value
TOMLExactly one document
PROTOBUFExactly one message of the declared type

An empty JSON, CBOR, YAML, or XML payload holds no values and decodes into no messages. jq stream semantics apply unchanged: .[] unfolds an array into one message per element, a comma yields several messages, and select or empty yields no message for the values it rejects.

CREATE IF NOT EXISTS CODEC order_lines_codec
  FROM JSON
  TO SCHEMA order_line
  WITH JAQ TRANSFORMATIONS ON INGESTION '.lines[] | select(.quantity > 0)';

Every output must be a JSON object that fits the internal schema: surplus keys are dropped, an absent or null value is accepted only for an OPTIONAL field, and every value must match its field’s type exactly. Messages carry no position of their own; a program that needs one computes it, for example with range(length) as $i | .[$i] + {index: $i}.

A payload is decoded as a whole or rejected as a whole. A malformed value, a program evaluation error, an output that is not an object or does not fit the schema, or more than 65,536 messages rejects the entire payload, including the messages produced before the failure. The rejection is a decode failure of the payload, not a route message error: it is reported as a runtime error event and logged, MODE NO ACK continues with the next payload, and acknowledged modes retry or redeliver it as they do any payload that fails to decode. The diagnostic names the codec, the cause, and the zero-based position of the input value, plus the output’s position when an output is at fault. It never quotes payload values, so a mismatched field is described by its JSON kind and a program evaluation error omits the evaluator’s message, which is logged at trace level instead.

The messages of one payload join one source ingest group together, so they share its domain execution snapshot, and every one of them sees the payload’s source metadata and headers. Each message is filtered, constructed, branched, routed, and error-handled on its own, and ingestor metrics count messages. Source acknowledgement stays per payload: a payload is acknowledged once every message it unfolded into has been acknowledged, a negative acknowledgement of any of them negatively acknowledges the whole payload so that its already acknowledged messages are delivered again with it, and a payload that unfolds into no messages is acknowledged as soon as it decodes. ACK PARALLEL MAX <n> admits n payloads however many messages each unfolds into.

Why The Split Matters

The schema split lets Nervix:

  • keep runtime typing independent from transport shape
  • support multiple wire formats
  • normalize awkward inbound JSON without changing the internal data model
  • reshape outbound payloads during emission without changing the internal record layout

JAQ Reference

Nervix embeds jaq-core 3.1.1. For the complete language and standard-library catalogue, read the human-readable upstream JAQ manual. The exact embedded version corresponds to the JAQ v3.1.1 release. This page only summarizes the Nervix boundary.

Nervix Contract

  • An ingestion transform runs once for every value a parsed native-format or protobuf payload holds, and receives that value as ..
  • Every object an ingestion transform yields becomes one message compatible with the internal schema. It may yield zero, one, or many objects, and one payload unfolds into at most 65,536 messages.
  • An emitting transform receives one record as . and must yield exactly one value accepted by the selected native format or protobuf message.
  • Nervix compiles filters with JAQ’s core, standard-library, JSON, and format functions. Standalone JAQ command-line options and file loading are not part of the codec contract.

See JAQ transformations for directionality, supported formats, exact output rules, and NSPL syntax, and Unfolding Payloads for how one payload becomes several messages.

Common Filters

FilterPurpose
.Preserve the current value
.field, .nested.fieldRead object fields
{field: .source}Construct an object
.items | map(.value)Transform every array element
.items[] | select(.enabled)Select matching elements
tonumber, tostringExplicitly convert JAQ values
//Supply an alternative value

Common

Common guides document features that cross several NSPL surfaces and are easiest to use when their pieces are explained together. They complement the entity-focused manual chapters; they do not introduce another model category.

  • Syslog groups the predefined singleton syslog wire schema, codecs that use it, socket clients, ingestor and emitter transport behavior, TLS, framing, failure semantics, and limits.
  • Database Client Connection Pools groups the required POOL SIZE MIN <n> MAX <n> clause shared by Postgres, MySQL, MongoDB, and Redis clients: accepted values, how the bounds relate to connector CONFIG, inspection, and how a bound is changed.

Additional cross-cutting guides will live in this section.

Syslog

Nervix separates the syslog message format from syslog network transport:

  • the predefined singleton SYSLOG wire schema represents the fixed syslog message format
  • a codec that uses that wire schema decodes RFC 3164 and RFC 5424 messages and encodes RFC 5424 messages
  • a TYPE SYSLOG client declares a UDP, TCP, or TLS socket endpoint
  • a SYSLOG LISTENER ingestor receives transport frames and passes each frame to its declared codec
  • a TO SYSLOG emitter sends codec output with the selected syslog transport framing

The pieces are intentionally independent. A KAFKA TOPIC ingestor can decode a syslog payload with a codec that uses the SYSLOG wire schema, and a SYSLOG LISTENER ingestor can pass its frames to a JSON codec. Pairing the format and transport pieces creates a conventional syslog receiver or forwarder. SYSLOG is a reserved NSPL keyword.

Predefined wire schema and codec

Reference the predefined wire schema directly from a codec:

CREATE CODEC syslog_codec
  FROM SYSLOG
  TO SCHEMA syslog_event;

SYSLOG itself is the reference to the singleton wire schema. It has no user-defined name or separate CREATE, SHOW CREATE, ALTER, or DROP lifecycle. A codec using it does not accept WITH JAQ TRANSFORMATIONS or ENCODE <field> AS RFC3339 rules.

Fixed field contract

The target schema may declare any subset of these fields. Every declared field must use the exact name, type, and optionality shown here.

FieldRequired schema shapeDecode sourceRFC 5424 encoding
facilityU8PRI divided by 8Required for encoding; must be at most 23
severityU8PRI modulo 8Required for encoding; must be at most 7
timestampDATETIME OPTIONALRFC 5424 timestamp or RFC 3164 timestampRFC 3339 UTC, or - when null or undeclared
hostnameSTRING OPTIONALHOSTNAMEValue, or - when null or undeclared
app_nameSTRING OPTIONALRFC 5424 APP-NAME or RFC 3164 TAGValue, or - when null or undeclared
proc_idSTRING OPTIONALPROCIDValue, or - when null or undeclared
msg_idSTRING OPTIONALMSGIDValue, or - when null or undeclared
structured_dataSTRING OPTIONALExact RFC 5424 STRUCTURED-DATA textValidated and emitted verbatim, or - when null or undeclared
messageSTRINGMSG, including an empty messageRequired for encoding

A field outside this contract, or a field with different type or optionality, makes CREATE CODEC fail and identifies the offending field and expected shape. Fields may be marked SENSITIVE in the target schema; normal sensitivity propagation and explicit external-leakage rules still apply.

Fields omitted from the schema are parsed and discarded during decoding. During encoding, omitted optional header fields and structured data become the RFC 5424 NILVALUE -.

An example schema that supports both decoding and encoding is:

CREATE SCHEMA syslog_event (
  facility U8,
  severity U8,
  timestamp DATETIME OPTIONAL,
  hostname STRING OPTIONAL,
  app_name STRING OPTIONAL,
  proc_id STRING OPTIONAL,
  msg_id STRING OPTIONAL,
  structured_data STRING OPTIONAL,
  message STRING
);

CREATE CODEC syslog_codec
  FROM SYSLOG
  TO SCHEMA syslog_event;

Decoding

A codec using the SYSLOG wire schema detects RFC 5424 or RFC 3164 for each message. The choice is not configurable.

  • the payload must be non-empty UTF-8 after trailing carriage returns, line feeds, and NUL bytes are removed
  • an absent or unparseable PRI uses priority 13: facility 1 and severity 5
  • an RFC 3164 timestamp uses the receiving node’s current year and is interpreted as UTC
  • RFC 5424 NILVALUE fields decode as typed nulls
  • structured_data retains the exact bracketed and escaped RFC 5424 text; RFC 3164 produces null
  • message may be an empty string

A decode failure skips that frame, records the normal decode diagnostic, and continues with the next frame.

Encoding

Encoding through the SYSLOG wire schema always produces RFC 5424 version 1 without a byte-order mark. A codec is encode-capable only when its target schema declares facility, severity, and message. Graph validation rejects an emitter using such a codec when any field is absent and lists the missing fields.

Timestamps are rendered in UTC. Nanosecond values are truncated to RFC 5424’s maximum six fractional digits, and trailing fractional zeroes are omitted.

Record-specific encoding failures follow the emitter’s ON MESSAGE ERROR policy. These include:

  • a facility above 23 or severity above 7
  • hostname, app_name, proc_id, or msg_id containing characters outside printable US-ASCII or exceeding its RFC 5424 length limit
  • structured_data that is neither - nor a well-formed sequence of structured-data elements

Client

A syslog client describes one direction-dependent socket endpoint:

CREATE CLIENT <name>
  TYPE SYSLOG [MOUNT <resource> VERSION <n>|LATEST]
  CONFIG {
    'protocol' = 'udp'|'tcp'|'tls',
    'addr' = '<host:port>',
    ...
  };

One client may be referenced by both ingestors and emitters. An ingestor binds addr; an emitter connects or sends to it.

An emitter resolves the host in addr through the node’s asynchronous resolver each time it opens a sender. Literal IPv4 and IPv6 addresses are used directly; hosts-file names and DNS answers follow the node’s configured resolver and TTL policy. UDP tries answers in order, binding each socket to the answer’s IP family, and sends to the first address whose setup succeeds. TCP and TLS try the answers in order, giving each an equal share of the time left in one 30-second connection budget. DNS, address attempts and the TLS handshake all spend that same budget. TLS verifies the original host from addr, whichever address accepted the connection. A new sender after a failure resolves again, so an expired answer can be replaced without restarting the node.

Config keyRequiredMeaning
protocolYesudp, tcp, or tls
addrYeshost:port; bind address for ingestion and destination for emission
max_message_sizeNoIngested-message byte cap; defaults to 131072
framingNoTCP emitter framing: octet-counting by default or non-transparent
tls_cert_fileDirectionalTLS ingestor server certificate; optional emitter client certificate
tls_key_fileDirectionalPrivate key paired with tls_cert_file
tls_ca_fileNoIngestor client-certificate CA, or emitter server trust root

TLS file paths use normal client resource mounts. For example:

CREATE CLIENT syslog_tls
  TYPE SYSLOG MOUNT syslog_identity VERSION 1
  CONFIG {
    'protocol' = 'tls',
    'addr' = '0.0.0.0:6514',
    'tls_cert_file' = '{{ syslog_identity }}/server.crt',
    'tls_key_file' = '{{ syslog_identity }}/server.key',
    'tls_ca_file' = '{{ syslog_identity }}/clients-ca.crt'
  };

For a TLS ingestor, tls_cert_file and tls_key_file are both required. Supplying tls_ca_file additionally enables mutual TLS and requires every client to present a certificate that chains to that CA. Without it, the server does not request client certificates.

For a TLS emitter, the certificate and key are optional, but must appear together when used for client authentication. tls_ca_file adds a server trust root; platform roots are used when it is absent. TLS verifies the server name derived from addr.

Configuration fails with a key-specific error for an unknown protocol, malformed address, framing on UDP, non-transparent framing on TLS, a missing TLS server identity, or only one half of an emitter client identity. TLS keys are invalid with UDP or plain TCP. Startup diagnostics retain the failed TLS file, certificate, or key cause beneath the Syslog configuration error, with the setting or path that failed. They do not include TLS file contents.

Ingestor

A SYSLOG LISTENER ingestor reads through a TYPE SYSLOG client. It has one delivery mode, NO ACK, and no INSTANCES clause:

CREATE [IF NOT EXISTS] SYSLOG LISTENER INGESTOR <name>
  FROM <client>
  MODE NO ACK
  ON QUIESCE SUSPEND
    | BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
    | DROP
  DECODE USING <codec>

Every live Nervix node runs the listener on the client’s configured addr. The listener is not leader-owned and has no placement owner: leadership changes do not move or restart it. A restarted node starts its listener again after restoring the active graph, and a node joining the cluster starts one after receiving that graph. In Kubernetes, a Service can therefore target the syslog port on every Nervix pod. Routes, branch construction, flush policy, and error policy follow the normal ingestor contract.

Every received message exposes its remote socket address as optional STRING metadata.peer_addr. For UDP this is the datagram source; for TCP and TLS it is the connection peer. Transport metadata does not propagate through a relay unless a route assigns it to a schema-backed field:

TO received_syslog
  INHERIT ALL
  SET peer_addr = metadata.peer_addr
  UNBRANCHED
  FLUSH IMMEDIATE
  ON MESSAGE ERROR LOG

UDP

UDP follows RFC 5426. One datagram is one frame. A datagram larger than max_message_size is dropped and logged at debug level.

TCP

TCP follows RFC 6587. The listener accepts any number of concurrent connections and detects framing for every frame:

  • a frame beginning with a digit uses octet counting
  • any other frame is terminated by LF; a CR immediately before the LF is removed
  • both framing forms may be interleaved on one connection

An octet count must be a positive decimal number with at most ten digits. A malformed or oversized octet-counted frame, or an oversized non-transparent frame, closes that connection because the stream cannot be resynchronized safely. Other connections and the listener continue unaffected. Per-connection frame memory is bounded by max_message_size, and completed frames enter a bounded intake queue.

A frame may arrive split across any number of reads, including inside its octet count or between its CR and LF. The listener keeps the part it has and completes the frame from the following reads; the frame, and any framing failure, is the same as when one read carries it whole.

TLS

TLS follows RFC 5425: the TCP listener behavior runs over a TLS session and frames use octet counting. A failed handshake closes only that connection. The client-authentication behavior is controlled by tls_ca_file as described above.

Quiesce and failures

SUSPEND stops reading. UDP datagrams may accumulate in and overflow the kernel receive buffer; TCP and TLS apply transport backpressure and retain transport-buffered data for resume. BUFFER and DROP continue reading and apply the normal bounded local quiesce behavior.

Syslog transport is at-most-once at intake. The listener sends no application acknowledgment, and datagrams dropped or stream data lost before intake are not recoverable.

Bind and listener-level socket failures appear as that node’s ingestor transient error and retry with the standard reconnect backoff. A failure on one node does not stop listeners on other nodes. Recovery clears the error and resets the backoff. A malformed message body is a codec decode failure and skips only that frame. For a malformed stream frame, the connection closes and its debug diagnostic retains the framing cause without quoting the frame’s payload.

Emitter

The syslog sink requires a codec and supports only NO_ACK:

TO SYSLOG <client>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
  ENCODE USING <codec>

Header invocations are not supported. Every emitter route still requires its normal flush policy, message error policy, and general error policy. Branch identity collapses only after successful external emission.

UDP

Each encoded record is one datagram. An encoded payload above 65,507 bytes is a message error. With an emitter BATCH clause and a SYSLOG codec, the codec writes one RFC 5424 message per batch. Its MSG is a JSON array of the members’ complete RFC 5424 messages, and the outer header contains the fields every member shares. Each complete batch frame is one datagram and is checked against the same UDP limit.

TCP

The emitter keeps one persistent connection. octet-counting writes the decimal byte length, one space, and the payload. It rejects a payload whose count needs more than ten digits before writing. non-transparent writes the payload followed by LF; an encoded payload that already contains LF is a message error in this mode.

TLS

The emitter uses octet-counted framing over one verified TLS connection. RFC 5425 does not permit non-transparent TLS framing. The same ten-digit count limit applies.

For every transport, success means the local socket accepted and flushed the complete frame. No remote delivery acknowledgment exists. Connection establishment and write failures are infrastructure errors: the current batch is retained, the connection is rebuilt, and delivery is retried on the declared backoff. Records accepted before a later failure in the same batch may be delivered more than once.

A missing name, silent DNS server, or unreachable answer is a connection failure. Resolving an address does not report record delivery. Shutdown or cancelled startup drops pending DNS and connection work; the physical connection budget also bounds an attempt if no cancellation occurs.

Observability and limits

Syslog ingestors and emitters use the standard DESCRIBE INGESTOR and DESCRIBE EMITTER runtime state, transient-error, reconnect-backoff, edge-metric, and emitter-metric surfaces. SHOW CREATE renders canonical codec, client, ingestor, and emitter declarations. Normal ALTER quiesce classification applies. Lifecycle transitions are info-level events; per-frame and per-connection details are debug or trace and do not include payload values.

LimitValue
Ingested messagemax_message_size, default 131,072 bytes
UDP datagram intake65,535 bytes, further limited by max_message_size
UDP payload emission65,507 bytes
Octet-counting prefixAt most 10 digits
Concurrent TCP/TLS connectionsUnbounded count; bounded per-connection frame memory and bounded intake queue

Database Client Connection Pools

Database clients declare how many connections Nervix may open for them. The pool-capable client types are POSTGRES, MYSQL, MONGODB, and REDIS. Every client of those types states both a minimum and a maximum connection count as part of its definition.

HTTP clients are outside this contract, including ClickHouse, HTTP polling, Prometheus, Sentry, OTEL, SQS, Iceberg REST, and the object-store transports. Broker clients, WebSockets, Syslog, and ZeroMQ keep their transport-specific connection policies. The pool clauses are available only for the four types listed above.

Declaring the bounds

The clause is required, with its two bounds in this order, immediately after the client type and before an optional resource mount:

CREATE [IF NOT EXISTS] CLIENT <name>
  TYPE POSTGRES|MYSQL|MONGODB|REDIS
  POOL SIZE MIN <minimum> MAX <maximum>
  [MOUNT <resource> VERSION <n>|LATEST]
  CONFIG { <connector configuration> };
CREATE CLIENT postgres_main
  TYPE POSTGRES
  POOL SIZE MIN 2 MAX 8
  CONFIG {
    'addr' = 'postgresql://USER:PASSWORD@HOST:5432/DATABASE?sslmode=verify-full'
  };

CREATE CLIENT redis_main
  TYPE REDIS
  POOL SIZE MIN 1 MAX 4
  MOUNT dev_tls VERSION 1
  CONFIG {
    'addr' = 'rediss://USER:PASSWORD@HOST:6379/0',
    'tls_ca_file' = '{{ dev_tls }}/ca.pem'
  };

MIN is the number of established, usable connections the pool maintains while it has local users. It counts idle and checked-out connections together; it does not reserve that many idle connections in addition to active ones. MAX is the ceiling on connections Nervix manages for the client, counting connections being established or retired as well as those in use.

A minimum of zero allows the pool to become empty while it is idle. Equal positive bounds request a fixed maintained size. A maximum of 1 is an ordinary bound, not a special case.

One pool per client, per domain, per node

A pool belongs to one named client in one domain on one physical Nervix node. Every local emitter of that client borrows from the same pool, so adding emitters does not add capacity and the bounds describe the node’s connections rather than each emitter’s. Two separately named clients have separate budgets even when their addresses match, and clients in different domains do too.

A pool is materialized only when the node has a local user that needs pooled operations. Registering a client, or replicating its definition to a node with no local user, opens no connections. Removing one user preserves the pool for the others; removing the last one closes it and releases its resource mounts.

Placement therefore decides how many pools exist. A client with POOL SIZE MIN 2 MAX 8 and local users on three nodes maintains six connections cluster-wide and may reach 24. The bounds are not a cluster-wide database quota, and capacity planning must also account for separately named clients and other applications using the same database.

Borrowing and waiting

A borrower holds a connection only while it performs an external operation or its necessary metadata work. For Postgres and MySQL the unit is a bounded insert or its metadata lookup, and for Redis it is one publish and its response; a flush containing several inserts releases capacity between them so other emitters can make progress. An emitter waiting out a flush interval or a retry backoff holds no connection at all.

When capacity is exhausted the operation waits, and that wait applies backpressure through the emitter’s existing bounded input and flush buffers. A full pool is a waiting state rather than a failure: DESCRIBE EMITTER <name>; reports

status: WAITING
detail: waiting for a connection from client 'postgres_main' for 3s

An acquisition timeout, an inability to connect, or the loss of a connection is an infrastructure failure instead, and follows the emitter’s existing retry policy and general-error reporting.

Postgres has a 30-second acquisition deadline covering pool wait, connection establishment, authentication and validation, independent of how long an accepted query then runs. MySQL and Redis have no separate pool-wait deadline. MongoDB retains its native server-selection and operation timeouts.

What each bound counts

Client typeScope of POOL SIZE MIN M MAX N on one Nervix node
POSTGRESMaintain M usable connections and allow at most N across all local users of the named client.
MYSQLMaintain M usable connections and allow at most N across all local users of the named client.
REDISMaintain M usable command connections and allow at most N across all local publishers. Each Redis Pub/Sub ingestor owns one dedicated subscription connection that is never drawn from, returned to, or charged against the command pool.
MONGODBMaintain M and allow at most N application connections independently for each ready MongoDB server, shared by all local users. Monitoring connections are driver-owned and counted separately.

A Redis client used only by ingestors still declares both bounds but materializes no command pool until a local publisher needs one. Consequently a client with POOL SIZE MIN 1 MAX 4, one local publisher and two local Pub/Sub ingestors maintains one command connection and may use up to six Redis connections in total: four command connections and two dedicated subscription connections.

Accepted values

Both values are unquoted decimal integers written directly in the statement.

BoundAccepted range
MIN0 through 4,294,967,295
MAX1 through 4,294,967,295

The minimum must not exceed the maximum. The clause appears exactly once and carries each bound once, and neither count takes a unit, an expression, a template, an unlimited value, or an implicit default. A statement that omits the clause or a bound, writes the bounds in the other order, repeats one, places the clause after MOUNT, or gives a count outside its range is rejected where it is read, before any client exists. The diagnostic points at the rejected text and never prints a credential or a complete connection address.

Bounds and connector configuration

The bounds are structured client configuration, independent of the connector’s string-valued CONFIG. Endpoint, authentication, database, TLS, and supported session options continue to belong to CONFIG. Connection-pool sizing is declared only through the POOL SIZE clause. Native connector options for connection establishment, idle lifetime, and operation timeouts stay in CONFIG and do not restate either bound.

Both counts are strongly persisted with the rest of the client definition, and SHOW CREATE CLIENT <name>; reports them in the declared order:

CREATE CLIENT postgres_main
  TYPE POSTGRES
  POOL SIZE MIN 2 MAX 8
  CONFIG {
    'addr' = 'postgresql://USER:PASSWORD@HOST:5432/DATABASE?sslmode=verify-full'
  };

Changing a bound

There is no ALTER CLIENT. Change either bound the same way as any other part of a client definition: DROP CLIENT and CREATE CLIENT in one transaction. A client that runtime entities reference is replaced together with them, under the existing transactional quiesce contract for those entities.

Resources

RESOURCE is Nervix’s versioned file-distribution primitive.

A resource is an uploaded directory tree that is stored and replicated across the cluster. It is used for binary or multi-file assets that runtime nodes need locally, such as:

  • ONNX models
  • protobuf descriptor trees
  • CSV lookup tables
  • TLS certificate bundles

This chapter describes the NSPL statements and their observable behavior. See Resource Versions And Bindings for the architecture behind them: how a version is installed and completed on every node, what each binding loads and when, and how a rebinding is validated, classified, and applied.

Domain Ownership

A resource belongs to the domain it was created in, like every other entity. CREATE RESOURCE, UPLOAD RESOURCE, and DESCRIBE RESOURCE all act on the session’s selected domain, and a model resolves a resource name only against versions completed in its own domain.

The same resource name in two domains is two independent resources: they have separate version sequences, separate stored content, and separate replica state. Referring to a name that was never created in the selected domain fails with resource '<name>' does not exist, even when another domain has it.

Lifecycle

Resources are managed in two phases:

CREATE [IF NOT EXISTS] RESOURCE <name>;
UPLOAD RESOURCE <name> VERSION '<local_directory>';
DESCRIBE RESOURCE <name>;
DESCRIBE RESOURCE <name> VERSION <n>;
REBIND RESOURCE <name> TO VERSION <n>|LATEST
  [FOR <kind> <name> [, <kind> <name> ...]];

CREATE RESOURCE registers the logical resource name.

With IF NOT EXISTS, creating an already-registered resource becomes a successful no-op instead of an error.

UPLOAD RESOURCE uploads a local directory recursively and returns the assigned numeric version after every current live node incarnation has verified and atomically installed the same archive digest. A command that references the version can run immediately through any live node after the upload succeeds.

Clients give each administrative upload an identity and retain it across redirects and transport retries. Reusing an identity for the same user, domain, and resource returns its existing assigned version and terminal outcome. Reusing an identity with different archive bytes fails with a digest conflict. Once the client has supplied the complete declared archive and the cluster admits it, installation continues if that request disconnects. An interrupted partial body has not admitted a complete upload and can be sent again with the same identity.

DESCRIBE RESOURCE <name> reports latest as the highest completed version, or (none) when no upload has completed. Its versions list and version details include every version whose metadata has been published, including a version whose upload is still applying or finished with failure. This keeps incomplete upload diagnostics visible without making that version eligible for use. Its usages section lists every model currently bound to the resource and its pinned version. A session client also receives the same description typed, so it never reads these facts back out of the printed text.

DESCRIBE RESOURCE <name> VERSION <n> shows the detailed state for one version, including:

  • content checksums
  • total file count and size
  • each node’s replica state, process incarnation, and installation diagnostics
  • the models pinned to that specific version

Descriptions are observations. They are useful for diagnosis and recovery visibility, but a caller does not poll them to finish an upload.

Versioning

Versions are monotonically increasing integers assigned by the cluster leader per domain and resource name. A resource binding may select only a completed version, and every binding names the version it selects with a mandatory VERSION <n> or VERSION LATEST clause. An explicit VERSION <n> fails while that upload is applying and remains unavailable if the upload fails. VERSION LATEST selects the highest completed version, so a newer applying or failed version does not displace the last usable one. A resource with no completed version cannot be bound.

LATEST is resolved when the statement is applied, and the stored model keeps the resolved number. A standalone statement resolves it immediately. A statement queued in a transaction resolves it provisionally when it is admitted, so the queued prefix can be validated, and again when COMMIT applies it; a version that completes in between is the one COMMIT binds. The command result names the version each LATEST resolved to, SHOW CREATE renders the stored number, and no later upload moves an existing binding.

latest in DESCRIBE RESOURCE is the version VERSION LATEST would select at that moment.

Rebinding Existing Models

REBIND RESOURCE moves existing bindings without changing the resource catalog:

REBIND RESOURCE fraud_model TO VERSION LATEST;
REBIND RESOURCE fraud_model TO VERSION 3
  FOR INFERENCER score_model, HASH MAP scores_by_id, CLIENT model_store;

Without FOR, every usage in the selected domain is included. With FOR, every member is kind-qualified and must exist and already bind that resource. The supported kinds are VHOST, CODEC, SIGNALING PROTOCOL, INFERENCER, WASM PROCESSOR, HASH MAP, and CLIENT. Duplicate members are rejected, and written member order has no semantic effect.

Nervix resolves the target from one captured planning snapshot, rebuilds every selected model, runs the same model and external-file validation used by CREATE, and commits the resulting model replacements as one mutation batch. If one selected model fails validation, none of the bindings move. A stopped domain still stores the replacements without a runtime pause. An already-pinned selection succeeds at DYNAMIC, reports every selected usage as unchanged, and writes no model.

LATEST follows the same timing as other model bindings: immediate for a standalone command, provisional during transaction admission, and resolved again when COMMIT plans the queued statement. The result reports the number of changed and selected usages, the effective quiesce level, and one sorted from/to line per selected model.

rebound 2 of 3 usage(s) of resource 'fraud_model' to version 3 (latest)
quiesce level: DOMAIN_PAUSE
- kind=client name=model_store from=3 to=3 unchanged
- kind=inferencer name=score_model from=1 to=3
- kind=hash_map name=scores_by_id from=2 to=3

Rotate a resource by uploading the replacement first and then moving its usages. The upload must complete everywhere before the rebind can select it:

UPLOAD RESOURCE fraud_model VERSION './models/2026-09-17';
REBIND RESOURCE fraud_model TO VERSION LATEST;

The rebinding runs at the level of the usages it moves. Rotating a VHOST certificate this way is DYNAMIC: every node’s HTTPS listener installs the new bundle before the command succeeds, established connections keep their session, new connections present the new certificate, and ingestion does not pause. The refresh also applies while the domain is stopped. An inferencer or WASM processor usage pauses only that entity, and a protobuf codec, signaling protocol, hash map, or client mount usage pauses the domain. A WASM processor moved to another version starts every branch without guest state. If any node’s listener cannot install the new certificate, the rebinding fails; when none of its usages paused, as when it moves only VHOSTs, every usage keeps its previous version.

A rebinding that leaves a WASM processor on the version it already binds moves nothing and so resets nothing. Nervix compiles each candidate module before the batch is published, so a module it cannot compile rejects the whole rebinding with every previous binding and its saved guest state still in place:

invalid WASM PROCESSOR 'counting_guest' in domain '<domain>': wasm processor 'counting_guest' module compilation failed

Upload Format

The client builds a deterministic tar archive of the directory’s subdirectories and regular files, skipping symbolic links and other special files, declares its exact size, and sends it in bounded chunks. Every node verifies the archive against its digest and the per-version limits below before it atomically installs the version, so a failed install leaves no partial version behind. An upload never changes the version an existing binding uses. See Resource Versions And Bindings for installation, transfer between nodes, and completion.

The per-version limits are configured with NERVIX_RESOURCE_MAX_ARCHIVE_BYTES, NERVIX_RESOURCE_MAX_EXTRACTED_BYTES, and NERVIX_RESOURCE_MAX_FILE_COUNT. Their defaults are 4 GiB, 16 GiB, and 1,000,000 files. Every node enforces them for each version it installs.

TLS Bundles

The first runtime integration for resources is VHOST TLS. A VHOST presents the bundle version it binds on the HTTPS listener of every node, and REBIND RESOURCE moves it to another version without pausing the domain.

A TLS resource bundle must contain these files at its root:

  • tls.crt
  • tls.key
  • ca.crt

This matches the common cert-manager layout.

Client Config Mounts

Client configs can mount a resource version into a temporary directory at runtime and then refer to files inside that mount from normal key-value settings.

Declare the mount directly on the client:

MOUNT <resource_name> VERSION <n>|LATEST

Every mount includes a version. A number binds that exact completed resource version. With VERSION LATEST, Nervix resolves the highest completed version when the client definition is applied and stores the resulting number. Later uploads and restarts therefore keep using the same files, and SHOW CREATE CLIENT renders the stored number.

Inside other client config values, Nervix renders values with a lightweight Jinja-like template language. The mounted resource path is exposed as a template variable named after the mounted resource:

{{ <resource_name> }}

Example:

CREATE IF NOT EXISTS CLIENT kafka_tls
  TYPE KAFKA
  MOUNT dev_tls VERSION 1
  CONFIG {
    'bootstrap.servers' = '127.0.0.1:9094',
    'security.protocol' = 'ssl',
    'ssl.ca.location' = '{{ dev_tls }}/ca.pem'
  };

Nervix creates one temporary mount root per instantiated client and keeps it alive for the lifetime of the ingestor or emitter using that client.

TLS Client Config Pattern

The general pattern for TLS-enabled external clients is:

  1. upload a resource containing the PEM files you want to use
  2. mount that resource on the client with MOUNT <resource_name> VERSION <n>|LATEST
  3. reference mounted paths from the client config template

Example:

CREATE IF NOT EXISTS RESOURCE dev_tls;
UPLOAD RESOURCE dev_tls VERSION './tls/dev';

CREATE IF NOT EXISTS CLIENT redis_tls
  TYPE REDIS
  POOL SIZE MIN 1 MAX 4
  MOUNT dev_tls VERSION 1
  CONFIG {
    'addr' = 'rediss://127.0.0.1:6380/',
    'tls_ca_file' = '{{ dev_tls }}/ca.pem'
  };

Common Nervix-managed TLS config keys:

  • tls_ca_file: PEM-encoded CA bundle used to trust the remote server
  • tls_cert_file: PEM-encoded client certificate for mTLS
  • tls_key_file: PEM-encoded client private key for mTLS

When supported by the client type, tls_cert_file and tls_key_file must be supplied together.

Client types that currently support TLS-oriented configuration through mounted resources:

  • Kafka
  • HTTP
  • Sentry
  • OTEL
  • Prometheus
  • WebSockets
  • MQTT
  • NATS
  • Pulsar
  • RabbitMQ
  • Redis
  • SQS
  • Syslog

ZEROMQ remains plain pass-through transport configuration and does not currently expose a Nervix-specific TLS helper surface.

Pulsar currently supports mounted tls_ca_file server trust configuration, but not mounted client certificate authentication.

Ingestors

Ingestors are the entry points into the runtime graph.

A typical ingestor:

CREATE BRANCH by_user
  SCHEMA user_branch TTL 5m;

CREATE IF NOT EXISTS KAFKA TOPIC INGESTOR kafka_notifications
  FROM kafka_main
  TOPIC 'notifications'
  OFFSET BY CONSUMER GROUP 'nervix_consumer'
  INSTANCES 1
  MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
  ON QUIESCE SUSPEND DECODE USING notification_codec
  TIMESTAMP NOW
  TO notifications
    INHERIT ALL
    BRANCHED BY by_user
    SET user_id = message.user_id
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

An ingestor statement names the connector and the entity it reads in its head, as one phrase before INGESTOR: KAFKA TOPIC consumes a Kafka topic, HTTP ENDPOINT serves a Nervix HTTP endpoint, and CLIENT PRODUCER admits batches that applications submit. The body after FROM names no connector and holds only that pair’s clauses; see Supported Ingestor Types. Completion offers each head as one item, such as KAFKA TOPIC INGESTOR. ALTER repeats the stored ingestor’s connector and entity, so an alteration never changes what an ingestor reads; to read another connector or entity, drop the ingestor and create it again. DROP INGESTOR, DESCRIBE INGESTOR, SHOW CREATE INGESTOR, SHOW INGESTORS, and relocation address an ingestor by its name alone.

CREATE [IF NOT EXISTS] <CONNECTOR> <ENTITY> INGESTOR <name>
  FROM <body>
  [DECODE USING <codec>]
  [TIMESTAMP NOW | TIMESTAMP AT <field>]
  [FILTER WHERE <expression>]
  TO <relay> <route body>
  [TO <relay> <route body> ...]
  ON GENERAL ERROR IGNORE | LOG;

DECODE USING is required under every head except CLIENT PRODUCER, which decodes nothing and does not accept it. A client the body names must be a client of the head’s connector, and an endpoint must have the head’s TYPE; completion after FROM offers only those, and any other reference is refused when the statement is applied, with a message that names both kinds, such as requires a KAFKA client, but client 'cache' is a REDIS client. SHOW CREATE INGESTOR renders the complete statement under its head. DESCRIBE INGESTOR prints the pair and the body on its from: line, such as from: KAFKA TOPIC kafka_main TOPIC 'Orders' OFFSET BY CONSUMER GROUP 'Readers' MODE NO ACK ON QUIESCE SUSPEND, and SHOW INGESTORS prints the pair alone, such as from=KAFKA TOPIC.

Every ingestor defines:

  • the destination relay or relays
  • the codec used for decoding, or for a client ingestor the schema its batches carry
  • a route-local outgoing branch declaration
  • a flush policy for every destination relay
  • a message error policy for every destination relay
  • the timestamp source
  • the connector and entity it reads, and the source body that follows FROM
  • the delivery mode
  • the source-specific quiesce mode and every variable it requires
  • optional node-level FILTER WHERE and route-local INHERIT / SET / WHERE

FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE is required after every TO <relay>. The NSPL Overview defines the system-owned 100 µs minimum batching window for FLUSH IMMEDIATE. It has no size boundary. Every route also requires its own ON MESSAGE ERROR <policy>. Each route buffers and handles message-specific construction failures independently. ON MESSAGE ERROR SEND TO uses that same route’s interval and maximum batch-size boundaries for error-record delivery. ON GENERAL ERROR remains node-level because it handles source or transport failures that are not tied to one message or output route.

MAX BATCH SIZE counts logical Arrow value, offset, and validity bytes. It excludes unused Arrow buffer capacity and object overhead. In delivery modes, MAX <n> is only an in-flight window for ACK PARALLEL; MODE NO ACK never accepts it.

At runtime, the ingestor:

  • decodes inbound payloads into runtime records; a JAQ-backed codec may unfold one payload into zero or more records
  • collects decoded records into a bounded source ingest group that closes once it holds 1,024 or more messages, or earlier when the source goes quiet for 5 ms; the records of one payload always join one group together, so a payload that unfolds past the remaining room closes its group above 1,024. This bound is independent of every route FLUSH policy and caps the first Arrow batch built from external input at 1,023 messages plus one payload’s records
  • executes FILTER WHERE once against that whole group
  • executes each route’s ordered construction and WHERE program once against the surviving group
  • resolves the concrete branch group from the referenced CREATE BRANCH
  • accumulates decoded rows independently for every matching destination and branch group
  • writes each route’s buffered rows when its configured interval or size boundary fires, when the documented FLUSH IMMEDIATE system timeout expires, or when the domain is force-flushed by a model alteration, a domain pause, or a planned ownership handoff

Branch execution receives these completed Arrow batches and does not buffer them behind another flush policy.

Source acknowledgement is per payload. An acknowledged delivery mode acknowledges a payload once every record decoded from it has been acknowledged on every route it reached, and a negative acknowledgement of any of those records negatively acknowledges the payload. ACK PARALLEL MAX <n> windows count payloads. A client ingestor’s payload is one submitted batch.

Timestamp selection and admission use one domain execution snapshot when the source group is delivered. TIMESTAMP NOW records that snapshot. TIMESTAMP AT <field> and connector-owned event timestamps preserve the external instant exactly; TIME RATE never scales them. The internal low and high watermarks initially equal the selected event time, so source event time remains distinct from delivery and observation time throughout the graph. The 5 ms source-idle close and the FLUSH IMMEDIATE 100 µs minimum are physical monotonic waits. FLUSH EACH and paced admission use domain-logical time. See Domains And Time. For a decoded group, timestamp selection and paced admission operate on Arrow columns. The group keeps admitted rows together; each rejected or missing event timestamp follows the ingestor route’s ON MESSAGE ERROR policy with code validation and operation admit, preserving that row’s ACK and source metadata. A batch containing both kinds can therefore deliver its accepted rows while its rejected rows reach the configured error route.

Delivery Modes

Every source body declares its delivery mode with MODE, polled and listener sources included:

  • NO ACK is one composed phrase. Nothing waits on an acknowledgement, and it takes no variables.
  • ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration> lets one payload at a time await its acknowledgement.
  • ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration> lets at most n payloads await their acknowledgement; BATCH TIMEOUT is the longest wait for a partial batch. A CLIENT PRODUCER ingestor writes ACK PARALLEL MAX <n> ACK TIMEOUT ... without BATCH TIMEOUT.

Each entity accepts only the modes it can honor, and completion offers only those:

EntityMODE forms
KAFKA TOPIC, PULSAR TOPIC, MQTT TOPICNO ACK, ACK SEQUENTIAL ..., ACK PARALLEL ...
RABBITMQ QUEUE, SQS QUEUEACK SEQUENTIAL ...
CLIENT PRODUCERACK SEQUENTIAL ..., ACK PARALLEL ... without BATCH TIMEOUT
HTTP REQUEST, HTTP ENDPOINT, WEBSOCKETS ENDPOINT, WEBSOCKETS CONNECTION, REDIS PUBSUB, NATS SUBJECT, ZEROMQ PULL, SYSLOG LISTENER, PROMETHEUS QUERYNO ACK

In an ACK mode an ingestor acknowledges a message on its transport only once every route and error delivery the message unfolded into has completed, and it reports that progress only once the transport confirmed the acknowledgement as far as the entity can: an SQS QUEUE waits for the broker’s answer to its deletion, a RABBITMQ QUEUE for its acknowledgement to be written to the connection, and a KAFKA TOPIC, PULSAR TOPIC or MQTT TOPIC only for its client to queue the acknowledgement. An acknowledgement the transport fails to confirm is a source error that DESCRIBE INGESTOR shows as the transient error until a later acknowledgement is confirmed, and the messages are delivered again. A node that stops gracefully waits for its ingestors’ outstanding confirmations before it closes them; see Shutdown And Recovery.

Altering Ingestors

ALTER names the connector and entity of the stored ingestor in its head and applies one or more comma-separated operations in written order:

ALTER <CONNECTOR> <ENTITY> INGESTOR <ingestor>
    SET FROM <complete body of the head's connector and entity>
  | SET QUIESCE <quiesce body>
  | SET DECODE USING <codec>
  | SET TIMESTAMP NOW
  | SET TIMESTAMP AT <field>
  | DROP TIMESTAMP
  | SET FILTER WHERE <expression>
  | DROP FILTER WHERE
  | ADD ROUTE TO <relay> <full ingestor route body>
  | DROP ROUTE TO <relay>
  | REPLACE ROUTE TO <relay> <full ingestor route body>
  | SET GENERAL ERROR IGNORE
  | SET GENERAL ERROR LOG
  [, ...];

A head whose connector and entity differ from those the stored ingestor reads is refused, for example with the stored ingestor reads HTTP ENDPOINT, so ALTER KAFKA TOPIC INGESTOR cannot alter it. An alteration therefore never changes what an ingestor reads; drop the ingestor and create it again under the other head instead.

SET FROM accepts the complete body that follows FROM in a CREATE statement with the same head, and only that pair’s body. Change a client or endpoint, a Kafka topic or offset mode, INSTANCES, the delivery mode, or source-specific subscription settings by supplying that complete body, including its required ON QUIESCE clause. SET QUIESCE changes only the quiesce mode and its variables. It parses every quiesce body, and the stored source refuses one it does not honor, for example with HTTP ENDPOINT ingestors do not support ON QUIESCE SUSPEND; there is no DROP QUIESCE operation. SET DECODE USING exists under every head except CLIENT PRODUCER, which decodes nothing. ADD ROUTE appends a route, while REPLACE ROUTE preserves the matched route’s position. Duplicate routes to the same relay remain legal, so DROP ROUTE and REPLACE ROUTE reject an ambiguous target. An ingestor must retain at least one route.

Every ingestor alteration uses ENTITY_PAUSE. Nervix quiesces only the affected ingestor instances on all live nodes and waits for their already accepted in-flight work to drain before commit. That work includes route output still held behind a FLUSH EACH interval: the hold force-flushes those branch buffers, so a slow or stopped domain clock cannot keep accepted rows out of the drain. Schedule application starts the desired source configuration on its assigned nodes; unrelated graph paths keep flowing. The quiesce mode in effect when the hold begins governs that hold; a mode changed by the alteration applies to later pauses. A drain or cutover failure leaves the candidate unapplied and releases the old source. The transient hold also expires automatically, so loss of the coordinating leader cannot leave an ingestor quiesced indefinitely.

Quiesce Modes

Every source body ends with a required ON QUIESCE <body>, which DECODE USING follows under every transport head. The quiesce level says which runtime work pauses for an alteration; an ingestor’s quiesce mode says what its external source experiences while that ingestor is paused. There is no default.

The shared mode vocabulary is:

  • SUSPEND stops taking payloads. Data waits in the external source where that source retains it; the source’s retention, queue, or session limits continue to run. ZeroMQ instead keeps its socket open and stops reading, leaving transport flow control and the peer’s high-water marks to govern the backlog. Polling ingestors skip polls. SUSPEND has no variables.
  • BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST keeps a push connection active and retains raw payloads plus their ingest context in a per-instance volatile buffer. MAX SIZE must be positive. A payload larger than the bound is dropped, and a full buffer applies the declared side of the overflow policy. Every discard is counted.
  • DROP keeps the source connection and protocol maintenance active but discards and counts every payload received during the quiesce.
  • REJECT RETRY AFTER <duration> is available only to HTTP ENDPOINT and WEBSOCKETS ENDPOINT. HTTP requests and new WebSocket upgrades receive 503; HTTP includes Retry-After, rounded up to whole seconds. Established WebSockets close with code 1013 (Try Again Later).
  • Endpoint buffering, for HTTP ENDPOINT and WEBSOCKETS ENDPOINT, is BUFFER MAX SIZE <bytes> without ON OVERFLOW. Requests that fit receive 202 and are held. A full buffer, or a body that cannot fit, is rejected with HTTP 503 or a WebSocket 1013 close; an acknowledged body is never discarded by buffer policy.

A source accepts only the modes it can honor, and completion of its body offers only those. SET QUIESCE parses every body and refuses one the stored source does not honor:

EntityAllowed ON QUIESCE bodies
KAFKA TOPIC, PULSAR TOPIC, RABBITMQ QUEUE, SQS QUEUESUSPEND
MQTT TOPICSUSPEND, BUFFER ... ON OVERFLOW ..., DROP; SUSPEND requires SESSION PERSISTENT QOS 1
NATS SUBJECT, REDIS PUBSUB, WEBSOCKETS CONNECTIONBUFFER ... ON OVERFLOW ..., DROP
ZEROMQ PULL, SYSLOG LISTENERSUSPEND, BUFFER ... ON OVERFLOW ..., DROP
HTTP REQUEST, PROMETHEUS QUERYSUSPEND, BUFFER ... ON OVERFLOW ...
HTTP ENDPOINT, WEBSOCKETS ENDPOINTREJECT RETRY AFTER ..., BUFFER MAX SIZE ...
CLIENT PRODUCERSUSPEND

The mode is consulted for resumable model-alteration ENTITY_PAUSE holds, DOMAIN_PAUSE batches, and memory-pressure shedding. STOP and DROP INGESTOR terminate the source session. Unexpected owner loss also terminates it immediately; volatile quiesce buffers and in-memory ACK state do not survive, so recovery depends on the external source’s retention and redelivery contract.

Node drain, graceful-shutdown drain, placement relocation, and explicit relocation with RELOCATE use a planned ownership handoff. That hold deliberately ignores ON QUIESCE: polling and new endpoint admission stop, an already admitted payload continues through its routes, and connected-source policy does not buffer or drop new work on behalf of the move. Nervix waits for the moved ingestor’s admitted ACK roots to resolve before it commits the new owner. Kafka offsets, broker acknowledgements, queue deletes, and the corresponding downstream attached work therefore complete on the former owner before its source session stops. The destination starts only after the schedule revision commits.

Only moved ingestors take this hold. Every other ingestor keeps its session open and continues ingesting, including ingestors on the former and destination cluster nodes. A failed fence or drain releases the former source and leaves the schedule unchanged.

Graceful shutdown then stops intake the same way on every ingestor of the terminating node, including endpoint ingestors, which serve on every node, and ingestors whose scheduled owner did not move. It also ignores ON QUIESCE. Already admitted payloads continue through their routes, route buffers are force-flushed whatever their FLUSH EACH cadence, and Nervix waits for their ACK roots to resolve before the source session stops, so offsets, acknowledgements, and deletes complete for work that reached its sinks. A root still unresolved when the drain timeout passes is negatively acknowledged, and the source’s redelivery contract applies. A raw quiesce buffer is outside runtime graph work, so a shutdown discards what it retained instead of replaying it. See Planned Ownership Handoffs And Failover and Shutdown And Recovery for the per-source acknowledgement and redelivery contract at shutdown.

Ordinary model-alteration quiesce still does not wait for downstream ACK chains. Already admitted route batches continue downstream, and a source item not yet acknowledged, committed, or deleted is eligible for redelivery on resume. A connected mode’s raw quiesce buffer is outside runtime graph work. Ordinary relay backpressure is also independent of ON QUIESCE.

Buffered payloads drain in arrival order per instance before that instance admits new live intake. There is no total order across instances. Decode, timestamp selection, filters, construction, routing, and domain pacing use the configuration in effect at delivery, so a codec installed by an alteration decodes payloads buffered during its hold. TIMESTAMP NOW therefore records delivery time. Bounds are per instance, and an endpoint has an independent buffer on every serving node.

A payload stays in its buffer, counted in the buffered records and bytes and within MAX SIZE, until its messages enter their ingest group, and overflow never discards it while it drains. Its sender was already answered or its source has moved past it, so when its codec’s ON INGESTION transformation finds the node’s extension workers full, the payload waits for them rather than being refused, ahead of work that asks after it, and nothing behind it drains first. A new quiesce or a shutdown ends that wait and leaves the payload at the front of the buffer. A payload its codec rejects is reported and dropped, as any failure after it was retained is.

Memory pressure never adds buffered bytes. During memory-pressure quiesce, a BUFFER push source drops and counts new payloads, polling sources skip polls, and endpoints reject. Payloads already buffered by an overlapping alteration remain retained. DROP continues to discard and SUSPEND continues to hold at the source.

DESCRIBE INGESTOR renders quiesce:, quiesce state: none|entity hold|ownership handoff|domain pause|memory pressure, status: quiesced for every active hold, and the four quiesce counters. It also renders nervix_ingestor_transport_redeliveries_total, the acknowledgements and rejections the node discarded after a RabbitMQ, SQS or MQTT source reconnected, because the broker redelivers what the lost connection did not acknowledge; see Connector Crates And The Connector Contract. SHOW CREATE round-trips the full clause.

When The Extension Workers Are Full

A codec with an ON INGESTION transformation unfolds every payload on the node’s extension workers. When every worker is busy and the class’s wait queue is full, what happens to a payload depends on whether its sender can present it again:

  • An acknowledged delivery mode rejects the batch, and its source delivers it again on its RETRY POLICY.
  • An endpoint ingestor answers an HTTP body 503 without Retry-After and closes an established WebSocket with 1013, so the sender sends it again.
  • A payload that BUFFER retained waits for a place when its buffer drains; see Quiesce Modes.
  • A paced source has already moved past its poll, so the poll waits for a place. Waiting delays the next poll, and the domain cadence coalesces the occurrences it missed into one poll at the newest due instant.
  • A source read in MODE NO ACK cannot present a payload again either. The payload waits for a place when holding the source’s loop keeps its transport connected with bounded memory. Otherwise it is refused: the ingestor reports the refusal as an error and counts the payload in nervix_ingestor_unfolding_refused_total.
EntityA payload the extension workers cannot take nowWhy
HTTP REQUEST, PROMETHEUS QUERYWaitsHolding the loop only delays the next poll.
NATS SUBJECTWaitsThe client keeps its connection and reads the subscription while the loop waits, holding up to 65,536 messages before it drops the newest as a slow consumer.
ZEROMQ PULLWaitsUnread messages stay in the socket’s kernel buffers, and then the pushing peers wait. Nothing is dropped.
SYSLOG LISTENERWaitsThe listener stays bound. The kernel drops UDP datagrams beyond the socket’s receive buffer, and TCP and TLS senders wait once the listener’s 64-frame queue is full.
MQTT TOPICRefused and countedThe client sends its keep-alive only while the loop reads, so the broker closes the connection once one and a half keep-alive intervals pass without a packet from it, and what is published until the source reconnects is lost.
REDIS PUBSUBRefused and countedThe client reads every published message into an unbounded queue of its own, so a held loop would grow the node’s memory without limit.
WEBSOCKETS CONNECTIONRefused and countedThe client answers the server’s pings only while the loop reads, so a server that pings would close the connection and lose what it sent until the source reconnects.
KAFKA TOPIC in MODE NO ACKRefused and countedA consumer-group member that stops polling for max.poll.interval.ms leaves its group, and an instance reading domain offsets would keep its partitions assigned through an ownership handoff.
PULSAR TOPIC in MODE NO ACKRefused and countedA consumer whose receive queue fills cannot complete its close, so a later suspension or stop would wait on it. The refused message stays unacknowledged until the consumer reconnects, when the broker delivers it again.

A wait never delays a stop or a new quiesce. Stopping the ingestor ends the wait and drops the payload with the rest of the ingestor’s in-flight input. A new quiesce decides on the payload again, as on input that arrives while quiesced: BUFFER retains it, DROP drops it and counts it in nervix_ingestor_quiesce_dropped_total, and a suspension, under which a payload the source already handed over still dispatches, lets it wait on until the workers have room. While a payload waits, the ones behind it stay with the transport, in order.

Branch Semantics

Ingestors are where external mixed flows enter branch-isolated processing. Every route independently constructs a concrete branch or becomes unbranched. The branch declaration owns the key schema, TTL, and optional LRU eviction policy:

  • CREATE BRANCH <branch> SCHEMA <schema> TTL <duration> declares the branch key schema and lifetime
  • BRANCHED BY <branch> SET field = expression, ... constructs the route’s key after output finalization and route filtering
  • MAX INSTANCES <n> EVICT LRU may be added to cap active concrete branch instances and evict the least recently used branch when capacity is reached
  • UNBRANCHED produces an absent branch key; it is not encoded as an empty string or synthetic root identifier
  • the named branch defines both the branch identity and its key shape; downstream relays and branch-preserving processors must reference that same branch name
  • decoded rows are appended to matching destination relays inside that group’s branch
  • downstream normal processors keep the same group until a REINGESTOR or EMITTER boundary

Per-group behavior such as downstream deduplication, reordering, and window aggregation stays scoped to that branch.

Batching follows that same rule:

  • an ingestor buffers independently per concrete branch group
  • UNBRANCHED produces one root branch and one batcher

Client-backed ingestors can use resource-mounted client config values for TLS material and other file-based settings. See Resources.

Value Construction and Filters

Ingestors may declare an optional arrival filter and per-route construction clauses:

CREATE BRANCH by_tenant
  SCHEMA tenant_branch TTL 5m;

CREATE IF NOT EXISTS HTTP ENDPOINT INGESTOR notifications_in
  FROM ingress MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE 1MiB DECODE USING notification_codec
  FILTER WHERE input.active
  TO notifications
    INHERIT ALL EXCEPT raw
    SET amount = message.amount + 1,
        normalized = lower(input.raw)
    WHERE output.tenant = 'acme'
    BRANCHED BY by_tenant SET tenant = message.tenant
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

FILTER WHERE runs after codec decoding and before route processing. Each TO route starts with an empty output, applies INHERIT and ordered SET, finalizes the output, evaluates its WHERE, and then constructs its own branch key.

Supported blocks:

  • INHERIT ALL, INHERIT ALL EXCEPT ..., or explicit INHERIT field, ... copies compatible same-named decoded fields
  • SET <field> = <expr>, ... initializes route output fields in order
  • WHERE <expr>: drops rows whose predicate is false or null

General notes:

  • SET is a single clause with comma-separated assignments
  • assignments execute left to right; repeated destination fields are allowed and later expressions read the latest preceding value
  • later assignments may read earlier output values; repeated targets are valid
  • message.field reads the working message; input.field always reads the original decoded row and output.field requires prior initialization
  • route WHERE sees finalized output through bare fields, message, or output, without fallback
  • relay-qualified fields and UNSET are invalid
  • all node expressions use explicit typing; there is no implicit cast insertion
  • nested predicates and nested/chained builtin calls are supported
  • leader-side validation requires every required output and branch field to be initialized
  • public Models store structured expressions; the runtime never reparses NSPL
  • source-specific transport metadata may be exposed through metadata.<field> when the ingestor supports it; KAFKA TOPIC provides metadata.topic, metadata.partition, and metadata.offset, while SYSLOG LISTENER provides metadata.peer_addr
  • supported sources expose transport headers through read_header(name) and read_headers(name); header names may be any STRING expression, and these functions may be used in both top-level FILTER WHERE and per-route expressions

Useful built-ins include string, null-handling, numeric, regex, and contextual functions such as lower, coalesce, abs, regexp_like, now, and uuid_v7.

See Expression Functions for the full function reference.

Common expression patterns include:

  • nested conditions such as (active AND amount > 5) OR NOT flagged
  • chained calls such as lower(trim(raw))
  • arithmetic expressions such as (amount + fee) / divisor
  • explicit casts such as raw AS INT64, and tolerant conversions such as TRY_CAST(raw AS INT64), which yield a typed null instead of failing the message
  • typed reads of JSON text held in a STRING field, such as JSON_VALUE(raw, '$.order.id' AS INT64); see JSON Documents

The expression type surface matches the full Nervix internal schema type set:

  • U8, I8, U16, I16, U32, I32, U64, I64
  • F32, F64
  • BOOL, STRING, DATETIME

DATETIME is the internal logical timestamp type. JSON or AVRO string wire values require ENCODE <field> AS RFC3339 on the codec before they can decode into the named DATETIME field.

Header Context

Some ingestors receive additional source data alongside the decoded payload body. They expose it through two functions:

  • read_header(name) returns the first value as an optional STRING, or NULL when the header is absent
  • read_headers(name) returns every value in transport order as required LIST<STRING>, or an empty list when the header is absent

The name argument can be any expression that returns STRING. Both functions are available in top-level FILTER WHERE and in per-route SET / WHERE expressions for these sources:

  • HTTP ENDPOINT and HTTP REQUEST expose HTTP headers with UTF-8 values
  • WEBSOCKETS ENDPOINT exposes UTF-8 headers from the opening HTTP upgrade request
  • KAFKA TOPIC exposes Kafka record headers
  • NATS SUBJECT exposes message headers
  • PULSAR TOPIC exposes message properties
  • RABBITMQ QUEUE exposes AMQP message headers, converting typed AMQP values to strings
  • SQS QUEUE exposes message attributes, converting string, number, and binary values to strings

The same captured source envelope remains available while a supported ingestor constructs an ON MESSAGE ERROR SEND TO record, so its error-route SET may also call read_header and read_headers.

Header values are non-sensitive strings and do not propagate through relays unless explicitly assigned to schema-backed fields:

SET route = read_header(lower(input.route_header))
WHERE read_header("tenant") = output.tenant

MQTT TOPIC, REDIS PUBSUB, PROMETHEUS QUERY, SYSLOG LISTENER, ZEROMQ PULL, WEBSOCKETS CONNECTION, and CLIENT PRODUCER ingestors do not support header reads. Leader-side validation rejects these functions for a source without header support, with a message that names the source’s phrase, such as MQTT TOPIC ingestors do not support read_header or read_headers.

TLS Client Configuration

For outbound ingestor clients, TLS is configured on the CLIENT, not on the INGESTOR.

General pattern:

CREATE [IF NOT EXISTS] CLIENT <name>
  TYPE <kind>
  MOUNT <tls_resource> VERSION <n>|LATEST
  CONFIG {
    ...
    'tls_ca_file' = '{{ tls_resource }}/ca.pem'
  };

Transport-specific schemes and keys:

  • KAFKA: pass-through to librdkafka. Typically set 'security.protocol' = 'ssl', 'ssl.ca.location' = '{{ tls_resource }}/ca.pem', and if needed 'ssl.certificate.location' plus 'ssl.key.location'.
  • HTTP: use an https://... endpoint. Nervix honors tls_ca_file, tls_cert_file, tls_key_file, and optional timeout_ms.
  • PROMETHEUS: use an https://... addr. Nervix honors tls_ca_file, tls_cert_file, tls_key_file, and optional timeout_ms.
  • WEBSOCKETS: use a wss://... endpoint. Nervix honors tls_ca_file, tls_cert_file, tls_key_file.
  • MQTT: use mqtts://... in addr. Nervix requires tls_ca_file for server trust and also supports tls_cert_file plus tls_key_file for mTLS.
  • NATS: use tls://... in addr. Nervix honors tls_ca_file, tls_cert_file, tls_key_file.
  • PULSAR: use pulsar+ssl://... in addr. Nervix honors tls_ca_file and optional tls_allow_insecure_connection plus tls_hostname_verification_enabled. Pulsar client certificate authentication is not currently exposed.
  • RABBITMQ: use amqps://... in addr. Nervix honors tls_ca_file.
  • REDIS: use rediss://... in addr. Nervix honors tls_ca_file, tls_cert_file, tls_key_file.
  • SQS: use an https://... endpoint. Nervix honors tls_ca_file. This is primarily useful for SQS-compatible local/test endpoints.
  • SYSLOG: select 'protocol' = 'tls'. An ingestor requires tls_cert_file and tls_key_file; optional tls_ca_file enables required client-certificate verification.

HTTP REQUEST and PROMETHEUS QUERY ingestors resolve endpoint names through the node’s configured DNS resolver. A client timeout_ms covers name resolution, connection establishment, TLS, and the response. The endpoint name remains the HTTP authority and HTTPS certificate name after resolution. RabbitMQ resolves the host of its addr the same way and verifies an amqps broker certificate against that host; see RabbitMQ. Redis Pub/Sub also resolves its addr hostname through the node resolver for each dedicated subscription connection; see Redis Pub/Sub. MQTT resolves the host of its addr through the node resolver for every connection and verifies an mqtts broker certificate against that host; see MQTT.

Example Kafka TLS client:

CREATE IF NOT EXISTS CLIENT kafka_tls
  TYPE KAFKA
  MOUNT dev_tls VERSION 1
  CONFIG {
    'bootstrap.servers' = '127.0.0.1:9094',
    'security.protocol' = 'ssl',
    'ssl.ca.location' = '{{ dev_tls }}/ca.pem'
  };

Example HTTP TLS client:

CREATE IF NOT EXISTS CLIENT http_tls
  TYPE HTTP
  MOUNT dev_tls VERSION 1
  CONFIG {
    'endpoint' = 'https://127.0.0.1:18443/http/notifications',
    'method' = 'GET',
    'timeout_ms' = 5000,
    'tls_ca_file' = '{{ dev_tls }}/ca.pem'
  };

Client Ingestors

The Client Ingestors And Emitters architecture chapter follows a complete native graph through admission, application ACK, credit, forwarding and recovery.

A client ingestor, declared under the CLIENT PRODUCER head, admits typed batches that applications submit through a Nervix session instead of reading an external system. There is no CREATE CLIENT, codec, DECODE USING, transport header, or NO ACK mode: a submitted batch already carries the ingestor’s input schema, and every batch is acknowledged.

CREATE SCHEMA order_in (
  region STRING, order_id STRING, amount I64, card STRING SENSITIVE
);
CREATE SCHEMA region_key (region STRING);
CREATE BRANCH by_region SCHEMA region_key TTL 5m;

CREATE CLIENT PRODUCER INGESTOR orders_in
  FROM SCHEMA order_in
    MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 1s
    ON QUIESCE SUSPEND
  TIMESTAMP NOW
  TO orders_by_region
    INHERIT region, order_id, card
    SET amount_cents = input.amount * 100
    BRANCHED BY by_region SET region = message.region
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;
CREATE [IF NOT EXISTS] CLIENT PRODUCER INGESTOR <name>
  FROM SCHEMA <schema>
  MODE ACK SEQUENTIAL | ACK PARALLEL MAX <n>
  ACK TIMEOUT <duration>
  RETRY POLICY BACKOFF <duration> MAX <duration>
  ON QUIESCE SUSPEND

Every clause is required and there are no defaults:

  • SCHEMA names the input schema. A batch must carry exactly its fields, in order, with their types and optionality, and a producer must declare exactly those fields, including which are SENSITIVE, before it may submit anything.
  • ACK SEQUENTIAL admits one batch at a time; ACK PARALLEL MAX <n> lets at most n admitted batches await their acknowledgement. The window belongs to the ingestor’s one execution and every producer shares it, so opening more producers never widens it. Producers with queued batches take the window in turn.
  • ACK TIMEOUT bounds how long an admitted batch may make no acknowledgement progress. When it passes, the batch is reported as failed with ack_timeout; its admitted work is not cancelled and may still complete. Downstream work that reports it is alive keeps the batch from timing out.
  • RETRY POLICY BACKOFF <duration> MAX <duration> is the physical backoff a producer applies before sending again a batch that was refused only temporarily. It starts at BACKOFF and doubles up to MAX, which must not be shorter than BACKOFF. Producers are told it when they open.
  • ON QUIESCE SUSPEND is the only quiesce mode. Admission stops for the hold: producers are told that admission is suspended, queued batches that were not admitted yet are refused as suspended, and the producer keeps them to send again once admission reopens.

Timestamp selection, FILTER WHERE, route construction, WHERE, branch construction, route FLUSH, ON MESSAGE ERROR, and ON GENERAL ERROR behave as for every ingestor; message and input read the submitted row. Client batches carry no transport headers or metadata, so read_header, read_headers, and metadata.* are unavailable. The ingestor executes on its one scheduled cluster node. A producer is opened on a session that any live node may serve, and that node forwards the producer’s batches to the ingestor’s node; a console session, like every console session, is served only by the leader.

ALTER CLIENT PRODUCER INGESTOR ... SET FROM SCHEMA ... replaces the complete source body, and SET QUIESCE SUSPEND restates the only quiesce mode. SHOW CREATE INGESTOR renders the CLIENT PRODUCER head and the whole FROM SCHEMA body.

Submitted Batches

A batch is one canonical Arrow IPC stream: the schema message, exactly one record batch, and the end-of-stream marker. It is uncompressed and has no dictionary or extension encodings, and its Arrow schema is exactly the input schema in Nervix’s Arrow representation of each type, with no field metadata. A batch carries at most 65,536 rows and at most the bytes one submission may carry, which is the smaller of the producer’s granted bytes and what one session frame holds. The node that executes the ingestor validates the whole batch, its structure, bounds, and every column, before any row is admitted; a batch that fails is refused whole and has no effect on the graph. Nothing is coerced, cast, or partially admitted.

Outcomes

One batch is one source acknowledgement unit. It has exactly one terminal outcome:

OutcomeMeaningCause
not admittedNo row entered the graph.invalid batch: <defect>, suspended, busy, draining, producer ended, credit exceeded
completedEvery route and acknowledging sink its rows reached confirmed them under the graph’s rules.—
processing failedThe batch was admitted and its acknowledgement failed. Some of its effects may have happened.ack_timeout, rejected
unknown outcomeThe batch may have been admitted and processed, but no terminal result can be established.interrupted, owner_lost; a client whose session ended adds session_lost

Transport receipt is never an outcome. A batch completes under the normal acknowledgement rules: a row a filter drops is resolved; a detached boundary resolves where it detaches; and an acknowledging sink resolves its rows at its own success boundary, such as the complete response headers of an HTTP emitter. The defects of an invalid batch are malformed, unexpected message, compressed, schema mismatch, not one batch, too many rows, too large, and invalid data.

A row that fails on a route of an admitted batch is handled by that route’s ON MESSAGE ERROR policy exactly as for any other ingestor: IGNORE acknowledges it, LOG negatively acknowledges it, so its batch fails processing as rejected, and SEND TO acknowledges it once its error record is published to the error relay. The other rows of the batch are processed either way.

Only suspended and busy are temporary: sending the same batch again on the same producer may succeed, and the Rust client does so on the declared backoff. Every other outcome is final for that attempt. Nervix never replays a batch that failed or whose outcome is unknown; replaying it is the application’s decision, and it may duplicate the batch’s effects. The application therefore keeps its replayable source data until a batch completes.

Producers

A producer is opened on a session with an explicit domain and ingestor, the fields it expects, and the credit it asks for: how many batches and how many bytes it may have outstanding. The open is answered with the input schema, the domain’s START generation, the identity of the endpoint contract, the attachment, the policy above, the granted credit, and whether admission is open now. A later USE does not move a producer. An open is refused, with nothing left attached, when the domain does not exist or is stopped, the ingestor does not exist, reads a transport, or is not running on its scheduled node, the expected fields differ, the credit is larger than one producer may ask for, the session or node has no room left, or the session holds a transaction.

LimitValue
Producers per session32
Outstanding bytes per session32 MiB
Outstanding bytes per node, including batches retained for forwarded producers128 MiB
Batches one producer may ask to have outstanding1,024
Rows per batch65,536

A batch holds its share of the credit from the moment it is sent until its outcome is observed. A batch sent beyond the credit is refused as credit exceeded without reaching the graph, and the producer is ended as a protocol violation; the batches it already submitted still receive their outcomes.

A producer stays attached while the ingestor’s endpoint contract holds. The contract is the ingestor as producers see it — its input schema, mode, timestamp, filter, routes, error policies, and the branch declarations its routes construct — without the routes’ FLUSH cadence. The server ends a producer, as the last event about it, with one of these reasons:

ReasonWhen
endpoint changedAn alteration changed the endpoint contract, or made the ingestor read a transport.
endpoint removedThe ingestor or its domain was dropped.
domain stoppedThe domain stopped, or a new START replaced the generation the producer attached under.
relocatedA planned ownership handoff moved the ingestor to another node.
shutting downThe serving node, or the node that executes the ingestor, is shutting down.
owner lostThe node that executes the ingestor, or the connection to it, was lost.
protocol violatedThe producer sent a batch beyond its credit.

An ended producer’s queued batches are refused as producer ended, and its admitted batches whose acknowledgement is unresolved have an unknown outcome. That holds when the node that executes the ingestor dies or stops answering, too: the node that forwards a producer’s batches to it clears each batch before it may be admitted, so after the loss it refuses every batch it never cleared as producer ended, and only the cleared ones have an unknown outcome with cause owner_lost. A new producer can be opened as soon as the ingestor runs again. An alteration that keeps the contract, such as one that changes only a route’s FLUSH, suspends admission for its hold and reopens it afterwards with every producer attached. A planned ownership handoff stops intake for good on the former owner: batches that arrive are refused as draining, admitted ones complete there, and its producers end as relocated once the new owner is committed.

Observing Client Ingestors

SHOW INGESTORS lists every ingestor of the domain with the connector and entity it reads, its codec or schema, the node that executes it, and its state. A client ingestor adds whether it admits batches, its attached producers, the batches and bytes they have outstanding, and the batches holding a slot of its window:

orders_in from=CLIENT PRODUCER schema=order_in owner=node-2 status=running admission=open producers=2 outstanding_batches=3 outstanding_bytes=2412 admitted_batches=2

DESCRIBE INGESTOR renders from: with the head’s phrase and the complete body, such as from: CLIENT PRODUCER SCHEMA order_in MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 1s ON QUIESCE SUSPEND, and schema:, mode:, owner:, admission:, producers:, forwarded producers:, outstanding batches:, outstanding bytes:, and admitted batches:. The node that executes the ingestor also exports the same counts and every answered batch as metrics; see Metrics And Observability.

External Names

An ingestor names the external entity it reads with a string literal: a topic, consumer group, subscription, queue, channel, subject, queue group, or MQTT topic filter. Nervix keeps the name exactly as written, letter case included, and sends it to the external system as written. Each entity validates its names when CREATE or ALTER applies, by the external system’s own rule where it has one:

EntityNameRule
KAFKA TOPICTOPIC1 to 249 ASCII letters, digits, ., _, and -, and neither . nor ..: the broker’s own rule
KAFKA TOPICCONSUMER GROUP1 to 255 bytes without control characters
PULSAR TOPICTOPICA local name without /, which the client places in its namespace (public/default unless the client configures one), or a fully qualified persistent://tenant/namespace/topic or non-persistent://tenant/namespace/topic. At most 1,024 bytes, without whitespace, control characters, or an empty level
PULSAR TOPICSUBSCRIPTION1 to 255 bytes without control characters
RABBITMQ QUEUEQUEUE1 to 255 bytes: the AMQP 0-9-1 limit
SQS QUEUEQUEUE1 to 80 ASCII letters, digits, -, and _, and a FIFO queue’s name ends in .fifo: SQS’s own rule
REDIS PUBSUBCHANNEL1 to 1,024 bytes of any text; a * is the character itself, and pattern subscriptions are not offered
NATS SUBJECTSUBJECTAt most 1,024 bytes of .-separated tokens, none empty, without whitespace or control characters; * stands for one token and > for the rest, each only as a whole token, and > only last
NATS SUBJECTQUEUE GROUP1 to 255 bytes without whitespace or control characters
MQTT TOPICTOPICA topic filter of 1 to 65,535 bytes without NUL; + stands for one level and # for the rest, each only as a whole level, and # only last. Levels are joined by / and may be empty

A reference to a Nervix model, such as a client, endpoint, schema, codec, or relay, stays a bare identifier.

Supported Ingestor Types

Each subsection shows a head and the body that follows its FROM; SET FROM takes the same body under the same head. DECODE USING <codec>, the timestamp, the filter, the routes, and ON GENERAL ERROR follow the body as in every ingestor statement.

HeadReadsSection
HTTP REQUESTResponses to the requests a TYPE HTTP client sends on a cadenceHTTP REQUEST
HTTP ENDPOINTRequests to a TYPE HTTP endpointEndpoints
WEBSOCKETS ENDPOINTFrames on connections to a TYPE WEBSOCKETS endpointEndpoints
WEBSOCKETS CONNECTIONFrames on the outbound connection of a TYPE WEBSOCKETS clientWEBSOCKETS CONNECTION
KAFKA TOPICA topic, through a TYPE KAFKA clientKafka
PULSAR TOPICA topic, through a TYPE PULSAR clientPulsar
RABBITMQ QUEUEA queue, through a TYPE RABBITMQ clientRabbitMQ
REDIS PUBSUBA channel, through a TYPE REDIS clientRedis Pub/Sub
MQTT TOPICA topic filter, through a TYPE MQTT clientMQTT
NATS SUBJECTA subject, through a TYPE NATS clientNATS
ZEROMQ PULLThe PULL socket of a TYPE ZEROMQ clientZeroMQ
SYSLOG LISTENERThe listener a TYPE SYSLOG client declaresSyslog
SQS QUEUEA queue, through a TYPE SQS clientSQS
PROMETHEUS QUERYA PromQL query, through a TYPE PROMETHEUS clientPrometheus
CLIENT PRODUCERBatches applications submit through their sessionsClient Ingestors

HTTP REQUEST

CREATE [IF NOT EXISTS] HTTP REQUEST INGESTOR <name>
  FROM <client> EVERY <duration>
  MODE NO ACK
  ON QUIESCE SUSPEND
    | BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
  • polls a configured HTTP endpoint periodically
  • issues the first request immediately, then follows an anchored domain-clock cadence; slow responses coalesce missed occurrences into one request at the newest due instant
  • 204 No Content is treated as no message
  • SUSPEND skips polls and therefore creates a sampling gap
  • BUFFER keeps polling on the declared cadence and drains retained poll payloads in order
  • a poll whose ON INGESTION unfolding finds the node’s extension workers full waits for them; see When The Extension Workers Are Full

Kafka

CREATE [IF NOT EXISTS] KAFKA TOPIC INGESTOR <name>
  FROM <client>
  TOPIC '<topic>'
  OFFSET BY CONSUMER GROUP '<group>'|DOMAIN
  [INSTANCES <count>]
  MODE NO ACK
    | ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
    | ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration>
        RETRY POLICY BACKOFF <duration> MAX <duration>
  ON QUIESCE SUSPEND

Kafka is the richest ingestion surface today.

Offset modes:

  • CONSUMER GROUP '<group>': Kafka manages offsets
  • DOMAIN: Nervix stores the next offset in replicated runtime state and commits partition-to-instance assignment in the Raft-backed domain schedule

Kafka client configuration is passed through to librdkafka. Nervix does not override auto.offset.reset; set it explicitly, for example to earliest, when a new consumer group must read records that may already exist.

In an ACK mode, Nervix commits a Kafka position only after its downstream acknowledgement completes. If downstream work rejects a record, the ingestor seeks back to it. A consumer-group rebalance can move the record’s partition to another group member while its batch is in flight. The ingestor then does not seek: whichever member is assigned the partition next, this ingestor included, resumes it from the committed offset, which never passes a rejected record. If Kafka cannot seek back on a partition the ingestor still holds, the ingestor stops polling, refreshes its assignment, and retries that seek. It cannot commit a later offset while the rejected position remains unresolved. Replays after a crash or assignment change may repeat records whose output was already written.

Under OFFSET BY CONSUMER GROUP, MODE NO ACK is the consumer’s auto-commit: the consumer commits the positions it has read on its own schedule, without waiting for downstream acknowledgement, so a failure can lose records whose positions were committed before their downstream work completed.

OFFSET BY DOMAIN is at-least-once. A commit records the partition’s next offset in memory. Nervix persists the offsets on the runtime state snapshot interval and whenever a node stops executing the domain, and when the ingestor has state replicas, a commit completes only after every replica has acknowledged it. Crash recovery may therefore restart from a slightly stale persisted offset snapshot. The leader watches Kafka partition topology and commits any rebalance through the strongly consistent domain schedule, which is persisted through the control-plane Raft/Fjall path. Executing ingestors consume only the committed partition assignment.

A commit or start-point reset waits for its replica acknowledgements within the native checkpoint bulk operation’s thirty-second budget. A missing acknowledgement fails the commit at that deadline; the source applies its existing rejection and retry policy. Large native checkpoints use bounded disk staging and transfer chunks, so checkpoint length is independent of the Bulk memory ceiling.

Offset recovery details:

  • persisted per-partition offsets are clamped to the partition’s currently available Kafka watermark range on reassignment
  • if a partition appears later and has no stored domain offset yet, unpaced domains start from the normal default behavior, while paced domains seek from the domain’s current logical time

Quiescing leaves a consumer group, or unassigns domain-owned partitions, and resume continues from committed offsets. Topic time and size retention continue during the pause. For consumer-group offsets that age out, the client’s pass-through auto.offset.reset setting decides whether resume uses the earliest retained record, skips to new records, or raises a general source error. Domain offsets clamp to the live watermark range and resume at the earliest retained record, exposing a gap. Other members of a user-named group may consume and commit while Nervix is absent. Quiescing does not change the auto-commit loss windows of MODE NO ACK.

Pulsar

CREATE [IF NOT EXISTS] PULSAR TOPIC INGESTOR <name>
  FROM <client>
  TOPIC '<topic>'
  SUBSCRIPTION '<subscription>'
  [INSTANCES <count>]
  MODE NO ACK
    | ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
    | ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration>
        RETRY POLICY BACKOFF <duration> MAX <duration>
  ON QUIESCE SUSPEND

Pulsar ingestors use Nervix-managed shared subscriptions. The subscription name is still required by Pulsar, but subscription type is not exposed in NSPL. Client config currently supports:

  • 'addr': broker address such as 'pulsar://127.0.0.1:6650'
  • optional 'namespace': defaults short topic names to persistent://public/default/<topic>; fully qualified topic names are accepted as-is
  • optional 'tls_ca_file': PEM-encoded CA bundle for pulsar+ssl://... connections
  • optional 'tls_allow_insecure_connection': true or false; defaults to false
  • optional 'tls_hostname_verification_enabled': true or false; defaults to true

Pulsar TLS currently supports server trust configuration only. Nervix does not yet expose Pulsar client certificate authentication.

Suspension disconnects the consumer. The durable subscription retains its backlog and redelivers unacknowledged in-flight messages. Broker message TTL, backlog quota policy, and inactive-subscription expiry remain in force. If the subscription expires, resume creates it again at the broker’s new subscription initial position and the suspension window is lost.

RabbitMQ

CREATE [IF NOT EXISTS] RABBITMQ QUEUE INGESTOR <name>
  FROM <client>
  QUEUE '<queue>'
  [INSTANCES <count>]
  MODE ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
  ON QUIESCE SUSPEND

Each instance consumes with a prefetch window of one delivery, which it acknowledges once the records decoded from it have been acknowledged through the graph. A delivery whose acknowledgement fails, or whose payload cannot be decoded, is negatively acknowledged with requeue, so RabbitMQ delivers it again and the ingestor retries it on its declared RETRY POLICY cadence.

Suspension cancels the consumer and RabbitMQ requeues unacknowledged in-flight deliveries. Other consumers on the same queue continue. Queue length, message TTL, overflow, and auto-expiry policies remain in force. If auto-expiry deletes the queue, resume reports a source error until an operator re-provisions it; Nervix never creates it.

A connection that closes while a delivery is still unacknowledged, because the broker restarted, closed it, or the network dropped it, also makes RabbitMQ requeue that delivery. The acknowledgement or the requeue the ingestor still owes it can only be sent on the connection it arrived on, so the instance reconnects on its RETRY POLICY cadence, discards what it owed, counts it in nervix_ingestor_transport_redeliveries_total, which DESCRIBE INGESTOR also prints, and consumes again. RabbitMQ then delivers the message again: a delivery that failed in the graph is retried, and one the graph had already acknowledged is ingested a second time, so delivery across a reconnect is at least once.

Each instance resolves the host of the client’s addr through the node’s configured DNS resolver every time it connects, so a changed DNS answer takes effect on the next connection, and tries the addresses it receives in order. A literal IPv4 address, or an IPv6 address in brackets, is connected to as written. Resolution, the TCP connection and, for amqps, the TLS handshake have 30 seconds together; the broker certificate must name the host addr names. A connection that fails, including a name that does not resolve, is a source error that DESCRIBE INGESTOR shows as the transient error, and the instance tries again on its RETRY POLICY cadence without acknowledging anything.

Redis Pub/Sub

CREATE [IF NOT EXISTS] REDIS PUBSUB INGESTOR <name>
  FROM <client>
  CHANNEL '<channel>'
  MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
    | DROP

Redis Pub/Sub has no retained backlog, so it cannot suspend honestly. Both modes keep reading and keep the subscriber healthy; payloads are either retained locally within the declared bound or discarded and counted. A TYPE REDIS client declares connection-pool bounds even when only ingestors reference it; see Database Client Connection Pools. Each subscription owns a separate connection. It resolves the addr hostname through the node’s asynchronous DNS resolver on initial subscribe and every resume after a disconnect; it does not consume a pooled command connection. For rediss://, TLS verifies the original hostname and uses the configured CA and optional client identity. DNS and connection failures follow the source’s existing retry cadence; Redis Pub/Sub does not replay messages missed while disconnected.

MQTT

CREATE [IF NOT EXISTS] MQTT TOPIC INGESTOR <name>
  FROM <client>
  TOPIC '<topic-filter>'
  [INSTANCES <count>]
  [SESSION CLEAN|PERSISTENT]
  [QOS 0|1]
  MODE NO ACK
    | ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
    | ACK PARALLEL MAX <n> BATCH TIMEOUT <duration> ACK TIMEOUT <duration>
        RETRY POLICY BACKOFF <duration> MAX <duration>
  ON QUIESCE SUSPEND
    | BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
    | DROP

The topic filter is a string literal; see External Names for the levels and wildcards it may hold.

Delivery constraints:

  • NO ACK defaults to SESSION CLEAN QOS 0; explicit SESSION and QOS may be supplied before MODE
  • NO ACK has no in-flight ACK window, so it never accepts MAX <n>
  • ACK modes require SESSION PERSISTENT QOS 1
  • ACK PARALLEL MAX <n> is the in-flight ACK window and BATCH TIMEOUT is the maximum partial-batch wait
  • INSTANCES <count> controls Nervix consumer parallelism; MQTT delivery always uses Nervix-managed shared subscription groups so instances do not duplicate messages

SUSPEND is valid only with SESSION PERSISTENT QOS 1. It disconnects while asking the broker to retain the session; broker offline-queue bounds and session expiry limit what can return. If the session expires, resume establishes a fresh session and the interim is lost. BUFFER and DROP remain connected under any valid session declaration. In ACK modes, Nervix acknowledges a payload when it is buffered or deliberately dropped, trading broker redelivery for the declared connected behavior.

Each instance resolves the host of its client’s addr through the node’s asynchronous DNS resolver whenever it connects: on its first subscription and on every resume after a lost connection. It dials the answers in order within the client’s five-second connect timeout, which also covers TLS and the MQTT handshake. For mqtts, TLS verifies the configured host name. A name that does not resolve is a connection failure, never a message rejection: the instance resumes again on its declared RETRY POLICY, or on the host’s reconnect cadence in MODE NO ACK. See DNS for MQTT.

NATS

CREATE [IF NOT EXISTS] NATS SUBJECT INGESTOR <name>
  FROM <client>
  SUBJECT '<subject>'
  QUEUE GROUP '<queue_group>'
  INSTANCES <count>
  MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
    | DROP

NATS ingestors use Core NATS queue subscriptions. QUEUE GROUP and INSTANCES are mandatory; use INSTANCES 1 for a single queue member.

Core NATS retains no backlog, so SUSPEND is unavailable. A quiesced instance remains a member of its queue group and keeps receiving its share; that share is buffered or discarded rather than shifted to other members.

ZeroMQ

CREATE [IF NOT EXISTS] ZEROMQ PULL INGESTOR <name>
  FROM <client>
  MODE NO ACK
  ON QUIESCE SUSPEND
    | BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
    | DROP

The source is a PULL socket. SUSPEND keeps it open but unread, so transport buffers fill and the peer’s own high-water-mark policy determines whether sends queue, block, or drop. Buffered transport data is read on resume. BUFFER and DROP keep reading locally.

Syslog

CREATE [IF NOT EXISTS] SYSLOG LISTENER INGESTOR <name>
  FROM <client>
  MODE NO ACK
  ON QUIESCE SUSPEND
    | BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
    | DROP

The client binds a UDP, TCP, or TLS listener. UDP treats one datagram as one message; TCP detects RFC 6587 octet-counted or LF-terminated framing per frame; TLS uses RFC 5425 octet counting. Messages expose optional metadata.peer_addr. The source takes no INSTANCES clause and has no application acknowledgment. Every live cluster node runs the listener; it is independent of the leader and restarts or joins with its owning node. See Syslog for the client keys, cluster lifecycle, framing, TLS, limits, and failure semantics. An invalid client setting or unreadable TLS file fails listener startup with the Syslog key or file and its underlying cause. A frame or connection failure keeps its cause in the source diagnostic. These failures do not acknowledge a message; Syslog has no application ACK.

SQS

CREATE [IF NOT EXISTS] SQS QUEUE INGESTOR <name>
  FROM <client>
  QUEUE '<queue>'
  [INSTANCES <count>]
  MODE ACK SEQUENTIAL ACK TIMEOUT <duration> RETRY POLICY BACKOFF <duration> MAX <duration>
  ON QUIESCE SUSPEND

Suspension stops polling. Messages remain only for the queue’s configured retention period. An already received message remains invisible until its visibility timeout and may then be redelivered as a duplicate; the ingestor resumes by polling past anything the service expired.

Starting the ingestor sends no request to the service. Each source instance looks its queue up before its first poll and keeps the queue’s URL from then on. The source resolves the host of its client’s endpoint through the node’s asynchronous resolver each time it opens a connection, and tries the answers in order. Every request is still signed for the configured host, and over HTTPS the service certificate must name that host. Without tls_ca_file the client trusts the platform’s native roots and follows the HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY environment variables; with it, the client trusts that CA alone and connects directly. A missing name, a silent name server or an unreachable answer fails the queue lookup, a poll, or a deletion. The AWS SDK’s standard retry mode makes up to three attempts at each such request. A failed lookup, poll or deletion is then a transient source failure that DESCRIBE INGESTOR shows and the source retries on its RETRY POLICY, so an ingestor started while the service is unreachable consumes once the service answers. A queue that does not exist fails the lookup with its name, such as SQS queue 'orders' does not exist, and is retried the same way; Nervix never creates it. A message is deleted only once it is acknowledged, so a lookup failure never removes one from the queue.

Prometheus

CREATE [IF NOT EXISTS] PROMETHEUS QUERY INGESTOR <name>
  FROM <client>
  QUERY '<promql>'
  EVERY <duration>
  MODE NO ACK
  ON QUIESCE SUSPEND
    | BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST

Prometheus samples are flattened into JSON before codec decoding. The first query becomes due after one EVERY interval. Nervix supplies each anchored due instant as Prometheus’s evaluation time; a slow query coalesces missed occurrences and returned samples are evaluated with a fresh domain execution snapshot. SUSPEND skips scrapes and leaves a sampling gap. BUFFER keeps querying at the declared cadence and delivers retained results in order after resume. A scrape whose ON INGESTION unfolding finds the node’s extension workers full waits for them; see When The Extension Workers Are Full.

HTTP ENDPOINT And WEBSOCKETS ENDPOINT

CREATE [IF NOT EXISTS] HTTP ENDPOINT INGESTOR <name>
  FROM <endpoint> MODE NO ACK
  ON QUIESCE REJECT RETRY AFTER <duration>
    | BUFFER MAX SIZE <bytes>

CREATE [IF NOT EXISTS] WEBSOCKETS ENDPOINT INGESTOR <name>
  FROM <endpoint> MODE NO ACK
  ON QUIESCE REJECT RETRY AFTER <duration>
    | BUFFER MAX SIZE <bytes>

This is how Nervix receives inbound HTTP requests and WebSocket connections on its own server-side endpoints. An HTTP ENDPOINT ingestor reads an endpoint declared TYPE HTTP, and a WEBSOCKETS ENDPOINT ingestor reads one declared TYPE WEBSOCKETS; completion after FROM offers only endpoints of that type. An endpoint of the other type is refused when the statement is applied, with a message such as requires a TYPE HTTP endpoint, but endpoint 'stream' is TYPE WEBSOCKETS. Both heads take the same body.

Quiesce is enforced independently on every serving node. With REJECT, HTTP requests and new WebSocket upgrades receive 503, while established WebSockets close with 1013. With BUFFER, bodies that fit receive 202 and drain after resume; a full buffer rejects further intake without discarding anything already acknowledged. A stopped, absent, or otherwise unable ingestor always rejects and never returns a silent 202. If several ingestors share a route, the request is accepted when at least one accepts it. It is rejected only when all reject, and Retry-After is included only when every rejecting ingestor declares REJECT.

A body whose codec runs an ON INGESTION transformation is unfolded on the node’s extension workers. When they cannot take it now, the body is rejected the same way: HTTP answers 503 without Retry-After, and an established WebSocket closes with 1013. The body was not decoded, so its sender may send it again; a body its codec rejects is still accepted and reported as a decode failure. A body BUFFER retained was already answered 202, so when it drains after resume and the extension workers cannot take it, it waits for them instead. It stays in the buffer, counted in the ingestor’s buffered records and bytes, until they unfold it, and the bodies behind it follow in order. A body its codec rejects as it drains is reported as an ingestor error and not delivered, like any other retained body that fails after its 202.

A WebSocket connection retains the route and signaling protocol selected at upgrade. Stopping or replacing its endpoint source ends that intake lifetime, including signaling data intake. During payload ingestion after signaling, the next refused payload closes the connection with 1013, even if the source has already restarted. Open a new connection to use the replacement source. A request already admitted before source ending may finish. Stopping a domain withdraws only that domain’s routes and intakes; another domain serving the same host and path continues receiving requests. A route whose configuration has been withdrawn returns HTTP 404.

Server-side endpoints are hosted under a VHOST. A plain VHOST serves HTTP and WS on the HTTP listener. A TLS-enabled VHOST serves HTTPS and WSS on the separate HTTPS listener.

TLS is configured on the VHOST itself:

CREATE IF NOT EXISTS VHOST edge api.example.com, ws.example.com
  WITH TLS tls_bundle VERSION 3;

WITH TLS requires a version. VERSION LATEST binds the highest completed version of the bundle when the statement is applied:

CREATE IF NOT EXISTS VHOST edge api.example.com, ws.example.com
  WITH TLS tls_bundle VERSION LATEST;

Either way the VHOST stores one concrete version and keeps presenting that certificate after later uploads; SHOW CREATE VHOST renders the number it bound. Rotate the certificate by uploading a new version and moving the VHOST to it with REBIND RESOURCE, which refreshes the HTTPS listener on every node without pausing ingestion. See Resources.

The referenced resource bundle must contain:

  • tls.crt
  • tls.key
  • ca.crt

WEBSOCKETS CONNECTION

CREATE [IF NOT EXISTS] WEBSOCKETS CONNECTION INGESTOR <name>
  FROM <client> MODE NO ACK
  ON QUIESCE BUFFER MAX SIZE <bytes> ON OVERFLOW DROP OLDEST|DROP NEWEST
    | DROP

This opens an outbound WebSocket connection through a TYPE WEBSOCKETS client and decodes text or binary frames. For ws and wss, each initial connection and resume resolves the URL host through the node’s asynchronous resolver. It tries the returned addresses in order within one 30-second budget for DNS, TCP, TLS and the opening upgrade. The upgrade still uses the configured URL for the HTTP Host header, TLS server name, path and query; custom TLS roots and client identity remain in effect. An expired DNS answer is refreshed on a new attempt, while a healthy connection stays open. Resolution and connection failures are transient source failures handled by the host’s existing retry lifecycle. Cancelling or stopping the source drops a pending connection attempt. Both quiesce modes continue polling the connection so keepalives and reconnect behavior remain live. A stop-reading mode is unavailable because it would starve protocol maintenance and become a disconnect.

A WEBSOCKETS CONNECTION ingestor is a single-owner ingestor. It keeps its existing primary and replica assignment through schedule recomputation while every assigned cluster node remains live, just like Kafka, MQTT, and the other client sources. Creating or changing another runtime node, a cluster node joining or being uncordoned, and a soft placement-policy change do not move its outbound session. Failover, draining its owner, an explicit RELOCATE, or a newly effective REQUIRE COLOCATION group can move it; a real move closes the former owner’s session and opens a new session on the new owner. HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER ingestors are different because their listeners execute on every live cluster node.

Outbound WebSocket clients can declare WITH SIGNALING PROTOCOL <name> after TYPE WEBSOCKETS. Server-side WebSocket endpoints can declare the same clause after TYPE WEBSOCKETS.

A signaling protocol declares a wire format and expresses the whole handshake as JAQ programs:

CREATE SIGNALING PROTOCOL bybit_subscribe
  FORMAT JSON
  ON CONNECT
  SEND JAQ '{op: "subscribe", args: ["publicTrade.BTCUSDT"]}'
  WAIT JAQ '.op == "subscribe" and .success == true'
    FAIL JAQ 'select(.success == false) | .ret_msg'
    ACCEPT DATA
  TIMEOUT 5s;

Steps run strictly in the order they are written: a step completes before the next one starts, which is what lets a request depend on an earlier reply. A SEND step writes its frames; a WAIT step blocks until every matcher it lists is satisfied, in any arrival order.

Each SEND JAQ program must produce exactly one value, sent as a frame in the declared format. JSON, YAML, TOML, XML, and RAW travel as text frames; CBOR and PROTOBUF travel as binary frames. A RAW program must produce a string, which is sent verbatim — that is how plain-text handshakes are expressed.

Every incoming frame is decoded with the same format into exactly one value, which for XML is the frame’s root element, and offered to the current step. A textual format also reads payloads delivered as binary frames, since peers commonly frame text that way; a binary format reads binary frames only. Failure matchers run first, so a rejection is never swallowed by a lenient acknowledgement matcher: a FAIL JAQ guard on the step is checked before the protocol-wide one, and the first to produce a value that is neither null nor false aborts the handshake carrying that value as the reason. Otherwise the step’s outstanding matchers are tried and the first satisfied one is consumed. Because matchers assert only the fields they name, acknowledgements carrying connection ids, timestamps, or echoed parameters still match.

A matcher that errors on a frame of a different shape counts as a non-match rather than a connection failure. On timeout, the error names the matchers the current step was still waiting on. Invalid signaling programs fail validation with the protocol, clause, step, and compiler cause. Handshake send, capture, encoding, and transport failures retain their causes in the source diagnostic. A failed handshake delivers no frame to the relay.

Data Arriving During The Handshake

Peers commonly start streaming before they finish acknowledging. ACCEPT DATA says where payload starts flowing to the relay:

ON CONNECT ACCEPT DATA                                  -- from the first frame
WAIT JAQ '<matcher>' [FAIL JAQ '<matcher>'] [CAPTURE '<program>'] ACCEPT DATA

On ON CONNECT the relay is open before anything is negotiated. On a WAIT step it opens when that step completes, which is the usual case: completing the step is what proves the peer is streaming. Because a step may list several matchers, a client can subscribe to two streams at once and open the relay when the first subscription is confirmed while the second is still outstanding.

Frames that arrive before the relay opens are not payload the graph asked for, so they are dropped rather than held. Nothing accumulates in memory, and nothing reaches a relay from a connection that was never established. Without ACCEPT DATA anywhere, payload starts flowing once the handshake finishes.

Sequencing And Captured State

CAPTURE records values from the frame that satisfied its matcher, and every program can read what has been captured so far through the $state variable:

CREATE SIGNALING PROTOCOL exchange_login
  FORMAT JSON
  ON CONNECT
  SEND JAQ '{op: "auth", key: "..."}'
  WAIT JAQ '.op == "auth" and .success' CAPTURE '{token: .data.token}'
  SEND JAQ '{op: "subscribe", token: $state.token, id: 1}',
           '{op: "subscribe", token: $state.token, id: 2}'
  WAIT JAQ '.id == 1' ACCEPT DATA
  WAIT JAQ '.id == 2'
  TIMEOUT 5s;

$state starts as an empty object; each CAPTURE must produce an object, whose entries are merged in with later values winning. A capture that fails or yields a non-object fails the handshake, because its matcher already accepted the frame. CAPTURE attaches to a single-matcher WAIT only, since it describes the one frame that matched.

A handshake may wait before it sends anything, which is how a server-initiated challenge is answered — wait for the challenge, capture it, then reply:

CREATE SIGNALING PROTOCOL challenge_response
  FORMAT JSON
  ON CONNECT
  WAIT JAQ '.challenge' CAPTURE '{nonce: .challenge}'
  SEND JAQ '{op: "answer", nonce: $state.nonce}'
  WAIT JAQ '.accepted'
  TIMEOUT 5s;

The TIMEOUT is one budget for the whole handshake, and captured state lives only for its duration — it is never logged, and does not reach ingestion.

PROTOBUF signaling declares its resource, the version it compiles, and one message type per direction:

CREATE SIGNALING PROTOCOL protobuf_subscribe
  FORMAT PROTOBUF USING RESOURCE proto_bundle VERSION 1
    CONFIG {'file' = 'signaling.proto', 'include' = '.'}
    SEND MESSAGE 'nervix.test.Subscribe'
    WAIT MESSAGE 'nervix.test.Ack'
  ON CONNECT
  SEND JAQ '{id: 1}'
  WAIT JAQ '.id == 1'
  TIMEOUT 5s;

VERSION is mandatory. VERSION LATEST binds the highest completed version when the statement is applied, and the protocol stores that number. SHOW CREATE SIGNALING PROTOCOL renders it.

Protobuf decoding is permissive: unknown fields are kept and missing fields take their proto3 defaults, so nearly any binary frame decodes as the WAIT MESSAGE type. Write matchers that test meaningful field values rather than relying on a decode failure to reject a frame.

Instancing

INSTANCES <count> is accepted by the entities that can safely scale through competing consumers or parallel pollers on one node:

  • KAFKA TOPIC
  • PULSAR TOPIC
  • MQTT TOPIC
  • RABBITMQ QUEUE
  • SQS QUEUE
  • NATS SUBJECT

NATS SUBJECT requires the clause. For the others, if omitted, the default is INSTANCES 1.

Processors

Processors consume relay records and create one or more route-local outputs. Relay names identify graph edges; expressions use language-defined scopes instead of relay-qualified fields.

Shared input and branch contracts

Ordinary multi-source processors require every FROM relay to reference the same declared schema Model. Merely having structurally equal schemas is not enough. The relays must also use the same exact named branch, or all be unbranched.

Relay inputs may optionally collect incoming Arrow batches before node execution:

FROM <relay> [WHERE <expr>], ...
COLLECT FOR <duration> [MAX BATCH SIZE <bytes>]

Without COLLECT FOR, each relay batch is executed immediately and Nervix creates no additional input buffer. With it, the duration starts when data enters an empty input collector. The node executes the accumulated batch when that timer expires or the optional maximum size is reached. Collection is independent for each source relay and concrete branch, and occurs before source-specific WHERE, node-wide FILTER WHERE, and node-specific execution. The duration is a domain-logical wait that begins only when an empty collector receives data.

This clause is available on junctions, deduplicators, reorderers, window processors, inferencers, WASM processors, and reingestors. Correlators configure it independently after each complete LEFT FROM or RIGHT FROM relay list. Ingestors cannot use it because they do not consume relays; generators are scheduled from materialized state and have no FROM relay list.

Each accepted input batch receives one domain execution snapshot. Source predicates, the node filter, construction, keys, aggregate expressions, route predicates, message-error construction, and any Roto or WASM call made for that execution all see that same instant. A batch released from COLLECT FOR starts a new execution with a fresh snapshot; the collection deadline itself remains bound to the source relay, concrete branch, domain, and START generation.

Branch-preserving processors declare one node-wide contract:

BRANCHED BY <branch>

or:

UNBRANCHED

Every input and output relay must match it exactly. State, scheduling, buffers, and materialized views are instantiated independently for every concrete branch. Only ingestors and reingestors construct branch keys.

Filters and construction

Transforming routes use the working-message model. The scopes below describe fixed points in that construction timeline.

Processing order is:

  1. A source-specific FROM ... WHERE predicate.
  2. Node-wide FILTER WHERE, when the node supports it.
  3. Node-specific work such as deduplication, inference, or ordering.
  4. Independent route construction and route WHERE evaluation.

A transforming route starts empty and may use INHERIT and ordered SET:

TO projected_notifications
  INHERIT tenant, user_id, amount
  SET amount = amount + 1,
      amount = amount * 2,
      normalized = lower(trim(input.raw))
  WHERE output.amount > 10
  FLUSH IMMEDIATE
  ON MESSAGE ERROR LOG

INHERIT ALL, INHERIT ALL EXCEPT ..., and explicit field lists require exact type and nullability matches. Sensitive values may be promoted but not downgraded. Explicit inheritance leakage is written INHERIT password LEAK SENSITIVE.

Assignments run left to right. output.field reads only an already initialized output field. message.field and a bare RHS field read the working message. Route WHERE reads the finalized route output.

Set-only routes reject INHERIT. All required fields must be assigned; omitted optional fields finalize as typed nulls. Generated inferencer and WASM values are immutable read sources and are visible independently to every route. They never initialize route outputs automatically.

Every flush-based processor route declares FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE. The NSPL Overview defines the system-owned 100 µs minimum batching window and its forced-flush exceptions. FLUSH EACH is domain-logical, while the Immediate minimum is physical; both start on the empty-to-buffered transition and remain independent for each concrete branch. A route using ON MESSAGE ERROR SEND TO buffers its error records independently and emits them on that route’s same interval or maximum batch-size boundary.

Materialized relay state

For mechanism selection, including differently keyed data, see Choosing An Enrichment Mechanism.

Normal processors declare ordered node-wide dependencies after their branch declaration:

USING MATERIALIZED STATE profiles REQUIRED WAIT
USING MATERIALIZED STATE rules REQUIRED SKIP
USING MATERIALIZED STATE preferences DEFAULT {
  theme = "system",
  alerts = true
}

State is read as relay_state.<relay>.<field>. Each relay must be materialized, in the same domain, and exactly branch-compatible. Duplicate dependencies are invalid.

Dependencies execute in written order. Real state binds immediately; DEFAULT binds a typed constant record; REQUIRED SKIP suppresses the input successfully; and REQUIRED WAIT retains the message in memory, keeps its acknowledgement open, and applies backpressure. When state arrives, resolution restarts at the first declaration. Whole-branch eviction drops both state and suspended work.

REQUIRED SKIP and REQUIRED WAIT gate a node’s input. Dependencies resolve once per batch, and every output route of that batch reads the same resolved values, including the constants bound by DEFAULT. Routes never observe a partially resolved or per-route view of state.

Defaults must initialize every required field. Omitted optional fields become typed nulls. Default expressions cannot contain field reads, side effects, or nondeterministic calls.

Junction

Junctions perform transforming fan-out:

CREATE JUNCTION route_notifications
  FROM notifications WHERE input.active
  FILTER WHERE input.amount > 0
  BRANCHED BY by_tenant
  USING MATERIALIZED STATE profiles REQUIRED SKIP
  TO accepted
    INHERIT ALL
    WHERE relay_state.profiles.enabled
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG
  TO audit
    INHERIT ALL
    FLUSH EACH 1s MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG;

DESCRIBE JUNCTION <junction> reports the stored inputs, branch and attachment contracts, route summaries, scheduled owner and replicas, and local incoming and outgoing edge metrics when those metrics exist.

Altering Junctions

ALTER JUNCTION accepts comma-separated operations and applies them in written order. Input, materialized-dependency, and route order are preserved:

ALTER JUNCTION route_notifications
  ADD FROM priority_notifications WHERE input.active,
  SET COLLECT FOR 25ms MAX BATCH SIZE 1MiB,
  SET FILTER WHERE input.amount >= 10,
  ALTER MATERIALIZED STATE profiles SET REQUIRED WAIT,
  REPLACE ROUTE TO accepted
    INHERIT ALL
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG,
  SET DETACHED;

Input operations are ADD FROM, DROP FROM, and ALTER FROM ... SET|DROP WHERE. A junction must retain at least one input. DROP FROM also removes that input’s WHERE. Collection, node filter, attachment mode, and branch selection each have SET forms; collection and filtering also have DROP forms.

Materialized dependencies support ADD MATERIALIZED STATE <relay> <policy>, DROP MATERIALIZED STATE <relay>, and ALTER MATERIALIZED STATE <relay> SET <policy>. Adding appends; altering keeps the existing order position; duplicate dependencies are invalid.

Routes support ADD ROUTE TO <relay> <full route body>, DROP ROUTE TO <relay>, and REPLACE ROUTE TO <relay> <full route body>. Adding appends and replacing keeps the route’s index. Multiple routes may target the same relay, so drop and replace require that their target identify exactly one route. A junction must retain at least one route.

Filter, per-input WHERE, construction, flush, collect, and same-target message-error policy changes are classified dynamic and hot-applied from the published schedule. Existing input collectors, buffered route output, pending materialized-state work, subscriptions, and branch-local processor state remain in place. Before publication, the runtime binds a complete typed plan for each changed processor against its installed schemas, branch contract, lookups and UDFs. Existing branches adopt that plan by typed revision identity, and branches that appear later start from the same plan. Unchanged processors retain the exact prepared programs they already hold. A flush-policy update also forces an immediate convergence pass so buffered output is evaluated against the new policy without waiting for another input.

Input/route topology, attachment, branching, dependencies, and changed error-route targets are classified entity pause. Nervix gates their source relays across the cluster, drains affected relay owner buffers, dispatch slots, and node work, and swaps only the altered junction task. Pending materialized-state work and branch presence residue are handed to the replacement before it resumes. Other nodes in the domain continue to run; sibling consumers of a gated relay can experience bounded backpressure until the gate is released.

Deduplicator

Deduplication expressions are structured and evaluated in source order:

CREATE DEDUPLICATOR unique_notifications
  FROM notifications
  FILTER WHERE input.active
  DEDUPLICATE ON input.tenant, input.event_id
  MAX TIME 10m
  BRANCHED BY by_tenant
  TO unique_events
    INHERIT ALL
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

Deduplication state is branch-local. Duplicate details are logged at debug or trace, never at info.

Altering Deduplicators

ALTER DEDUPLICATOR applies comma-separated operations in written order:

ALTER DEDUPLICATOR unique_notifications
  SET DEDUPLICATE ON input.tenant, input.external_id,
  SET MAX TIME 30m,
  SET FILTER WHERE input.active,
  REPLACE ROUTE TO unique_events
    INHERIT ALL
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

The deduplicator-specific operations are SET DEDUPLICATE ON <expr>, ... and SET MAX TIME <duration>. It also supports the junction-style input, collection, filter, attachment, branching, materialized-state, and route operations described above. A processor must retain at least one input and one route. Duplicate materialized dependencies are rejected, and drop/replace route operations require a unique target when multiple routes use the same relay.

Changing only MAX TIME, filters, per-input WHERE, collection, route construction/flush, or a same-target message-error policy is dynamic. Changing the deduplication expressions is an entity-pause operation: Nervix gates and drains the input relays, stops the old task, purges its branch-local and persisted deduplication keyspace, then starts the replacement. Input/route topology, attachment, branching, dependencies, and changed error-route targets also use entity pause.

A backup archives each branch’s keyspace with the time every key was first seen. A restore installs those keys, so a duplicate of an archived message is suppressed and each key expires MAX TIME after its archived first sighting.

Reorderer

CREATE REORDERER ordered_notifications
  FROM notifications
  BY input.occurred_at, input.sequence
  MAX TIME 30s
  BRANCHED BY by_tenant
  TO ordered_events
    INHERIT ALL
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

Ordering buffers and maximum-time release are independent per concrete branch.

Altering Reorderers

ALTER REORDERER uses the same ordered common processor operations, plus SET BY and SET MAX TIME:

ALTER REORDERER ordered_notifications
  SET BY input.priority, input.occurred_at, input.sequence,
  SET MAX TIME 10s,
  SET COLLECT FOR 25ms MAX BATCH SIZE 1MiB,
  SET ATTACHED;

SET BY <expr>, ... replaces the complete ordering expression list. SET MAX TIME <duration> changes only the maximum holding time. The shared input, filter, route, materialized-state, attachment, and branching operations have the same validation and ordering semantics as deduplicators and junctions.

MAX TIME and the shared expression/configuration-only aspects are dynamic. Changing BY uses entity pause: the old ordering buffers are force-flushed while the input relays are gated, then the node task is replaced with the new ordering program. Structural shared operations also use entity pause.

Window processor

Windows are set-only. Aggregates appear directly in route SET; there is no AGGREGATE clause. input.field is valid only inside aggregate arguments, and aggregates cannot be nested:

CREATE WINDOW PROCESSOR latency_windows
  FROM latencies
  FILTER WHERE input.latency >= 0
  WIDTH 5m DURATION
  STEP 1m DURATION
  MAX STATE SIZE 1MiB
  BRANCHED BY by_tenant
  TO latency_summary
    SET count = COUNT(input.latency),
        count_plus_one = COUNT(input.latency) + 1,
        minimum = MIN(input.latency),
        maximum = MAX(input.latency),
        tenant = FIRST(input.tenant)
    WHERE output.count > 0
    ON MESSAGE ERROR LOG;

Aggregate calls may participate in larger scalar expressions and combine with constants. Route WHERE reads the finalized output and cannot read live input rows. Windows use WIDTH and STEP, never FLUSH. Window Aggregates owns every aggregate’s types, null handling, numerical behavior, errors, and sensitivity, and Approximate Sketches owns the accuracy, memory, and expiry of the sketch aggregates.

MAX STATE SIZE <bytes> caps the live state of each concrete branch. It charges retained Arrow input and argument allocations, row handles, branch keys, and the reserved capacity of every sketch in every active pane. A row that would exceed the cap, or a row whose aggregate arguments fail, is refused before it changes the window: the refusal is logged and the row is not acknowledged. Sketches require this clause, duration WIDTH and STEP, and a bounded MAX INSTANCES ... EVICT LRU branch when branched. Thus the configured worst-case live sketch state across branches is bounded by MAX STATE SIZE * MAX INSTANCES; unbranched windows have one state. A sketch’s pane size is the greatest common divisor of its width and step, aligned to the Unix epoch. Each pane includes its starting timestamp and excludes the next pane’s starting timestamp. Stepping removes records strictly before the step cutoff; records exactly at that cutoff remain. Panes are merged only from rows still in the active window and are rebuilt after stepping. Published branch snapshots share the retained Arrow input and argument columns, seal them in bounded sections, and rebuild the same sketches from those columns when ownership moves, a node recovers or a backup is restored. A window whose retained rows exceed one 8 MiB snapshot section persists through bounded pieces on quota-owned disk rather than in memory, so the window a node persists is bounded by its own WIDTH and MAX STATE SIZE and by the node’s snapshot staging quota rather than by the bulk memory budget. Histogram delayed removals travel in bounded typed sections beside the columns. A restored window continues only under the archived window model and branch lifetime; otherwise it starts empty and the restore reports a warning.

Admission reads each consecutive run of retained rows as typed Arrow value slices with their validity bits. Counts and integer sums are exact across run boundaries. Floating-point sums and statistics merge each run’s compensated or centered result into the branch-local window, so their last rounded digits can depend on the run and lane grouping. Stepping refolds surviving runs; restoring a snapshot re-admits its retained rows.

In a window route, COUNT, SUM, FIRST, LAST, MIN, and MAX always name window aggregates. The array and vector functions with the same names apply to one ARRAY or VEC value everywhere else.

Window aggregate functions

The aggregates a window route can compute are COUNT, COUNT_IF, BOOL_AND, BOOL_OR, SUM, AVG, MIN, MAX, FIRST, LAST, ARG_MIN, ARG_MAX, VAR_POP, VAR_SAMP, STDDEV_POP, STDDEV_SAMP, COVAR_POP, COVAR_SAMP, CORR, PERCENTILE_LINEAR_HISTOGRAM, APPROX_COUNT_DISTINCT, APPROX_QUANTILE, and APPROX_TOP_K. See Window Aggregates for each one’s arguments, result, and behavior. DESCRIBE WINDOW PROCESSOR lists the shared structures that answer them.

A duration width begins at the first retained record’s low watermark and becomes due when an input watermark or the bound domain clock reaches that logical target. A paced TIME RATE therefore changes how soon a partially filled window becomes due in real time without changing its source event timestamps. Each concrete branch owns independent entries, aggregate state, and deadlines. The emitted record keeps the minimum input low watermark and uses the emission execution snapshot as its high watermark.

Inferencer

Commands that bind an ONNX model inspect its tensor metadata on the node’s bulk workers. Each inspection uses its admitted worker for ONNX initialization and optimization and has a thirty-second bound covering the worker queue, model loading, metadata extraction and session release. A full bulk queue refuses the inspection with its execution cause; releasing that capacity permits the same valid inferencer to be created. Tensor names, element types and dimensions are checked before the model is committed.

Inferencers keep the explicit tensor mapping surface. INPUTS expressions may read input; route construction cannot. Routes read immutable generated model fields, declared materialized state, and the branch:

CREATE INFERENCER score_events
  FROM features
  USING RESOURCE scoring VERSION 1
  FILE "score.onnx"
  INPUTS {
    "features" DENSE TENSOR<F32>[2] = input.features
  }
  OUTPUT SCHEMA {
    "score" DENSE TENSOR<F32>[1]
  }
  BRANCHED BY by_tenant
  TO scores
    SET tenant = branch.tenant,
        score = score
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

Every required route-output field is explicit. The source input is not implicitly inherited.

USING RESOURCE requires VERSION <n> or VERSION LATEST. The inferencer loads the model file from the one version it stores, so a later upload of the resource does not change the scoring model; see Versioning.

WASM processor

WASM routes are also set-only and execute only when the guest returns actual output data:

CREATE WASM PROCESSOR normalize_events
  FROM events
  USING RESOURCE normalizer VERSION 1
  FILE "processor.wasm"
  MAX FUEL 1000000000
  MAX MEMORY 64MiB
  BRANCHED BY by_tenant
  TO normalized_events
    SET tenant = tenant,
        normalized = normalized
    WHERE output.normalized != ""
    ON MESSAGE ERROR LOG
  ON REJECTED STATE PRESERVE
  ON GLOBAL ERROR LOG;

USING RESOURCE requires VERSION <n> or VERSION LATEST, and the processor compiles the module from the one version it stores. SHOW CREATE WASM PROCESSOR renders that version and DESCRIBE WASM PROCESSOR reports it.

Generated guest state is immutable across routes. WASM processors do not declare FLUSH; guest output and guest-requested timeouts own emission cadence. MAX FUEL and MAX MEMORY are both required, in that order immediately after FILE. Fuel bounds one logical guest operation, while memory bounds the branch instance’s Wasmtime linear memory. See WASM Processor Guests for exact accounting and failure behavior.

Guest initialization, input processing, requested-timeout callbacks, quiesce flushes, and state saves and restores each receive the snapshot selected for that operation. The guest cannot ask the engine for wall time or execute without an explicit snapshot.

ON REJECTED STATE decides what happens when a recreated guest refuses the snapshot Nervix hands it. It is optional and defaults to PRESERVE, which keeps the refused snapshot and reports the refusal; RESET opts the processor in to replacing that branch’s state lifetime once. Only the guest’s own verdict on the saved bytes reaches this policy, so no module, limit, storage or replication failure can erase computation state through it. See Recovering A Rejected Snapshot.

An ATTACHED WASM processor, the default, acknowledges an input only after the guest-state checkpoint that covers it is on the stable storage of the branch’s owner and of every replica the schedule assigns the processor; relay fan-out acknowledges the input of a DETACHED one upstream. A checkpoint that cannot get there negatively acknowledges what it covers and recreates the branch’s guest from its last completed checkpoint. DESCRIBE WASM PROCESSOR reads the committed binding, default state generation, latest coordinated reset and retained rejected-state recoveries together with the owner’s current checkpoint stages and revisions. Each reported branch uses a fixed-size opaque fingerprint rather than a branch value. Required and confirmed replica counts are shown for checkpoints captured on the current owner; a restored checkpoint whose earlier boundary is unknown reports those counts as unknown. Counts cover every active branch, while detailed branch and recovery entries are bounded. DESCRIBE WASM PROCESSOR <name> FORMAT JSON serializes the same typed state inspection used by the text output; the command also returns it as a typed client outcome in either format. See Checkpoints And Acknowledgements and WASM State And Recovery.

Reset guest state from NSPL

RESET WASM PROCESSOR <processor> STATE IN DOMAIN <domain> FOR <scope>; replaces the selected guest-state lifetime of an existing, running processor. The scope is mandatory:

RESET WASM PROCESSOR normalize_events STATE IN DOMAIN sales FOR UNBRANCHED;
RESET WASM PROCESSOR normalize_events STATE IN DOMAIN sales FOR ALL BRANCHES;
RESET WASM PROCESSOR normalize_events STATE IN DOMAIN sales FOR BRANCH VALUES { tenant = 'acme' };

Use FOR UNBRANCHED only for an unbranched processor. Use FOR ALL BRANCHES or exact FOR BRANCH VALUES fields only for a branched processor. Values must match the declared branch schema exactly. A selected branch must already be active; the command does not create one. The selected branch loses its previous guest computation state and starts from the guest’s initial state. Sibling branches keep their state and continue processing.

The normal client and CLI command path returns success only after the new generation is durable and its replacement execution is usable. Repeating the same command execution reference returns the retained outcome without creating another generation. A fresh command is a new reset. See Command Completion for retry validity and expired references.

Correlator

Correlators use explicit sides and have no default input scope:

CREATE CORRELATOR correlate_orders
  LEFT FROM orders WHERE left.active
  COLLECT FOR 10ms
  RIGHT FROM payments WHERE right.approved
  COLLECT FOR 10ms MAX BATCH SIZE 1MiB
  CORRELATE WHERE left.order_id = right.order_id
  MATCH EARLIEST
  MAX TIME 5m
  ON CORRELATION TIMEOUT DROP, DROP
  BRANCHED BY by_tenant
  TO paid_orders
    SET order_id = left.order_id,
        amount = right.amount,
        label = concat("paid:", output.amount AS STRING)
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

Relays on each side share one declared schema; left and right schemas may differ. Correlators reject FILTER WHERE, INHERIT, bare RHS field reads, input, and a separate OUTPUT block. Correlations occur only within one concrete branch.

Reingestor

Reingestors are branch-boundary transforming nodes. Each route preserves the incoming exact branch, constructs another branch, or becomes unbranched:

CREATE REINGESTOR repartition_events
  FROM events
  USING MATERIALIZED STATE profiles REQUIRED WAIT
  TO by_user_events
    INHERIT ALL
    BRANCHED BY by_user
    SET tenant = message.tenant,
        user_id = message.user_id
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

When the outgoing branch name equals the incoming branch, no branch SET is allowed and the key is preserved. A message passes its input’s FROM ... WHERE and then the reingestor’s FILTER WHERE before any route constructs it. State lookup always uses the incoming branch, never a partially constructed outgoing key. The reingestor resolves the outgoing branch before buffering the route, so each concrete outgoing branch has an independent flush interval and size boundary. Downstream branch execution receives the completed Arrow batch and does not apply a second flush policy.

Altering Reingestors

ALTER REINGESTOR applies the shared input, collection, filter, attachment, materialized-state, and route operations in written order:

ALTER REINGESTOR repartition_events
  ADD FROM priority_events WHERE input.active,
  SET COLLECT FOR 25ms MAX BATCH SIZE 1MiB,
  SET FILTER WHERE input.amount > 0,
  SET DETACHED,
  REPLACE ROUTE TO by_user_events
    INHERIT ALL
    BRANCHED BY by_user
    SET tenant = message.tenant,
        user_id = message.user_id
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

The input, collection, filter, attachment, and materialized-state forms are the same as for junctions. Route add/replace bodies include the reingestor’s required per-route BRANCHED BY ... or UNBRANCHED construction. Reingestors do not have a node-wide branching operation.

Every reingestor change uses entity pause because inputs, route construction, attachment, and dependency changes affect its relay consumers or branch-entrypoint wiring. Nervix gates both old and desired input relays, force-flushes collected input, drains node work, stops only the affected reingestor tasks, rebuilds their branch entrypoints, and reconnects their relay consumers. Other domain nodes continue to run.

Generator

Generators run from a materialized relay on a domain-clock cadence. Their routes are set-only:

CREATE GENERATOR synth_notifications
  USING MATERIALIZED STATE notifications
  EACH 100ms
  UNBRANCHED
  TO generated_notifications
    SET user_id = relay_state.notifications.user_id,
        amount = relay_state.notifications.amount
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

The first occurrence is immediate. Later occurrences remain anchored to the declared EACH schedule. If reading or generating spans multiple periods, Nervix generates once for the newest due occurrence and continues at the first future boundary. Route expressions use a fresh domain execution snapshot taken after the materialized-state read.

Altering Generators

ALTER GENERATOR supports SET MATERIALIZED STATE <relay>, SET EACH <duration>, SET BRANCHED BY <branch>, SET UNBRANCHED, and the ordered ADD ROUTE, DROP ROUTE, and REPLACE ROUTE operations:

ALTER GENERATOR synth_notifications
  SET EACH 250ms,
  REPLACE ROUTE TO generated_notifications
    SET user_id = relay_state.notifications.user_id,
        amount = relay_state.notifications.amount
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

Generator route bodies remain set-only and must contain at least one SET assignment. Drop and replace require a unique target when duplicate target relays exist, and the generator must retain at least one route.

Every generator change uses entity pause. Nervix gates its old and desired materialized source relays, lets the old timed task force-flush pending route output, waits until that task reports quiescent, then replaces only that generator task from the published schedule. The gate has a deadline expiry backstop, so a failed control-plane operation cannot leave generation wedged.

Message errors

Errors And Diagnostics owns the typed failure and structured message-error model; this section defines the route behavior and available expressions.

ON MESSAGE ERROR terminates each route. SEND TO constructs an error relay record with ordered SET assignments:

ON MESSAGE ERROR SEND TO processing_errors
SET error_reference = error.reference,
    error_code = error.code,
    operation = error.operation,
    source_id = input.id,
    attempted_total = partial_output.total

error is structured; partial_output is an all-optional view of the failed route output. Eligible handlers may also read the original input (or correlator left and right) and the exact relay_state snapshot. Error routes preserve the branch in which the failed operation executed and never construct a new key. Error-route assignments run through the same typed expression VM as ordinary SET, so deterministic scalar functions, casts, unary expressions, binary expressions, and ordered reads of earlier error-record assignments are supported. Window aggregates and side-effect functions are not available in error construction.

The error relay must declare the same exact named branch as the source route, or be unbranched when the source is unbranched. A rejected binding identifies the node, source route, error relay, and both branch declarations.

WASM Processor Guests

WASM processors are native WebAssembly modules loaded by Wasmtime. They do not use WASI. A guest module exports a small C ABI, owns one reusable linear-memory buffer, and exchanges Arrow IPC record batches with Nervix through that buffer.

Rust guests use the Rust WASM Guest SDK rather than implementing this ABI by hand. This chapter is the authoritative wire contract that the SDK implements and that guests in other languages implement directly.

WASM State And Recovery is the architecture reference for how Nervix makes guest state durable, fences its lifetimes, replaces them, and recovers them after a failure.

See Choosing An Extension Tier when deciding between builtins, operator-trusted Roto UDFs, and a WASM processor.

The runtime creates one guest instance per concrete branch. Guest state must therefore be branch-local. Do not aggregate across branch keys inside the guest.

Module Sharing And Branch Memory

A node compiles the module file a WASM processor pins once, and keeps the compiled module for as long as the schedule assigns the processor to that node, as its owner or as a replica. The owner instantiates every branch from it, and a replica that forced recovery promotes restores the branches’ guests from it, so taking over a processor never waits for its module to compile. Each branch instance still has its own Wasmtime store, linear memory, guest state, and timeout handles. Compiled code is shared. Mutable guest memory is not.

Capacity therefore grows with the linear-memory pages dirtied by each live branch, not with a full copy of compiled machine code per branch; each node the processor is assigned to holds one copy. See Capacity Planning For Branched Graphs.

WASM processor output flush is guest-controlled. CREATE WASM PROCESSOR does not accept FLUSH EACH or FLUSH IMMEDIATE; Nervix routes batches returned from nervix_process_batch, batches returned later from nervix_on_timeout callbacks requested by the guest, and batches released by nervix_flush when the host quiesces the branch, through the processor’s declared TO clauses.

Execution Limits

Every declaration requires both limits immediately after FILE, in this order:

FILE "processor.wasm"
MAX FUEL 1000000000
MAX MEMORY 64MiB

MAX FUEL is the Wasmtime instruction-fuel budget for one logical guest operation. Nervix resets the store to that budget before initialization, each input batch, each timeout callback, a quiesce flush, and each state save or load. All guest ABI calls made as part of that operation share the budget: for example, input-buffer allocation, nervix_process_batch, global error inspection, and every subsequent nervix_read_emit call are one batch-processing budget. Module instantiation also receives one budget. Fuel measures guest WebAssembly execution, not wall-clock time or host-side work.

MAX MEMORY is the Wasmtime linear-memory ceiling for each branch store. It applies to the total initial size of all module memories and every later memory.grow; choose a value large enough for the module’s initial pages and reusable ABI buffer. It does not include shared compiled code, host-side Arrow or FlatBuffer data, or Wasmtime store bookkeeping.

Fuel or memory exhaustion is handled by the processor’s node-wide ON GLOBAL ERROR policy. Nervix discards that concrete branch’s trapped guest instance; later work instantiates a replacement from the branch’s committed checkpoint. Other branch instances are independent and continue running. There is no separate WASM wall-clock timeout clause.

Before every guest operation, Nervix supplies one explicit domain execution snapshot to the WASM host. Initialization, input processing, a timeout callback, quiesce flush, and state save or load each receive the snapshot of their owning execution. Every call to nervix_domain_time_nanos() during that operation returns exactly that value. The host exposes no context-free guest invocation and the module cannot choose or read host wall time. Wasmtime epoch yielding and fuel enforcement remain physical execution safeguards and do not change guest-visible domain time.

Contract Summary

The guest imports host functions from the env module:

nervix_domain_time_nanos() -> i64
nervix_timeout_after_nanos(delay_nanos: i64) -> i64
nervix_request_state_reset() -> i32

The guest must export:

nervix_buffer_ptr() -> i32
nervix_buffer_len() -> i32
nervix_buffer_capacity() -> i32
nervix_alloc(size: i32) -> i32
nervix_init(ptr: i32, size: i32) -> i32
nervix_current_domain_time_nanos() -> i64
nervix_process_batch(ptr: i32, size: i32) -> i32
nervix_on_timeout(handle: i64) -> i32
nervix_flush() -> i32
nervix_read_emit() -> i32
nervix_dump_state() -> i32
nervix_load_state(ptr: i32, size: i32) -> i32
nervix_reset_state() -> i32

Return 0 from fallible functions on success. Return a negative code on guest rejection. Nervix treats negative codes as runtime errors and applies the processor error policy. nervix_dump_state returns the size of the snapshot it wrote, or a negative code when it cannot serialize its state; Nervix then keeps the state saved last.

Nervix resolves nervix_reset_state when it instantiates a branch but never calls it: every new state lifetime starts in a newly instantiated guest that is initialized without a restore. A guest must still export it.

nervix_load_state has two reserved codes that classify the saved state itself:

CodeVerdict
-7The saved bytes are not a snapshot envelope the guest can decode.
-8The guest decoded the snapshot envelope and refuses the application state it carries.

Only these two codes are a rejection of the saved state. A trap, an exhausted MAX FUEL or MAX MEMORY, or any other negative code while restoring is a failure of the restore itself and says nothing about the saved bytes. When an instance the branch’s owner creates fails to restore, Nervix keeps the saved state, including after a rejection, and reports each outcome as a distinct failure stage.

nervix_request_state_reset is answered by the host rather than by the guest: 0 when Nervix takes the request and -9 when the guest operation in progress cannot carry one. See Guest-requested reset for what an accepted request does.

Buffer Ownership

The guest owns one reusable byte buffer in linear memory.

  1. Host estimates the required FlatBuffer capacity and calls nervix_alloc(capacity).
  2. Guest ensures its buffer can hold capacity bytes and returns its current pointer. The buffer may move on every call.
  3. Host builds the FlatBuffer directly in that guest-memory range.
  4. Because FlatBuffers builds backwards, the completed message may occupy a suffix of the allocation. Host calls nervix_process_batch(ptr, size) with that exact range.
  5. Guest writes pending output or state back into the same buffer.
  6. Host calls nervix_read_emit() or nervix_dump_state() and reads the returned byte length from nervix_buffer_ptr().

If the capacity estimate is too small, the host finishes the message in temporary spill storage, releases its linear-memory borrow, calls nervix_alloc again with a larger capacity, and copies the finished message once. Reallocation is never attempted while the host holds a guest-memory slice. The host enforces a maximum guest buffer size. A guest must still validate the supplied pointer and size against its current buffer and reject impossible ranges.

Init Payload

nervix_init receives the BranchInit variant of the size-prefixed FlatBuffers Message union. output_schemas contains one schema per declared TO relay. A guest output envelope must name one of those relays and provide one destination-aligned column descriptor per field before Nervix applies the route-level SET and WHERE clauses. WASM routes are set-only: they reject INHERIT and UNSET, and construction does not expose message or input. Guest-generated columns form an immutable base independently visible to every route. The authoritative cross-language schema is crates/nervix-wasm-protocol/schema/nervix_wasm.fbs.

{
  "domain_name": text,
  "domain_type": text,
  "branch_key": bytes | null,
  "input_schema": {
    "name": text,
    "fields": [
      { "name": text, "ty": WasmProcessorType, "optional": bool }
    ]
  },
  "output_schemas": [
    {
      "name": text,
      "fields": [
        { "name": text, "ty": WasmProcessorType, "optional": bool }
      ]
    }
  ]
}

domain_type is currently a descriptive host string. Guests should not branch on it unless they own a strict compatibility rule for the exact Nervix version they are targeting.

branch_key is the concrete branch key of this instance as its canonical JSON text, for example {"tenant":"alpha"}. It is absent for the explicit unbranched instance of an unbranched processor; an unbranched execution is never represented by an empty key.

ProcessorTypeKind is a FlatBuffers enum with these scalar variants:

"U8" | "I8" | "U16" | "I16" | "U32" | "I32" | "U64" | "I64"
| "Bool" | "String" | "Bytes" | "Datetime" | "F32" | "F64"

Container variants use the nested ProcessorType.element table; arrays also set array_len:

ProcessorType { kind: Array, element: ProcessorType, array_len: u32 }
ProcessorType { kind: Vec, element: ProcessorType }

Datetime values are Arrow Timestamp(Nanosecond) values at the wire boundary. Bytes values are Arrow Binary arrays and retain arbitrary octets without UTF-8 conversion. Treat nanosecond integers as a boundary format and convert them to your guest’s typed timestamp representation immediately after decoding.

Treat this as configuration for the branch instance. Store what you need in guest state; reject it only when the processor cannot run correctly.

Batch Envelope

Every input, output, init, or bundled guest-state payload is one size-prefixed FlatBuffer. Its root is the Message union and its file identifier is NVWY. The ABI size and internal size prefix must agree exactly. Arrow IPC payloads are FlatBuffers byte vectors. Generated Rust and Go accessors return slices into the FlatBuffer, avoiding a deserialization copy. Crossing WebAssembly linear memory still requires writing the source bytes once. On the normal host-to-guest path, FlatBuffers writes them directly into their final guest-memory representation instead of creating and then copying a complete host-side FlatBuffer. After a guest emit, the host keeps the generated Arrow vector as a shared slice of that retained FlatBuffer.

Input envelopes have this shape:

{
  "kind": "input",
  "arrow_ipc_batch": bytes,
  "acks": AckSidecar
}

Output envelopes have this shape:

{
  "kind": "output",
  "generated_arrow_ipc_batch": bytes,
  "outputs": [
    {
      "output_relay": text,
      "columns": [
        { "kind": "input", "column_index": u32 },
        { "kind": "generated", "column_index": u32 },
        { "kind": "uninitialized" }
      ],
      "acks": AckSidecar
    }
  ]
}

Each routed output’s columns entries correspond positionally to its destination fields. An input column references the declared processor input schema by index; source and destination types and nullability must match exactly, although their names may differ. A generated column references the common generated Arrow batch by index. Index namespaces are determined by the variant. An uninitialized descriptor is encoded as FlatBuffers ColumnSource.Uninitialized with canonical column_index = 0. It has no input or generated-pool index; its type and nullability come from the positionally aligned destination field, and its row count comes from acks.rows.len().

generated_arrow_ipc_batch is either an empty byte string or exactly one Arrow IPC stream containing one schema and one uncompressed record batch. The stream ends with the end-of-stream marker, or where its record batch message ends. Its schema may declare only the Arrow types Nervix carries: integers of 8 to 64 bits, 32- and 64-bit floats, booleans, UTF-8 text, binary, nanosecond timestamps, and lists and fixed-size lists of those, none dictionary-encoded. Every message must open with Arrow’s continuation marker, 0xFFFFFFFF, and declare metadata and body lengths that lie inside the stream. Its record batch must declare exactly the field nodes and buffers those fields take, every buffer inside the message body, a validity bitmap long enough for the nulls it counts and offsets buffers that hold whole offsets. Nervix checks all of this before it reads the batch or allocates anything from a declared length, and a stream that fails the check is invalid generated Arrow IPC. The empty byte string is the only valid empty generated pool; do not encode a zero-column Arrow stream. When present, generated schema field names must be empty. Nervix compares every other field property with each referencing destination field, including data type, nullability, timestamp units and timezones, nested types, fixed lengths, and semantic field metadata. Destination schemas remain authoritative for field names.

Generated columns are immutable and reusable. Several routes, or several fields in one route, may reference the same generated index. Nervix decodes the common batch once and clones the same ArrayRef; it does not copy, decode, or serialize that column again. Every generated pool column must be referenced at least once.

Rows are positional within one output group. For routed row R, a generated reference reads generated array row R, while an input reference reads the host input row selected by that routed row’s source_token. Every route that references the generated pool must therefore have the pool’s row count and use the same generated row order. Routes needing different generated counts, ordering, guest-side filtering, or duplication must be queued as separate output envelopes and returned by separate positive nervix_read_emit() calls. Generated indexes never cross an envelope boundary. Route-level SET and WHERE processing occurs after this materialization and does not prevent sharing.

Uninitialized columns pass through route processing as explicit VM state. Any expression read materializes a typed all-NULL column before applying ordinary NULL semantics, so coalesce(uninitialized, value) yields value and is_null(uninitialized) yields true. At the node boundary, an uninitialized optional field becomes typed NULLs, while an uninitialized required field is a schema error. The marker is not part of a Nervix relay schema and never crosses a relay, IPC, persistence, interconnect, or node boundary. An uninitialized descriptor does not by itself require source_token.

The ACK sidecar is:

{
  "rows": [
    { "tokens": [u64, ...], "source_token": u64 | null }
  ],
  "acked": [
    { "tokens": [u64, ...] }
  ],
  "nacked": [
    { "tokens": [u64, ...], "reason": text }
  ],
  "message_errors": [
    { "tokens": [u64, ...], "reason": text }
  ]
}

For every host input row, Nervix issues one token and sets both tokens and source_token to that token. Preserve the complete row sidecar when filtering or enriching. source_token is an optional FlatBuffers scalar and is absent only for a generated row that has no input source.

If an output envelope contains any input column, every output row must have a non-null, live source_token, and that token must occur in the row’s tokens. It selects the retained host input row used for every referenced column in that output row. It also selects the original record exposed through route expressions such as input.field. A source token does not add an ACK use.

If the guest drops an input row, put that row’s token set in acked. To fail it directly without invoking the processor message error policy, put it in nacked with a reason.

Use message_errors for per-message guest errors that must be handled through ON MESSAGE ERROR (IGNORE, LOG, or SEND TO). Global errors are not part of the ACK sidecar because they are guest/node state, not message lineage.

Guests may expose this optional global-error channel:

nervix_global_error_ptr() -> i32
nervix_global_error_len() -> i32
nervix_clear_global_error() -> i32

If any of these exports exists, all three must exist. After host calls into the guest (nervix_init, nervix_process_batch, nervix_on_timeout, nervix_flush, and emit reads), and after a negative result from nervix_dump_state, nervix_load_state, or an emit read, it checks nervix_global_error_len(). A positive length means nervix_global_error_ptr() points at UTF-8 error bytes. The host reads the bytes, calls nervix_clear_global_error(), and reports them as the reason for the failure. Failures of initialization, restore, input processing, timeout callbacks, quiesce flush, and output validation apply ON GLOBAL ERROR; a failure to save, persist, or replicate guest state is always reported. Wasmtime call failures and traps are also handled as global processor errors.

The guest decides lineage; the host performs the actual ACK/NACK operation. Tokens are host-local hot-path capabilities. They are valid only while the current host instance is alive, and they are never persisted.

Each routed output retains its own sidecar because routes may carry different row lineage or terminal decisions. The host counts carried token uses across all routed outputs in the callback, so fan-out completes the original input ACK only after every downstream delivery completes. A terminal decision may occur only once across the validated callback.

The sidecars must be internally consistent:

  • rows.len() is the routed output row count and must equal the generated pool row count when that route references a generated column.
  • an uninitialized descriptor uses rows.len() directly and must use canonical column_index = 0.
  • every token in rows, acked, nacked, and message_errors must come from the current host-provided input sidecar.
  • a token may be carried into output rows, or terminally acked/nacked, but not both.
  • a token may have at most one terminal decision across acked, nacked, and message_errors.
  • a non-null source_token must be live and carried in its output row.

It is valid to carry the same input token into more than one emitted row, routed output, or output group. The host keeps attached child guards and resolves the original guard only after all derived deliveries complete. All output groups from one callback are validated together; no output is dispatched and no terminal decision is applied if any routed output is invalid.

For input references, Nervix retains the original input Arrow batch while its tokens are live. Identity selections reuse the source ArrayRef, contiguous selections use a buffer-sharing slice, and filtered, reordered, duplicated, or cross-batch selections use host-side Arrow kernels. The guest never has to serialize unchanged field values back to the host.

All table and vector fields are required, including empty vectors. Unknown fields are ignored for FlatBuffers schema evolution; unknown union or enum variants, missing required fields, a wrong identifier or size prefix, trailing bytes, invalid column counts, bad source tokens, malformed or trailing Arrow IPC, and exact-schema mismatches are global processor errors. Short headers are rejected before identifier access, including a complete size prefix whose declared length matches a buffer too short to hold the root and identifier. Empty output groups, out-of-range or unreferenced generated columns, and generated row-layout mismatches are also rejected. Nonzero uninitialized column indexes are rejected. CBOR and per-output generated-column envelopes are not supported. There is no format negotiation, legacy decoder, or fallback path. Rebuild every guest for this FlatBuffers contract.

State

Use branch-local guest state for the durable computation state a recreated instance needs to continue: counters, aggregates, open windows, or whatever else the guest derives from the input it has already accepted.

Nervix saves guest state through nervix_dump_state at the end of every guest callback, as the checkpoint described below, and when it checkpoints a branch for an ownership handoff. It restores the committed checkpoint through nervix_load_state, after nervix_init, when the branch instance is recreated.

A save that fails — a negative nervix_dump_state code, a trap, or an exhausted limit — is reported as a state snapshot failure and fails the checkpoint. Nervix never replaces the committed checkpoint with the result of a failed save, so a recreated instance restores the last checkpoint that completed. A save is bounded by the host’s 64 MiB guest buffer limit; a larger state fails the save the same way.

Guest state holds computation state only. Everything an instance uses to execute belongs to that instance and is never saved:

  • input the guest still buffers, together with its ACK tokens, row sidecars, and input-column references;
  • output groups the guest has not emitted yet;
  • timeout handles, which the host issues per instance;
  • error state the guest latched after a failed callback.

A recreated instance therefore starts without buffered input, pending output, error state, or pending timeouts. A guest releases buffered input from nervix_flush before a handoff, and a guest whose restored state needs a timer requests it again from its next input or timeout callback.

Keep nervix_load_state strict about what it restores. Save what a restore needs to validate the state, such as the branch configuration it was taken under, and reject state that does not match the instance it is handed to. The Rust SDK’s snapshot envelope permits a restored domain name to change if the domain type, branch key, and input and output schemas match. Guests with their own snapshot format decide whether a domain name change is valid for their application state.

A concrete branch evicted for its TTL or instance limit stops without a quiesce flush, but its checkpoint stays: when the same branch key appears again, its new instance restores the last checkpoint that branch committed.

A zero-length nervix_dump_state result saves no state at all: the next instance is initialized without a nervix_load_state call. A guest whose computation state can be empty therefore wraps it in an envelope, as both example guests do with GuestSnapshot, so that empty computation state is still restored as state.

A restore that fails leaves the saved state in place. The next input for that branch instantiates the guest again and hands it the same saved state, so a guest that rejects its state keeps being reported with the same saved state revision instead of silently starting fresh. Rejecting the state with -7 or -8 is the only outcome that classifies the saved bytes as unusable; keep nervix_load_state strict and use those codes only for a verdict on the state. A processor can opt in to discarding a lifetime its guest rejected; see Recovering A Rejected Snapshot.

State Generations

Every branch’s guest state belongs to a generation, one lifetime of that state that the committed schedule names. A save belongs to the generation that was current when the guest saved it, and Nervix persists, replicates, serves, restores, and recovers saved state only in the generation the committed schedule names for its branch. A snapshot of an earlier generation never becomes current again, whatever revision it carries: a former owner that restarts with older saves, a replica that missed a transition while it was offline, and a handoff prepared before the transition are all fenced out.

A new WASM processor starts every branch in its first generation, and a planned ownership handoff keeps the generation. When an owner is lost and its state is recovered without it, the forced recovery starts a new generation for every branch of the processor in the same schedule publication that names the new owner. The checkpoint the recovery selects for a branch continues in that generation; a branch with no surviving checkpoint of the generation being replaced starts fresh, and SHOW CLUSTER STATUS reports it as a wasm_processor reset. A recovery whose preparation fails, including one whose new owner cannot restore a staged checkpoint and one that runs past its five-second budget, starts every branch fresh the same way, whatever ON REJECTED STATE declares; see Forced Recovery. Applying the same committed schedule again, after a restart or a rebuild, publishes nothing new.

A generation also belongs to the module binding it was published for. A model change that binds another resource, version, or module file, such as a REBIND RESOURCE, starts a new generation for every branch in the schedule publication that commits it, so the new module starts every branch without guest state. Changing only the execution limits, the global error policy, or the rejected-state policy leaves the binding alone and keeps every branch in the generation it has. The node compiles the candidate module before the batch is published, so a module it cannot compile rejects the whole change while the previous binding and its saved state are still the current ones. See Resource Versions And Bindings.

Coordinated reset

The control plane can replace the state lifetime of the explicit unbranched instance, one concrete branch, or every branch of a WASM processor, and a guest can ask for the lifetime of its own branch to be replaced through the same operation. One-branch reset leaves every sibling generation and task intact. All-branches reset advances the processor’s shared generation and also covers branches that currently exist only as stored checkpoints, so a later appearance cannot restore their old state.

The owner first stops selected live instances at their callback boundary, initializes fresh guests without calling nervix_load_state, and captures each fresh nervix_dump_state result. No durable identity has changed yet; an initialization failure restores the stopped instances. The control plane then publishes the new generation as Publishing. From that point, the old checkpoints are unreachable and the operation cannot roll back. The owner durably publishes the selected branch lifecycle, writes the first checkpoint of each fresh guest, and waits for the assigned replicas before the schedule becomes Ready and input resumes.

Accepted input drains before the stop boundary, and its buffered output and acknowledgements are finalized once. Work still suspended on materialized state is discarded and negatively acknowledged. Timeout handles and remaining guest-local capabilities are not copied into the fresh lifetime. A pre-publication failure reports an ordinary reset failure with the old lifetime still usable. A post-publication failure reports a committed but unusable reset; retrying the same stable request reference resumes its initial checkpoints without advancing the generation again. Restart, owner failover, and an offline replica follow the same published generation, and no stale former owner can make an older checkpoint current.

A checkpoint of an instance whose generation or ownership has already moved on is refused before anything is saved, persisted or replicated, and is reported as a state authority check failure. Checkpoints are written to the node’s state store on its storage workers, never on the worker that runs the guest.

ACK tokens are separate from guest state. They are host-local hot-path runtime capabilities and are not persisted or replicated. If ACK state is lost with a processor owner, the upstream ingestor reacts according to its delivery mode and retry policy.

Guest-requested reset

A guest asks for the state lifetime of the branch it runs in to be replaced by calling nervix_request_state_reset() from a callback. The host performs the replacement after the callback returns and never calls back into the guest to do it. The request selects nothing: it is scoped to the calling branch and to the generation that branch is running in, so a guest can never name another processor, domain, branch, or generation. Every accepted request is routed through the same coordinated reset described above, so it has the same durability, replica, fencing and recovery guarantees as an operator’s. DESCRIBE WASM PROCESSOR reports it with the reason GUEST, and its FORMAT JSON inspection carries its request reference, which begins with wasm-guest-reset..

Only the callbacks that end with a checkpoint own uncommitted effects a reset can discard, so only those admit a request: an input batch, a timeout callback, and a quiesce flush. Every other operation, including initialization, saving, and restoring, refuses the request and schedules nothing.

An accepted request is terminal for the callback that made it:

  • Output that callback emitted is discarded and never dispatched.
  • Every input the branch holds, including input the guest still buffers, is negatively acknowledged, so a source with acknowledgements redelivers it into the new lifetime.
  • No checkpoint follows, because the state that callback would save is the state the reset discards.
  • The instance is dropped with its pending timeouts, and the branch accepts nothing more until the new lifetime is durable. It then resumes through a fresh instance restored from that lifetime’s initial checkpoint, which the failed callback can never observe.
  • Effects earlier callbacks published stand, because their checkpoints completed; their acknowledgements settle exactly once.

A callback that asks and then fails is still asking, and asking repeatedly — several times in one callback, or from several callbacks of one lifetime — replaces that lifetime exactly once and initializes one fresh guest. Acceptance says only that the host took the request; it is never proof that a new lifetime became durable. A reset that fails is reported as a runtime error naming the processor and domain, the branch keeps the lifetime the reset could not replace, and the guest asks again from its next callback.

Clearing the guest’s own fields is an ordinary application-state mutation that the next checkpoint saves, and needs none of this.

Recovering A Rejected Snapshot

ON REJECTED STATE is the processor’s answer to a guest that refuses the snapshot it is handed. It is written between the last TO clause and ON GLOBAL ERROR, it is optional, and it has two values:

  • PRESERVE, the default. The refused snapshot stays exactly where it is. Every later instance of the branch is handed the same bytes and reports the same failure, so the state is still there when an operator decides what to do with it — including running a coordinated reset by hand.
  • RESET. Nervix replaces that branch’s state lifetime once, through the same coordinated reset, and the branch resumes on the fresh lifetime it publishes.

Only the guest’s own verdict — -7 or -8 from nervix_load_state — reaches this policy. A module that does not compile, an initialization the guest refuses, an exhausted MAX FUEL or MAX MEMORY, a trap while restoring, and every storage, replication and state-authority failure leave the saved bytes as usable as they were, so the policy never discards state for any of them. Losing the branch’s owner is the one path that can recreate state without a verdict; see Forced Recovery.

One refused lifetime is worth exactly one recovery attempt. Nervix records that the attempt was spent before it resets anything, so the budget survives whatever happens next:

  • The reset publishes a fresh lifetime and the branch resumes on it. The branch has moved on, and a guest that later refuses that lifetime is a new failure with an attempt of its own.
  • The reset fails before it publishes. The previous lifetime — refused bytes and all — is still there, and the spent attempt means the branch reports its refusal from then on instead of resetting again on every arriving record.
  • The reset is committed but its fresh lifetime is not usable. Admission for the branch stays fenced under the published generation, exactly as it does for an operator-requested reset that ends the same way.

The record is control-plane state, so a process restart, a leader change and an owner change all read the same spent attempt. Nothing about the refused guest’s memory is kept: the record names the branch by its opaque fingerprint, the generation that was refused, which of the two verdicts the guest gave, and what the attempt achieved. DESCRIBE WASM PROCESSOR reports it as a rejected state recovery line, beside the rejected state policy the processor declares.

Checkpoints And Acknowledgements

Every guest callback that runs — an input batch, a timeout callback, and a quiesce flush that emits output — ends with a checkpoint of the guest instance it leaves behind, and Nervix acknowledges an input as successful only after the checkpoint that covers it has completed. A branch handles one callback at a time, in this order:

  1. Nervix runs the callback with its execution snapshot and reads every output group the guest emits.
  2. It validates the callback’s output as a whole. Output that fails validation dispatches nothing, and every input the branch holds goes through ON GLOBAL ERROR instead.
  3. It dispatches the output rows to their relays, negatively acknowledges the nacked inputs, and routes message_errors through the route’s ON MESSAGE ERROR policy. It holds back the success of every other input the callback decided: inputs carried into output rows, acked inputs, handled message errors, and inputs that ON GLOBAL ERROR IGNORE accepts after a failed callback. Output reaches downstream nodes, and can reach sinks, while its checkpoint is still on its way; only the acknowledgement waits. Until the checkpoint ends, Nervix reports the inputs it holds back alive to their source, so a source’s ACK TIMEOUT shorter than the checkpoint does not deliver them again.
  4. It saves the guest with nervix_dump_state. The saved state is captured and stamped with the branch’s next revision.
  5. It writes that revision to the node’s stable storage and waits until the storage is synchronized. The state is then locally durable. Checkpoints taken by different branches at the same time share one synchronization.
  6. When the committed schedule assigns the processor replicas, every replica fetches the revision, writes it to its own stable storage, synchronizes it, and only then acknowledges it. Nervix waits until every assigned replica has, and the state is then replica-confirmed. A processor with no assigned replica, which is always the case with a replica count of 0, completes its checkpoints on local storage alone.
  7. It commits the checkpoint, which becomes the state a recreated instance restores, and releases the held acknowledgements. An input’s acknowledgement then succeeds as soon as every delivery the callback made for it has succeeded as well.
  8. Only then does the branch run its next callback.

Only an ATTACHED processor, the default, holds its source’s acknowledgements this way. A DETACHED processor receives its input after relay fan-out has acknowledged it upstream, so no source acknowledgement waits for its checkpoints, although its guest state is checkpointed the same way.

The replicas a checkpoint waits for are the ones the committed schedule assigns when it is captured, and SHOW CLUSTER STATUS lists them. While it waits, the checkpoint follows the schedule: a replica the schedule replaces is replaced in the wait as well. It never completes with fewer replicas than it was captured for, and never after the branch’s state generation or owner has moved on. A later checkpoint uses the replicas assigned when it is captured.

The whole checkpoint, from the save to the last replica’s acknowledgement, must complete within ten seconds. Branches are independent: one branch waiting for its checkpoint delays no other branch’s callbacks.

When A Checkpoint Fails

A checkpoint fails when the guest cannot save (state snapshot), when this node cannot write or synchronize it (local state persistence), when an assigned replica does not hold it within the deadline or the schedule assigns fewer replicas than it was captured for (state replication), and when the branch’s state generation or ownership has moved on (state authority check). Each failure is reported as a runtime error at its stage, whatever the processor’s ON GLOBAL ERROR policy, and then:

  • Every input the callback decided, and every input the guest still buffers, is negatively acknowledged. A source with acknowledgements redelivers them.
  • The committed checkpoint stays where it was.
  • The guest instance is discarded with the state nothing committed, together with its buffered input, pending output and timeouts. The branch never continues from that state: its next input instantiates the guest again from the committed checkpoint.

DESCRIBE WASM PROCESSOR reads the scheduled binding and state lifetime beside a read-only sample of the owner’s current checkpoints. The reset line reports its PUBLISHING or READY phase, reason, scope, and selected generation. PUBLISHING remains fenced even if its initiating command has returned an error; READY means the new initial checkpoint reached its durability boundary and the schedule admitted the new lifetime. Reset readiness reports RESETTING while the selected checkpoint is unfinished, AWAITING_USABLE_EXECUTION when a selected single-branch checkpoint is confirmed but its scope is still fenced, and READY when the ready schedule is published. An all-branches reset remains RESETTING until ready publication because a sample of currently materialized branches cannot establish that every selected branch completed. Rejected-state recovery lines retain the original guest rejection class and the attempt’s outcome, including a failed fresh initialization.

For each current branch, the description gives its generation, committed and latest revision, checkpoint stage, and required and confirmed replica counts. CAPTURED has not reached local storage, LOCALLY_DURABLE reached it but has not completed the replica boundary, and REPLICA_CONFIRMED completed its boundary. A restored checkpoint whose earlier replica boundary cannot be reconstructed reports replica counts as unknown rather than claiming confirmation. FAILED leaves the previous committed revision current until another checkpoint succeeds. When the failure occurred before capture, the latest revision and replica counts are absent because no new checkpoint exists. Branch identities are fixed-size opaque fingerprints; guest bytes and branch field values never enter the inspection. Counts cover all current branches, while detailed branch and recovery entries have a fixed report limit. FORMAT JSON serializes the same typed inspection; it is also returned to clients beside either rendering. JSON is the state inspection itself, so an unscheduled processor reports null rather than inventing runtime progress.

A failed synchronization of a node’s stable storage is not retried. The operating system may have dropped the writes it could not flush, and the database refuses to synchronize again, so every later checkpoint on that node fails until the node restarts.

Recovery, Replay And Duplicates

A checkpoint is not a transaction that spans the guest, its output and external sinks. It guarantees exactly one thing: once a source has received a successful acknowledgement for an input, the guest state that reflects the input is on the stable storage of the branch’s owner and of every replica the checkpoint was confirmed by. What that means differs for guest state, for the source, and for sinks.

  • Guest state. A restarted node restores each branch from the newest checkpoint on its own storage. After an owner is lost, forced recovery continues each branch from the newest surviving checkpoint of its generation when the new owner prepares the recovery, and with replicas, a replica holds every checkpoint whose acknowledgements were released. Without replicas, only the owner’s own storage holds a branch’s checkpoints, so recovering without that node resets the branch.
  • Source replay. An input whose acknowledgement is withheld — its checkpoint failed, its node stopped first, or the guest still buffers it — is redelivered by a source with acknowledgements and lost by one without. The state the branch continues from can already reflect a redelivered input: a guest counts buffered input as it accepts it, a checkpoint that failed at its replicas stays on the owner’s storage, and a replica can hold a checkpoint whose acknowledgement it sent too late. A guest therefore applies a redelivered input again unless it recognizes the input itself.
  • External delivery. Output is dispatched before its checkpoint completes, so a sink can emit output whose input is later negatively acknowledged and redelivered, and the redelivered input produces that output again. Delivery through a WASM processor is at least once; see ACK Semantics And Effective Delivery.

Where the checkpoint of the last callback had got to when its owner stopped decides which state the branch continues from. In every window the callback’s output may already have been dispatched, and its inputs were not acknowledged, so a source with acknowledgements redelivers them:

The owner stoppedThe branch continues fromA redelivered input
After dispatching output, before the guest savedThe previous checkpointIs applied once more to state that never reflected it, and can emit its output again
After the save was captured, before it reached stable storageThe previous checkpointIs applied once more to state that never reflected it, and can emit its output again
After it reached the owner’s stable storage, before every replica confirmed itOn restart, this checkpoint; after owner loss, whichever of the two checkpoints the promoted replica holdsIs applied again to state that may already reflect it
After every replica confirmed it, before the acknowledgements were releasedThis checkpoint, on restart and after owner lossIs applied again to state that already reflects it

A source that keeps redelivering an input while no owner accepts it can deliver it to the recovered branch more than once, so the number of times a redelivered input is applied is not fixed. A source without acknowledgements delivers nothing again, and the input is lost in the first two windows. Failure At Each Boundary extends this account to the control plane’s authority boundaries.

A branch holds back the acknowledgements of one callback at a time, for no longer than the checkpoint deadline. Stopping a node ends a branch still waiting for its checkpoint after the branch’s stop grace, and whatever it held is negatively acknowledged when the node’s drain ends.

Timeouts

A guest can call nervix_timeout_after_nanos(delay) while processing. The request is anchored at that operation’s supplied domain snapshot, and delay is a logical duration. The host returns a monotonically increasing handle for that branch instance. When the same bound domain and START generation reach the requested instant, the host calls:

nervix_on_timeout(handle)

After any successful timeout callback, the host repeatedly calls nervix_read_emit() and forwards every emitted output group. Shared generated columns work identically in timeout output. Input-column references remain valid while their source token is live.

Timeout handles belong to the branch instance that requested them. A recreated instance starts without pending timeouts and never receives a handle issued to an earlier instance. A guest-requested reset drops the instance that asked together with every timeout it had requested, so no old handle survives into the new lifetime.

Quiesce Flush

When Nervix quiesces a branch — to hand it to a replacement node during an ENTITY_PAUSE alteration or an ownership move, to pause its domain, to stop it for a coordinated reset, or to shut it down — it calls:

nervix_flush()

Emit every buffered output group during this call; the host drains them with nervix_read_emit() exactly as it does after a timeout, and a flush that emitted output is completed by a checkpoint like any other callback. A flush that emits nothing decides no input, so no checkpoint follows it. A guest that keeps input past nervix_flush leaves it unacknowledged until the branch resumes, so a guest that buffers between calls must release that buffer here instead of waiting for more input or for its next timeout. A guest that never buffers can return success without emitting.

This is what lets a WASM processor participate in ENTITY_PAUSE like every other stateful node: the host pauses intake at the relay gate, asks the guest to flush, checkpoints it, and restores that checkpoint into the replacement instance.

Rust SDK

The nervix-wasm-sdk crate owns the complete exported ABI surface — the reusable buffer, envelope encoding and decoding, the emit queue, the global-error channel, panic conversion, error-state latching, and GuestSnapshot plumbing — and exposes the typed Processor trait instead. See Rust WASM Guest SDK for installation and usage, and examples/wasm-processors/rust-guest for the complete reference guest.

Go Sketch

The prototype Go guest is in examples/wasm-processors/go-guest and is built with TinyGo:

//go:wasmimport env nervix_domain_time_nanos
func hostDomainTimeNanos() int64

//go:wasmimport env nervix_timeout_after_nanos
func hostTimeoutAfterNanos(delayNanos int64) int64

var buffer []byte
var pendingEmit [][]byte

//export nervix_alloc
func nervixAlloc(size int32) int32 {
    if size < 0 {
        return -1
    }
    if cap(buffer) < int(size) {
        buffer = make([]byte, int(size))
    } else {
        buffer = buffer[:int(size)]
        clear(buffer)
    }
    return int32(uintptr(unsafe.Pointer(&buffer[0])))
}

//export nervix_process_batch
func nervixProcessBatch(ptr int32, size int32) int32 {
    input, code := readBufferRange(ptr, size)
    if code != 0 {
        return code
    }
    envelope, code := decodeEnvelope(input)
    if code != 0 {
        return code
    }
    _ = hostTimeoutAfterNanos(1_000_000_000)
    pendingEmit, code = buildOutputEnvelopes(envelope)
    return code
}

//export nervix_read_emit
func nervixReadEmit() int32 {
    if len(pendingEmit) == 0 {
        return 0
    }
    next := pendingEmit[0]
    pendingEmit = pendingEmit[1:]
    buffer = append(buffer[:0], next...)
    return int32(len(buffer))
}

The Go guest uses bindings generated from nervix_wasm.fbs. FlatBuffers byte vectors are exposed as slices backed by the input buffer, so parse Arrow IPC directly from those slices while the ABI buffer remains alive. Guest-side domain structs should stay explicit before they are passed to the generated builders:

type ackSidecar struct {
    Rows          []outputRow
    Acked         []ackTokenSet
    Nacked        []nackSet
    MessageErrors []messageErrorSet
}

type outputRow struct {
    Tokens      []uint64
    SourceToken *uint64
}

type ackTokenSet struct {
    Tokens []uint64
}

type nackSet struct {
    Tokens []uint64
    Reason string
}

Use the generated MessagePayload union and enum values rather than string tags. In particular, an input reference to column zero must set the generated column index to 0. The same filter contract applies in Go: preserve the complete row sidecar for rows you emit, add dropped rows to Acked, and add rejected rows to Nacked with a reason.

Build with TinyGo’s non-WASI wasm-unknown target; standard Go only produces js/wasm and wasip1/wasm modules. TinyGo compiles against the standard library of the Go toolchain that go selects, so select the toolchain pinned in the guest’s go.mod explicitly; automatic selection keeps a newer host Go that TinyGo may not support:

cd examples/wasm-processors/go-guest
GOTOOLCHAIN="$(sed -n 's/^toolchain //p' go.mod)" tinygo build \
  -target=wasm-unknown \
  -scheduler=none \
  -opt=z \
  -panic=trap \
  -no-debug \
  -o nervix_wasm_processor_go_guest.wasm \
  .

Common Mistakes

  • Do not use WASI imports. The host does not provide WASI.
  • Do not keep global mixed-branch state. Each module instance is branch-local.
  • Do not invent ACK tokens. Only carry, ack, or nack tokens that arrived in the input sidecar.
  • Do not omit source_token when preserving an input-derived row.
  • Do not serialize a generated column separately for every route; place it once in the common generated batch and reference its index.
  • Do not give common generated schema fields destination names; their names must be empty.
  • Do not emit an encoded zero-column Arrow stream; use an empty byte string for an empty pool.
  • Do not leave generated pool columns unreferenced or reuse a pool for routes with different generated row layouts.
  • Do not rebuild unchanged input fields in the guest; emit input-column references.
  • Do not ack/nack a token and also carry it into an emitted row.
  • Do not silently accept an init payload whose schema does not match what the guest implements.
  • Do not persist guest state in a custom host-facing format unless load_state can reject bad bytes cleanly.
  • Do not save buffered input, ACK tokens, pending output, timeout handles, or latched error state in guest state; save only durable computation state.
  • Do not call host ACK/NACK directly. The guest only reports lineage and decisions in the sidecar.

Failure Diagnostics

Nervix reports a module that fails to compile, and every failure of a branch instance’s guest operations and saved state, in one shape:

wasm processor '<processor>' <stage> failed (<branch>, resource '<resource>' version <version> file '<file>'[, export '<export>'][, saved state revision <revision>]): <cause>

<branch> is branch followed by the lowercase hexadecimal fingerprint of the concrete branch’s key, or unbranched. The fingerprint is the one DESCRIBE WASM PROCESSOR prints beside the branch’s checkpoint, and a diagnostic never contains the key’s field values, which the branch schema may declare SENSITIVE. <export> names the guest export whose call failed, or the export that runs the failed operation. <revision> appears when the failure involves saved state: the revision being restored or the revision being persisted. <cause> is the complete chain of failures below the stage, ending with the guest’s own reason when it put one on the global-error channel. A module that fails to compile is reported without a branch or export. Diagnostics never contain guest payloads or saved state bytes, and a diagnostic raised on another node reaches the session unchanged.

StageWhat failed
module compilationResolving, reading, or compiling the pinned module file.
instantiationCreating the branch store, instantiating the module, or resolving its exports.
initializationnervix_init. The saved state revision appears when the instance was about to restore state.
snapshot envelope decodingnervix_load_state rejected the saved bytes with -7.
application state restorationnervix_load_state rejected the saved application state with -8.
state restorenervix_load_state failed without a verdict: a trap, an exhausted limit, or another negative code.
batch processingnervix_process_batch and the emit reads that follow it.
timeout callbacknervix_on_timeout and the emit reads that follow it.
quiesce flushnervix_flush and the emit reads that follow it.
output emissionAn emitted envelope the host cannot decode, or output that fails validation.
state snapshotnervix_dump_state. Nervix keeps the state saved last.
local state persistenceWriting the checkpoint to the node’s stable storage and synchronizing it.
state replicationWaiting for every assigned replica to hold the checkpoint on its stable storage, or the schedule assigning fewer replicas than the checkpoint was captured for.
state authority checkThe state’s authority refused it: a replica or peer that is not the state’s authority, or this node after the branch’s state generation or ownership moved on.

For example, the branch keyed by tenant alpha whose guest rejects its saved counters after an instance was recreated is reported as:

wasm processor 'sessionizer' application state restoration failed (branch 40238306d5ebf339fe640ae723e4705c5bb1743e39d0ec207ed8de19fead54b1, resource 'sessionizer' version 3 file 'sessionizer.wasm', export 'nervix_load_state', saved state revision 12): wasm guest rejected the application state in its saved snapshot: counters header is truncated

Troubleshooting

module compilation failed

The module is not valid wasm32-unknown-unknown, imports something outside the env functions listed above, or is not actually a WASM module.

missing required export

The guest did not export one of the required nervix_* functions with the expected C ABI signature.

guest buffer size ... exceeds configured limit

The host refused to write or read a buffer larger than the configured safety limit. Split the output, reduce the input batch size, or raise MAX MEMORY if the processor’s linear-memory ceiling is the constraining limit.

wasm guest exhausted MAX FUEL ...

One logical guest operation consumed its complete Wasmtime fuel budget. Raise MAX FUEL for expected work or remove an unbounded/overly expensive guest loop.

wasm guest exceeded MAX MEMORY ...

The branch guest tried to instantiate or grow linear memory beyond MAX MEMORY. Reduce guest allocation or raise the declared ceiling.

generated column ... has ... rows, but the routed output has ... rows

A generated pool and one referencing route disagree. Emit exactly one rows entry per generated Arrow value, or move the incompatible route into a separate output group.

missing source token for output row ...

An output uses an input-column reference, but the row does not select a live input row. Preserve the host-provided source_token and keep it in tokens.

wasm output referenced unknown ack token ...

The guest emitted or completed a token that did not come from the host input sidecar for the current live instance.

snapshot envelope decoding failed or application state restoration failed

The guest rejected the saved state it was asked to restore. Under the default ON REJECTED STATE PRESERVE, Nervix keeps that state and reports the rejection again, with the same saved state revision, each time the branch is instantiated. Keep load_state strict; rejecting the state is preferred to running with partially decoded state. Declare ON REJECTED STATE RESET to have the branch’s lifetime replaced once instead; see Recovering A Rejected Snapshot.

rejected-state recovery failed

A processor that declares ON REJECTED STATE RESET could not replace the rejected lifetime, and the one attempt that lifetime was worth is now spent. The branch keeps reporting its rejection until an operator acts. DESCRIBE WASM PROCESSOR reports the attempt as a rejected state recovery line naming the generation, the verdict and the outcome.

state restore failed

Restoring the saved state failed without a verdict on it, for example because the guest trapped or exhausted MAX FUEL. Fix the failure; the saved state is unchanged and is restored again on the next attempt.

Emitters

Emitters publish relay records to external systems or to application consumers through a client session.

A typical emitter:

CREATE IF NOT EXISTS EMITTER kafka_notifications
  FROM notifications
  COLLECT FOR 10ms MAX BATCH SIZE 1MiB
  TO KAFKA kafka_main TOPIC notifications_out
    MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING notification_codec
  INHERIT ALL
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

An emitter defines:

  • one or more source relays that declare the same payload schema
  • an optional input collection policy
  • the codec used for encoding
  • the transport-specific sink
  • the sink’s explicit publishing mode, confirmation window and bound where applicable, and retry pacing
  • an optional batching clause bounding how many records one external write carries and its encoded size, required for database sinks
  • the flush policy used to collect a batch before publishing
  • whether the branch is ATTACHED or DETACHED
  • route-local codec construction or a direct VALUES mapping
  • optional ordered header invocations on supported codec sinks
  • optional ordered materialized-state dependencies

Branch Semantics

An emitter is the terminal consumer for its source relays. The FROM list uses the same source-local predicate form as other relay-consuming nodes:

CREATE EMITTER combined_notifications
  FROM primary_notifications WHERE input.source = 'primary',
       replayed_notifications WHERE input.source = 'replay'
  TO KAFKA kafka_main TOPIC notifications_out
    MODE NO_ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING notification_codec
  INHERIT ALL
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Every listed relay must declare the exact same schema name. Unlike ordinary multi-input processors, emitter inputs may be unbranched or use differently named branches. Each source keeps its own branch identity until its records cross the successful external boundary.

That means:

  • the emitter consumes from all concrete branches of every source relay
  • each optional source WHERE is evaluated only for that relay
  • the current branch remains available internally for compatible materialized-state lookup
  • branch.field is unavailable to successful emitter expressions
  • branch identity collapses only after successful external publication

A node-wide materialized-state dependency and an ON MESSAGE ERROR SEND TO route must be exact-branch compatible with every source. Consequently, an emitter whose inputs use differently named branches cannot configure one branch-bound dependency or error relay across those inputs.

All emitters declare FLUSH EACH <duration> MAX BATCH SIZE <bytes> or FLUSH IMMEDIATE, and DESCRIBE EMITTER reports the declared policy on its flush: line. FLUSH means Nervix collects an in-memory Arrow batch before handing it to the external sink. FLUSH EACH is a domain-logical duration, so a paced domain’s TIME RATE accelerates or slows it; retry backoff, acknowledgement keepalive, and sink acknowledgement timeouts stay on the physical clock. See Domains And Time for the full clock split. The NSPL Overview defines the FLUSH IMMEDIATE 100 µs minimum batching window. For most emitters the collected batch is encoded and published on the flush boundary. Iceberg additionally requires COMMIT EACH <duration> MAX SIZE <bytes> as part of its sink clause: flush writes local Arrow IPC staging files, and commit appends the staged data to object storage. ON MESSAGE ERROR SEND TO buffers failed-message error records separately and delivers them using the emitter’s same FLUSH interval or maximum batch-size boundary. Which sinks gain from larger flush batches, and which publish per record regardless of batch size, is covered by the flush tuning guidance.

An emitter may place COLLECT FOR <duration> [MAX BATCH SIZE <bytes>] immediately after the complete FROM <relay> [WHERE ...] [, ...] list. This input policy runs before emitter filtering, construction, encoding, and the required output FLUSH policy. Omission means no additional input collection: each incoming relay batch enters emitter execution directly. When configured, collection is independent for each source relay and concrete branch and releases on the timer or optional size boundary. Equal keys from differently named branches are never collected together. Branch identity still collapses only after successful publication.

Emitter filtering, construction, headers, direct VALUES, encoding, and generated integration timestamps use the batch’s accepted domain execution snapshot. A failed publish keeps that snapshot with the pending batch across physical retry backoff; retry does not reevaluate expressions at a later domain time. Explicit source event timestamps remain unchanged. Fields whose public contract is observation time, such as OTEL observed_time_unix_nano, read actual UTC only at the external export boundary.

Publishing modes

Every emitter sink requires MODE <body> before its body selection, immediately before ENCODE USING when the sink uses a codec. There is no implicit mode, confirmation window, ACK timeout, or retry cadence. SHOW CREATE EMITTER and DESCRIBE EMITTER render the complete mode.

The shared variables are:

  • ACK SEQUENTIAL publishes and confirms one record before sending the next.
  • ACK PARALLEL MAX <n> permits at most n records to await confirmation, where n is at least one. Nervix fills the window, waits for the oldest confirmation when it is full, and completes a flush only after every record in that flush has been confirmed.
  • ACK TIMEOUT <duration> bounds one asynchronous broker confirmation. Expiry is ambiguous, not a record rejection: Nervix retries the still-unconfirmed records as an infrastructure failure. The broker may have accepted a timed-out record, so confirming modes are at least once and can duplicate on this path.
  • RETRY POLICY BACKOFF <duration> MAX <duration> is required by every mode. Infrastructure retry delays begin at BACKOFF, double on each attempt, and cap at MAX. A server-requested delay, such as an HTTP rate-limit interval, can extend an individual delay; one the node’s monotonic clock cannot schedule extends nothing. Retries continue with backpressure until the external system recovers or an operator repairs its provisioning.

Request/response sinks—SQS, Sentry, OTEL, HTTP, the databases, and Iceberg—do not take ACK TIMEOUT; their client request timeout bounds the response. SQS, Sentry, OTEL, and ClickHouse clients expose that bound as the optional timeout_ms CONFIG key, and a client an HTTP emitter uses must declare it. For every sink, Nervix accounts for records individually wherever the transport exposes individual results: delivered records acknowledge upstream, definitively invalid records follow ON MESSAGE ERROR, and a retry resends only records that are neither delivered nor rejected. An ambiguous or infrastructure-wide failure is never used to discard a record.

SinkMode formsPublish success boundary
KafkaNO_ACK; ACK SEQUENTIAL; ACK PARALLEL MAX <n>Local producer-queue acceptance for NO_ACK; one delivery report per record for ACK
PulsarNO_ACK; ACK SEQUENTIAL; ACK PARALLEL MAX <n>Producer acceptance for NO_ACK; one broker receipt per record for ACK
RabbitMQNO_ACK; ACK SEQUENTIAL; ACK PARALLEL MAX <n>The channel’s answer to one round trip after each write for NO_ACK; publisher confirm for ACK
MQTTQOS 0; QOS 1 ACK ...; QOS 2 ACK ...Client acceptance, PUBACK, or completion of the QoS 2 handshake respectively
NATSNO_ACK; JETSTREAM ACK SEQUENTIAL; JETSTREAM ACK PARALLEL MAX <n>Core-NATS connection flush for NO_ACK; JetStream PubAck otherwise
Redis Pub/SubNO_ACKServer acceptance of PUBLISH; the subscriber count is not a delivery guarantee
ZeroMQNO_ACKSocket acceptance
SQSSINGLE; BATCHSuccessful per-record or per-entry service response
SentryACKSuccessful one-event envelope response
OTELACKSuccessful OTLP Export response; partial_success is acknowledged with a warning
ClickHouse, Postgres, MySQL, MongoDBACKSuccessful insert/write result
IcebergACKSuccessful catalog commit
HTTPACKComplete successful response headers
ClientACK SEQUENTIAL; ACK PARALLEL MAX <n>A current consumer attempt’s explicit application ACK

Client emitters

The Client Ingestors And Emitters architecture chapter connects construction, competing consumers, application ACK, buffering and lifecycle in a complete graph.

TO CLIENT SCHEMA <output_schema> constructs native Arrow output for applications. It has no connector CLIENT object, codec, header operations, or direct VALUES form. The emitter still uses its ordinary FROM predicates, optional collection, materialized dependencies, ordered INHERIT and SET, route WHERE, error policies, placement, and ATTACHED or DETACHED boundary. A client sink requires MODE ACK SEQUENTIAL or MODE ACK PARALLEL MAX <n>, an explicit ACK TIMEOUT and RETRY POLICY, BATCH MAX MESSAGES <1..65536> MAX SIZE <bytes>, and its ordinary required FLUSH policy. SHOW CREATE EMITTER preserves this full contract.

The declared output schema is exact. Construction starts empty; only explicitly inherited or set fields are exported. A sensitive input cannot be copied into output without explicit leak_sensitive(...). Branch fields do not become expression values. Every prepared Arrow IPC stream contains rows from one source relay and one concrete branch, within both declared row and encoded byte limits. One row larger than the limit follows ON MESSAGE ERROR; it does not weaken the batch bound. A delivery carries an opaque branch fingerprint, not the branch key’s values.

Consumers of one emitter compete for its output. ACK SEQUENTIAL permits one outstanding batch per source relay and concrete branch; ACK PARALLEL MAX <n> permits at most n across all workers of that source and branch. The application may ack, retry, or reject a live attempt. Only ack confirms the batch. A retry keeps the original IPC bytes, member positions, identity, and execution snapshot and waits on physical backoff before a fresh attempt. A timeout or lost consumer revokes its ACK reference before reassignment. A later ACK for that reference is stale; repeating a confirmed ACK is idempotent while its bounded result is retained. reject applies the route’s message error policy to every batch member with a bounded, non-sensitive reason. An ATTACHED emitter keeps its source acknowledgement waiting for the application ACK; a DETACHED emitter keeps its existing earlier source boundary. There is no durable consumer cursor or delivery history: an owner loss can require upstream replay, and a lost ACK can duplicate an application effect.

DESCRIBE EMITTER reports active consumers, forwarded consumers and their granted credit, retained batches and IPC bytes, the forwarded subset of that retained work, assigned batches still awaiting application processing, retries, application ACKs, and application rejections. Retained work and forwarding counts describe the executing node’s current owner generation; outcome counters remain on that node across emitter restarts.

HTTP request configuration

The HTTP Emitter Architecture chapter follows the validated request through host preparation, the connector, retry, lifecycle and qualification evidence.

An HTTP emitter uses an existing TYPE HTTP client and declares the request method, path and body selection in this order:

TO HTTP <client>
  METHOD <string_expression>
  PATH <string_expression>
  MODE ACK RETRY POLICY BACKOFF <duration> MAX <duration>
  (ENCODE USING <codec> | WITHOUT BODY)

METHOD and PATH are structured expressions. They may read qualified source fields such as input.method and input.path; parentheses, arrays and string literals keep clause words inside the expression. MODE ACK has no acknowledgement window or ACK TIMEOUT, and NO_ACK is not available. The ordinary route-local FLUSH clause is required.

The referenced client must be a TYPE HTTP client in the same domain. Its endpoint is an http:// or https:// origin with a host and optional port, and no credentials, non-root path, query or fragment. It requires a positive, schedulable timeout_ms; the client’s polling method setting does not supply the emitter’s METHOD. HTTPS retains certificate and hostname verification. The optional tls_cert_file and tls_key_file settings must be supplied together, and mounted TLS files use the client’s pinned resource version. Validation does not probe the destination.

Method, path and write_header name and value expressions must each be exact, non-null STRING values. Request expressions read the original input and, with a codec, finalized output and message. Without a body, message is the source and output is unavailable. Sensitive values in any request field or body require explicit leakage. Branch fields and source-envelope header reads are unavailable. A literal invalid method, target or header rejects configuration; the same rules are checked per record for computed values before publication.

Methods are ASCII HTTP tokens of at most 64 bytes. CONNECT and TRACE are unavailable, and GET and HEAD require WITHOUT BODY; both rules apply to every ASCII case variant, while any other method is sent with exactly the spelling it evaluated to. PATH begins with exactly one / and is parsed against the client origin. It cannot include a fragment, backslash, invalid percent escape, whitespace or control character; normalization must keep it on that origin and must not produce a leading //. The normalized target is limited to 8 KiB. write_header accepts valid HTTP field names and UTF-8 values without control characters or leading/trailing whitespace in a nonempty value. Transport-owned headers cannot be written. After case-insensitive replacement, at most 128 application headers and 32 KiB of name/value bytes are allowed.

ENCODE USING permits the ordinary transforming construction clauses. WITHOUT BODY selects an absent request body and permits WHERE and INVOKE but no INHERIT, SET or VALUES. HTTP emitters publish one request per eligible source record, so they do not accept the optional BATCH clause. SHOW CREATE EMITTER preserves both request expressions and the explicit body selection.

For example, the first emitter below sends each record of outgoing with its own method, path and tenant header and a JSON body of two of its fields, and the second deletes without a body. api.example.com stands for an endpoint the operator has already provisioned.

CREATE CLIENT api TYPE HTTP CONFIG {
  'endpoint' = 'https://api.example.com',
  'timeout_ms' = 5000
};

CREATE ATTACHED EMITTER deliver_event
  FROM outgoing
  TO HTTP api
    METHOD input.request_method
    PATH input.request_path
    MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING event_body_codec
  INHERIT event_id, payload
  INVOKE write_header('Content-Type', 'application/json'),
         write_header('X-Tenant', input.tenant),
         write_header('Idempotency-Key', input.event_id)
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

CREATE EMITTER delete_event
  FROM outgoing WHERE input.request_method = 'DELETE'
  TO HTTP api
    METHOD 'DELETE'
    PATH input.request_path
    MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
    WITHOUT BODY
  INVOKE write_header('Idempotency-Key', input.event_id)
  FLUSH IMMEDIATE
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

HTTP requests

An HTTP emitter sends one request for each eligible record. For each batch it admits, it resolves its materialized dependencies once and uses one execution snapshot, and every expression below reads that snapshot. Each record then passes, in order: its source WHERE; construction and finalization of its codec record; route WHERE; METHOD; PATH; and each write_header invocation as it is written. A record that route WHERE filters evaluates no request field and sends nothing, even when one of its request fields would fail.

The first request field that fails rejects its record through ON MESSAGE ERROR before any part of its request is sent. A failed expression keeps its evaluation code; a value that is not a valid request field has the code validation. A method or path failure has the operation publish and names the request field, method or path, beside the fields its expression reads. A header write failure has the operation invoke and the zero-based position of its invocation. A record whose body the codec cannot encode when a flush releases it is rejected the same way with the operation encode. The message never quotes a method, target or header value. The error handler of every such rejection, and of any later rejection of an admitted request, reads the original input and the materialized state its batch was admitted with; with a codec it also reads the attempted record as partial_output.

The method keeps its spelling. The request goes to exactly the normalized target: encoded separators such as %2F, repeated query parameters, a literal +, and the empty query of a trailing ? stay as they are. Header names compare without ASCII case and a later write replaces an earlier value; an empty string is sent as an empty value. An invalid or reserved write rejects its record even when a later write would replace it, and the 128-header and 32 KiB bounds apply after every replacement. The body is exactly the bytes the codec produced, with no wrapper, array, newline, form encoding or compression added; Nervix adds no Content-Type, and a declared Content-Encoding does not transform the bytes. WITHOUT BODY sends zero content bytes.

A prepared request, with its method, target, headers and body, is kept until the endpoint answers for it, so every retry resends it byte for byte: neither the request expressions nor the codec run again, including volatile calls such as uuid_v4(). Retained bodies occupy node memory, which memory pressure accounts for, until their requests complete.

One active emitter execution has at most one request awaiting final response headers, across all source relays and branches it serves. It sends the requests a flush releases in publication order. Independent executions have no total order, and an endpoint can apply a request after Nervix loses its response, so this order does not settle ambiguous remote effects.

The HTTP sink speaks HTTP/1.1. It creates a connection for each attempt and closes it after final headers, so an unread or stalled body cannot be reused as the next response. DNS resolution, connection acquisition, TLS negotiation, the complete request send, interim responses and complete final headers share the client’s physical timeout_ms; queueing behind an earlier request and host retry backoff do not consume the next attempt’s timeout. HTTPS validates trust and the destination hostname and uses the client’s pinned CA and optional client certificate mounts. Starting the sink reads local configuration and sends no probe.

The transport writes Host, Connection: close, and Content-Length when a body exists. It adds Accept: */* only when the application did not write Accept. It adds no Accept-Encoding or Content-Type. The application may write Accept, Content-Type, Authorization, and Cookie with write_header. Response cookies are not retained; an authentication challenge sends no additional request. There is no redirect or library retry: every repeat is a separate emitter attempt under its declared policy.

Each interim and final response header block may contain at most 128 fields and 64 KiB of field name and value bytes. Malformed headers or final framing and any exceeded bound fail the attempt, even when the final status line says 200. Complete valid final 200–299 headers deliver the record, including 202 and 204; a stalled or failed body after those headers does not reverse delivery. The sink never waits for a body or uses bodies and trailers as graph data.

408, 425, 429, and 500–599 retain the current request and all later work for host retry. 401, 403, and 407 do the same and report an authentication or authorization infrastructure failure. DNS, connection, TLS, send, timeout, malformed response and loss before complete final headers are also infrastructure failures. Other 300–499 statuses and 101 reject only their record through ON MESSAGE ERROR; later records proceed after that policy completes. Redirects are never followed, 304 is not delivery, and 409 never implies an earlier delivery. Rejection diagnostics include the numeric status but no evaluated URL, header value, request body or response body.

HTTP retries and acknowledgements

A flush sends its requests in order and stops at the first one whose outcome is unresolved. That request and every request prepared after it stay retained, and records admitted while they wait queue behind them. A delivered request completes exactly its own record, and a rejected one resolves its record through ON MESSAGE ERROR; neither is sent again, even when its flush held other records that are retried. The retry resends the unresolved requests exactly as they were first sent, ahead of any request prepared later.

Retries wait on the declared physical backoff: the first waits BACKOFF, each later one twice the previous wait up to MAX, and a flush that completes resets the wait to BACKOFF. There is no attempt limit; retrying continues until the request resolves or the emitter stops. A retryable status (408, 425, 429, 500–599, 401, 403 or 407) whose final headers carry exactly one valid Retry-After field asks for a delay of its own: whole seconds, or an HTTP date in IMF-fixdate, RFC 850 or asctime form, compared with actual UTC when the response arrives. A date already past asks for no delay. The next attempt waits for the longer of that delay and the backoff, even beyond MAX, and the backoff sequence itself continues unchanged. A Retry-After that is missing, repeated in more than one field, malformed, such as fractional seconds, or unrepresentable, such as a delay ending after the year 2262, asks for nothing. Retry-After never turns a delivery or a rejection into a retry.

The request timeout, the backoff and a Retry-After delay are physical. TIME RATE scales the emitter’s COLLECT FOR and FLUSH EACH cadences and the domain time its expressions read, but never these waits, and FLUSH IMMEDIATE keeps its physical batching window.

An ATTACHED emitter keeps the upstream acknowledgement of every unresolved request alive until the request resolves: a delivery acknowledges it, and a rejection leaves it to the emitter’s message error policy. A DETACHED emitter acknowledges upstream at relay fan-out, yet still retries, keeps later work waiting behind an unresolved request, and routes rejections. Prepared requests and retry state live in memory only.

An endpoint can apply a request whose outcome Nervix never learns: the response is lost, the connection fails, or the attempt times out after the endpoint acted. The retry then sends the same request again, so the endpoint can receive it twice. Nervix generates no idempotency key and does not interpret an endpoint’s deduplication protocol. An endpoint that must recognize duplicates should receive a stable key taken from the record, such as write_header('Idempotency-Key', input.event_id). A value generated during evaluation, such as uuid_v4(), stays the same across these retries but not across an upstream redelivery.

ALTER EMITTER ... SET TO HTTP restates the complete method, path, mode and body selection. SET CLIENT changes the referenced client, SET MODE changes the retry policy, and SET ENCODE USING selects a codec body. DROP ENCODE is invalid for HTTP; use a complete SET TO HTTP ... WITHOUT BODY replacement. The retained construction and error scopes must be valid for the new selection.

Changing the HTTP sink, method, path, client reference, body selection, or publishing mode uses ENTITY_PAUSE. The emitter drains every admitted request with the client and request fields that prepared it before the replacement starts. If that drain fails, the change fails and the old definition remains active; an unanswered request is never sent to the proposed destination. Changing only FLUSH is DYNAMIC, while adding or removing a source relay uses DOMAIN_PAUSE. Changing the CLIENT definition itself follows the domain-pause configuration lifecycle.

ALTER retains the emitter’s INHERIT, SET, INVOKE, and error clauses, so a body-mode change must remain valid with all of them. To change construction or header invocations, use DROP EMITTER and CREATE EMITTER for the same name in one transaction. The complete candidate is validated before the transaction commits and has the same effective drain obligation as an ALTER replacement. A destination that remains unavailable can prevent that drain. The operator can repair it, or STOP the domain, change the emitter while stopped, and START again. Stopping discards prepared requests and retry state; attached work depends on its source’s redelivery contract, and an endpoint that applied a request before losing its response may see a duplicate.

While confirmations or infrastructure retries are pending, the emitter stops consuming from its relays and keeps upstream ACK leases alive. FLUSH still controls when and how much work enters a flush; MODE controls when each record in that flush counts as published. ATTACHED and DETACHED are orthogonal: a detached emitter acknowledges upstream immediately but still performs its declared confirmations and retries for error visibility and backpressure.

HTTP inspection and metrics

SHOW CREATE EMITTER and canonical formatting render the method and path expressions, the header invocations and the construction in canonical NSPL, with the explicit body selection. A configured expression is rendered as NSPL under the ordinary sensitivity rules, so an explicitly leaked field appears as its leak_sensitive(...) call and never as a value. DESCRIBE EMITTER reports the request contract of the deliver_event example above in these lines:

codec: event_body_codec
body: codec
sink: HTTP client=api method=input.request_method path=input.request_path
batch: none
flush: FLUSH EACH 100ms MAX BATCH SIZE 1MiB
publishing mode: ACK RETRY POLICY BACKOFF 250ms MAX 30s

body reads codec for ENCODE USING, and without body, with codec: none, for WITHOUT BODY. Header invocations appear in SHOW CREATE EMITTER rather than in DESCRIBE.

While a request is pending after a failed attempt — waiting out its backoff or Retry-After, or sent again and not yet answered — DESCRIBE EMITTER reports that failure as its transient error, with the reconnect backoff the retry waited and the reconnect wait still left, and the node reports it as a runtime event. It clears once the pending request is delivered. The failure names its cause and, for a response, its status, such as HTTP endpoint answered with retryable status 503, HTTP endpoint answered with authentication or authorization status 401, HTTP request timed out before complete final response headers, or HTTP TLS handshake failed: invalid peer certificate: UnknownIssuer. A DNS, connection, TLS, send or response-header failure keeps the cause beneath it, such as the resolver’s resolving 'api.example.com' failed: the name does not exist. ON GENERAL ERROR has no say over a response: a retryable failure stays pending and keeps attached acknowledgements alive, and a refusal follows ON MESSAGE ERROR, even with ON GENERAL ERROR IGNORE.

A record’s message error names the operation that rejected it and where applicable the request field or invocation: publish with the field method or path, invoke with the zero-based position of the header write, encode for a body the codec cannot produce, and, for a refused request, the code external with the operation publish and a message carrying the numeric status, such as HTTP endpoint answered with status 404. Message errors, emitter status, runtime events, logs and metric labels never carry an evaluated target, a header value, a credential, a request body or a response body.

The emitter’s sent counters count each record once, when its request is delivered, however many attempts that took, and never a record the endpoint refused. messages_total counts delivered records, not attempts. bytes_total counts a codec body’s record with the ordinary emitter payload accounting, the logical Arrow data of the finalized record, while a request without a body adds no payload bytes. A request’s method, target and headers and the response add nothing to either counter, and the counters carry only the ordinary graph labels, never a request value.

Batching

An emitter may declare two hard limits for every batch it publishes, written after the complete sink clause and its route construction, and before the flush policy:

BATCH MAX MESSAGES <n> MAX SIZE <bytes>

MAX MESSAGES is the most source records one batch carries, from 1 to 65,536. MAX SIZE is the most bytes its encoded payload may occupy: a positive whole number followed by B, KB, KiB, MB, MiB, GB, GiB, TB or TiB. Neither limit has a default, neither may be omitted, and neither is derived from the other or from FLUSH ... MAX BATCH SIZE, which measures Arrow memory rather than encoded bytes.

CREATE EMITTER kafka_notifications
  FROM notifications
  TO KAFKA kafka_main TOPIC notifications_out
    MODE ACK PARALLEL MAX 100 ACK TIMEOUT 30s RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING notification_codec
  INHERIT ALL
  BATCH MAX MESSAGES 500 MAX SIZE 1MiB
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;
SinksClause
Kafka, Pulsar, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS, Sentry, Syslog, OTEL, IcebergOptional
ClickHouse, Postgres, MySQL, MongoDBRequired: a database write always carries several rows
HTTPUnavailable: each request contains one source record

A statement is rejected, naming the offending value, when:

  • MAX MESSAGES is zero or above 65,536;
  • MAX SIZE is zero, fractional, written without a unit, or past the 64-bit byte range;
  • MAX SIZE is above 256 KiB on an SQS emitter, the largest SQS message Nervix sends;
  • the clause is absent on a ClickHouse, Postgres, MySQL or MongoDB emitter;
  • the clause is present on a Sentry emitter whose codec declares no ON EMITTING BATCH transformation, because one Sentry envelope carries at most one event and only that transformation can say where the members go;
  • the clause is present on an emitter whose protobuf codec declares no BATCH MESSAGE, because protobuf has no self-delimiting sequence. Without an ON EMITTING BATCH transformation, the batch message must declare exactly one field, repeated <MESSAGE>, which the domain build checks against the compiled descriptors.

The codec forms are described in Schemas and codecs. These checks cover the whole candidate graph, so replacing a codec that a batching emitter uses is validated against that emitter too.

SHOW CREATE EMITTER renders the clause between the sink clause and FLUSH, with the size in the unit it was written in. DESCRIBE EMITTER reports it on the line after sink:, as batch: MAX MESSAGES 500 MAX SIZE 1MiB or batch: none.

The complete contract for batch payloads — packing, containers per wire format, exact size measurement and failure attribution for every sink — is defined in Optional emitter batching. The database sinks bound every insert or bulk write by both limits, as Database writes describes. An OTEL emitter with the clause bounds each export request; without it, OTEL keeps one request per pending Arrow batch. Iceberg keeps its data-file and commit boundaries.

Batch payloads

A Kafka, Pulsar, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS, Sentry or Syslog emitter that declares the clause publishes batch payloads instead of one payload per record. Each payload is one message, one event or one frame whose value is the codec’s batch container: an array for JSON, CBOR, YAML and Avro, a single batch key for TOML, a single batch root element for XML, the codec’s BATCH MESSAGE for protobuf and one RFC 5424 frame for SYSLOG, or the single value an ON EMITTING BATCH transformation built instead.

The emitter walks the Arrow carriers released by one flush in arrival order, and each carrier’s eligible rows in source order. A payload can span carriers only while their source relay, exact named and concrete branch, key, ordered written headers, ordering group and, with a SYSLOG codec, syslog header other than the timestamp agree. A different value seals the open payload before the next row is considered; no row is skipped over or reordered. A carrier retains its own execution snapshot and row membership for errors and acknowledgements. A payload with one member keeps the container shape, such as a one-element array, and a carrier with no eligible row publishes nothing. Batching adds no timer: FLUSH alone decides when records leave, and a partial payload is published exactly like a full one.

MAX SIZE is the exact length of the payload: escaping, UTF-8, base64, field names, separators, length prefixes and the container’s own brackets all count, and a batch transformation is measured by the bytes of its output. Nervix never estimates the size. It encodes the candidate into a buffer that refuses to grow past the limit and abandons the encoding at the first byte that would not fit, so an oversize payload is never built in full and never reaches the destination. A payload of exactly MAX SIZE bytes is published. A candidate whose encoding reached the limit is halved: its first half is re-encoded under the same limit and the rest returns to the front of the queue. Each halving is encoded again, because a batch transformation may write more bytes for fewer members, so a candidate of n members takes at most ⌈log2(n)⌉ + 1 encodings. The bound covers the payload only: keys, headers and the framing a transport adds around the payload are outside it.

Schemaful JSON rows use the same bounded writer as other codecs. Their columnar encoder stops at the first write over the limit; its string classifier and direct column writes do not change exact byte measurement, halving, or the per-record error policy.

A record is rejected alone, through ON MESSAGE ERROR with operation encode, when its member value cannot be produced — its ON EMITTING transformation fails, or, without a batch transformation, its value is not one the format can write — and when a payload of it alone still exceeds MAX SIZE. The second case has code validation and a message naming the codec, its encoding and the limit, such as emitter 'bounded_events' codec 'event_codec' JSON payload exceeds MAX SIZE 32B. The records around it are packed as usual.

A batch transformation that yields no output, more than one output, fails to evaluate, or yields a value the format cannot write fails the whole candidate. Every member follows ON MESSAGE ERROR with operation encode, the same error reference and a message naming the emitter, the codec, the cause and the member count, such as emitter 'kafka_notifications' codec 'notification_envelope' ON EMITTING BATCH produced no output for a batch of 3 messages. The code is evaluation, or validation for a value the format cannot write. The message never quotes a payload value, so it does not repeat the program’s own error text, and Nervix does not subdivide such a batch to look for a member to blame; use a smaller MAX MESSAGES, or no batching, where per-record attribution matters.

One payload is one publish. Its confirmation delivers every member, and a destination’s rejection of it rejects every member with one shared error reference. ACK PARALLEL MAX <n> therefore counts payloads, not records. nervix_messages_total keeps counting source records.

A payload whose outcome the emitter did not learn — the destination failed, stopped answering, or its confirmation timed out — is retained exactly as it was written. The retry, or a force flush or drain before it, writes the same bytes with the same members again, so a duplicate a retry produces is the payload the destination may already hold, never a regrouped one. Payloads the destination confirmed or rejected in the failed attempt are not written again, and records that arrive while a payload is retained go into later payloads. The members of a retained payload keep their upstream acknowledgements alive until it resolves; a DETACHED emitter still acknowledges upstream at relay fan-out and still retries the payload. Where the destination answers for every record itself, as a MongoDB bulk write does per document, a retry carries only the records it left unresolved.

Member values and containers are working values that exist only while the released carriers are encoded; the records themselves stay in Arrow batches. The packer holds at most MAX MESSAGES prepared members in one candidate, even when the members come from successive carriers. For a codec with jaq transformations, member preparation, batch transformations and every re-encoding run in the same job on the node’s extension workers that already runs ON EMITTING, so a slow program never stalls the emitter task. A node whose extension workers cannot take the job now refuses it, and the emitter keeps its rows and retries them as it retries a failed publish. Candidate work is bounded by MAX MESSAGES and the encodings above; MAX SIZE bounds what is written.

Broker and message emitters

Kafka, Pulsar, RabbitMQ, Redis Pub/Sub, MQTT, NATS, ZeroMQ and SQS publish each batch payload as one message through the connector’s own driver: one Kafka record value, one Pulsar message payload, one AMQP message body, one PUBLISH to a Redis channel, one MQTT PUBLISH packet, one NATS message on the subject, one single-frame ZeroMQ message or one SQS message body. The message carries once what its members share. The Kafka record key and the Pulsar partition key are the members’ concrete branch key; the written headers become Kafka and NATS headers, Pulsar properties, AMQP headers or SQS message attributes; and an SQS FIFO message carries the members’ message group. Without the clause every record stays its own message, carried the same way.

Batching keeps every publishing mode’s completion point. NO_ACK and QOS 0 complete when the producer, channel, client or socket accepts the batch message; the confirming modes wait for one delivery report, broker receipt, publisher confirm, PUBACK, completed QoS 2 handshake or JetStream PubAck for it. ACK TIMEOUT bounds that one wait, and a batch message whose outcome stays unknown is retained for the retry described above.

SQS MODE BATCH remains a request shape and is independent of the clause. One SendMessageBatch request carries up to ten batch messages as separate entries within the 256 KiB request limit, and never two messages of one FIFO message group. The service answers for each entry, and an entry’s answer applies to every member of the batch message it carries. Entries are never merged into one message, and one entry never carries more than one batch message.

MAX SIZE bounds the payload, while the destination bounds the whole message it receives. Where the connector learns the destination’s limit, it checks every message against it before writing, counting what it writes around the payload:

SinkLimit checked before a message is writtenCounted around the payload
KafkaThe producer’s message.max.bytes client settingThe record key, headers and record overhead
MQTTThe Maximum Packet Size the broker declared in its latest CONNACK, and the largest packet the protocol can expressThe fixed header, topic, packet identifier and property length
NATSThe max_payload the server announcedThe message headers
PulsarThe maxMessageSize the broker announced when the producer’s connection openedThe message metadata: the properties, the partition key and the producer’s own fields
SQS256 KiBMessage attribute names, types and values, and the FIFO message group

A batch message that does not fit is never written. Every member follows ON MESSAGE ERROR with code external, operation publish and one shared reference, and the message names the size and the limit, such as mqtt rejected record: MQTT PUBLISH packet of 1200090 bytes exceeds the broker's maximum packet size of 1048576 bytes. The messages around it are still written. Declare a MAX SIZE that leaves room for the metadata to keep batches from reaching the limit.

A Pulsar broker announces its maxMessageSize on every connection, so a producer that reconnects to another broker checks against the new one’s. A Pulsar topic can also set a smaller maxMessageSize policy of its own. Only the broker applies it, measuring the metadata and payload with ten bytes of framing, so such a message is written and refused afterwards. In MODE ACK the refusal rejects every member the same way, with the broker’s reason, such as pulsar rejected record: the Pulsar broker does not allow the message: Exceed maximum message size. In MODE NO_ACK the message was already delivered when the producer accepted it, so the refusal is not observed.

The remaining limits are not visible to the client. RabbitMQ’s max_message_size is a broker setting that AMQP never tells a client, and the broker compares it with the message body alone. A batch message whose body is larger reaches the broker, which refuses it by closing the channel it arrived on and names the limit, and Nervix rejects the message on that answer, in every publishing mode: every member follows ON MESSAGE ERROR with code external, operation publish and one shared reference, and the message names the size and the limit, such as rabbitmq rejected record: message body of 1200090 bytes exceeds the broker's max_message_size of 1048576 bytes. The broker discards the messages written after the refused one with the channel, and Nervix writes them again on a new channel. A message written ahead of it that the broker had not confirmed yet may have reached its queue, as it can on a quorum queue, so the write then fails as an infrastructure failure, and its retry carries every message but the rejected one and those already confirmed. Headers do not count, so a MAX SIZE no larger than max_message_size keeps every batch message within it.

Redis rejects a value above its proto-max-bulk-len itself, and that rejection follows ON MESSAGE ERROR like the ones above. ZeroMQ fixes no limit; a receiving socket configured with a maximum message size drops a larger message after the sending socket has accepted it.

Database writes

ClickHouse, Postgres, MySQL and MongoDB always write several rows at once, so their emitters require the clause. A flush hands the sink every run of rows from successive Arrow carriers of one source relay and concrete branch, in the order the emitter would have published them, and the sink writes the run as inserts or bulk writes of at most MAX MESSAGES rows. Every source record stays one row or one document: an array or VEC value is one column value, never a set of rows, and no destination schema changes.

SinkOne writeMAX SIZE measures
ClickHouseOne INSERT INTO <table> FORMAT JSONEachRow requestThe request body, one JSON line and newline per row, before the client compresses it
PostgresOne INSERT ... SELECT ... FROM unnest(...) statement binding one text array per mapped columnThe statement text and every bound array as the extended-query protocol encodes it: a 20-byte array header, then a four-byte length and the value’s text for each row
MySQLOne multi-row INSERT ... VALUES (...), (...) statementThe statement text and every bound value as the binary protocol encodes it
MongoDBOne unordered insert_many, or one unordered bulk write of upserts under ON CONFLICTEach inserted document as the driver writes it, with the _id it adds to a document that has none, or each upsert’s filter and update documents

Everything around the measured payload is framing outside MAX SIZE: HTTP headers and the statement in the ClickHouse URL, each Postgres parameter’s own length word and the protocol messages, the MySQL packet headers and the parameter types and null bitmap of its execute packet, and the command and operation fields around MongoDB documents, the fields the driver adds to every command, and the wire-protocol header. The measured size is exact, and a write of exactly MAX SIZE bytes is sent. A candidate that measures more is halved, and its first half is measured again while the rest returns to the front of the next candidate. A row whose own write still measures more follows ON MESSAGE ERROR with code validation, operation encode, and a message naming the limit, such as Postgres insert of one row measures 1219 bytes, above MAX SIZE 600B. The rows around it are written.

The destination’s own limits bound every write too. A MySQL statement binds at most 65,535 placeholders, so an insert carries at most as many rows as fit them: 13,107 rows of five mapped columns, whatever MAX MESSAGES allows. Postgres reads no protocol message above 1,073,741,822 bytes, and a write’s measured size is never smaller than the message that carries its arrays, so a Postgres write is kept within that size too; a row whose own insert exceeds it follows ON MESSAGE ERROR as an external publish error. A MongoDB document holds at most 16 MiB, so a row whose document exceeds it follows ON MESSAGE ERROR as an external publish error naming 16777216 bytes before any write, and the rows around it are written. MongoDB takes 100,000 writes in one command, more than MAX MESSAGES allows, and its driver divides a write larger than the maxMessageSizeBytes the server reports into several commands whose documents it still answers for one by one.

A write that fails for a reason specific to its rows — a constraint, a value its column cannot hold, a packet the server refuses as too large — is written again one row at a time, so the rows the destination accepts land and only the rows it refuses follow ON MESSAGE ERROR, each with the destination’s own reason. This includes a Postgres ON CONFLICT DO UPDATE whose insert carries one key twice, which Postgres refuses for the whole statement: row by row, the later row updates the one before it, exactly as the rows would one insert at a time. MySQL applies the rows of one insert in order, so a key repeated in one write is updated by its later row under DO UPDATE and keeps its first row under DO NOTHING. A MongoDB write is unordered: one server applies its upserts in the order the write lists them, with the same result, while a sharded deployment may apply them in parallel. MongoDB names every document it rejects, so its healthy documents are delivered by the write itself without a row-by-row pass.

A write whose outcome is unknown — the connection failed or the destination stopped answering — is retried with its rows unresolved. The rows before it stay delivered, so the retry packs the same rows into the same writes again, and a write the destination already took is taken twice; use a conflict policy where duplicates matter. Where MongoDB answered for some documents of a write, the retry carries only the documents it left unresolved.

Each sink writes a BYTES value in its native binary form: the octets themselves in a ClickHouse String, the bytea hex format Postgres reads back as the same octets, the octets bound to a MySQL binary or blob column, and generic BSON binary data in MongoDB. An ARRAY or VEC value is a JSON array in ClickHouse’s JSONEachRow and in the JSON text Postgres and MySQL bind for it, and a BSON array in MongoDB; inside that JSON text a BYTES element is the padded base64 text every Nervix JSON value carries octets as.

Altering emitters

ALTER EMITTER applies one or more comma-separated operations in written order:

ALTER EMITTER <emitter>
    ADD FROM <relay> [WHERE <expr>]
  | DROP FROM <relay>
  | ALTER FROM <relay> SET WHERE <expr>
  | ALTER FROM <relay> DROP WHERE
  | SET TO <full sink clause>
  | SET MODE <transport-specific mode body>
  | SET CLIENT <client>
  | SET ENCODE USING <codec>
  | DROP ENCODE
  | SET COLLECT FOR <duration> [MAX BATCH SIZE <bytes>]
  | DROP COLLECT
  | SET ATTACHED
  | SET DETACHED
  | SET BATCH MAX MESSAGES <n> MAX SIZE <bytes>
  | DROP BATCH
  | SET FLUSH EACH <duration> MAX BATCH SIZE <bytes>
  | SET FLUSH IMMEDIATE
  | SET COMMIT EACH <duration> MAX SIZE <bytes>
  [, ...];

SET TO accepts the same complete transport-specific sink body that follows TO in CREATE EMITTER, including its required MODE, SQS FIFO group, and Iceberg commit policy. The existing construction, batching clause and output flush policy remain in place, so changing to a database sink requires the emitter to declare a batching clause, in the same statement if necessary. SET MODE changes only the current sink’s publishing mode and rejects a body that the sink does not support. SET CLIENT changes only the client of the current sink kind. SET COMMIT is valid only for Iceberg. DROP ENCODE fails if the emitter has no codec configured. SET BATCH adds or replaces the batching clause. DROP BATCH fails when the emitter has no clause and when its sink requires one.

ADD FROM rejects an already configured relay. DROP FROM cannot remove the final input. ALTER FROM ... SET WHERE adds or replaces that source’s predicate; ALTER FROM ... DROP WHERE fails when the source has no predicate.

Changing only FLUSH is a DYNAMIC update. The live emitter keeps its pending Arrow batches, installs the new cadence, and receives a force-flush kick, so buffered output is neither discarded nor re-encoded. Source-predicate, sink, publishing-mode, client, codec, collection, batching, and attachment changes use ENTITY_PAUSE: Nervix gates all of the emitter’s source relays, drains collected input and pending sink output, replaces that emitter task, and releases the gates. Changing source membership uses DOMAIN_PAUSE because it changes graph topology. Other relays continue flowing during an entity pause; sibling consumers of a gated source may see bounded backpressure until the gate is released. The complete candidate graph is validated before any change is committed.

Codec-emitter construction

Codec emitters are transforming routes. They begin with an empty codec-schema payload and use explicit inheritance and ordered assignment:

CREATE IF NOT EXISTS EMITTER kafka_notifications
  FROM notifications
  TO KAFKA kafka_main TOPIC notifications_out
    MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING notification_codec
  INHERIT ALL EXCEPT raw, secret
  SET secret = leak_sensitive(input.secret),
      normalized = lower(input.raw)
  WHERE output.active
  INVOKE write_header("tenant", input.tenant),
         write_header("route", output.normalized)
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

message.field reads the working message, input.field always reads the source relay row, and output.field requires prior initialization. Relay-qualified fields are invalid. There is no implicit identity transformation and no UNSET; use INHERIT ALL EXCEPT.

External sensitivity is strict. Every sensitive payload value requires leak_sensitive(...) or an explicit INHERIT field LEAK SENSITIVE, even when the codec target field is also sensitive.

Direct-emitter values

Database, object-store, and OTEL direct emitters construct external name-keyed mappings:

VALUES {
  "tenant" = input.tenant,
  "normalized" = lower(input.action),
  "secret" = leak_sensitive(input.secret)
}
WHERE input.active

Entries are independent and do not create variables. Order does not affect evaluation, duplicate external keys are invalid, output is unavailable, and sensitive values require explicit leakage. Bare fields, message.field, and input.field read the source row. Direct emitters reject INHERIT and all current direct sinks reject INVOKE.

Header invocations

write_header is a side-effect function. It accepts statically non-null STRING name and value expressions and is valid only as a top-level INVOKE call. Sensitive values require leak_sensitive. Calls execute left to right after payload finalization and route filtering. Header mutations are staged in a temporary route-local envelope; invocation failure prevents payload and partial-envelope publication.

Header output is supported only on codec emitters for Kafka, NATS, Pulsar, RabbitMQ, and SQS. Kafka and NATS preserve ordered repeated values. Pulsar, RabbitMQ, and SQS use last-write-wins behavior. Redis, MQTT, Syslog, ZeroMQ, Sentry, OTEL, direct database sinks, and Iceberg reject header writes.

Emitter expressions use the same typed surface as other runtime nodes:

  • arithmetic: +, -, *, /, %
  • comparisons and boolean logic: =, !=, >, <, >=, <=, AND, OR, NOT
  • explicit conversions: expr AS TYPE, and TRY_CAST(expr AS TYPE), which yields a typed null instead of failing the message
  • JSON extraction: JSON_VALUE(doc, '$.path' AS TYPE), TRY_JSON_VALUE(doc, '$.path' AS TYPE), and JSON_EXISTS(doc, '$.path')
  • built-ins: string, null-handling, numeric, regex, and contextual functions such as lower, coalesce, abs, regexp_substr, now, and uuid_v4

See Expression Functions for the full function reference.

That expression surface applies to the full Nervix internal schema type set:

  • U8, I8, U16, I16, U32, I32, U64, I64
  • F32, F64
  • BOOL, STRING, DATETIME

Nested conditions and chained calls such as contains(lower(trim(input.raw)), 'warn') are supported before encoding.

Client-backed emitters can use resource-mounted client config values for TLS material and other file-based settings. See Resources.

TLS Client Configuration

Emitter TLS is configured on the referenced CLIENT exactly the same way as ingestor TLS.

Common pattern:

CREATE [IF NOT EXISTS] CLIENT <name>
  TYPE <kind>
  MOUNT <tls_resource> VERSION <n>|LATEST
  CONFIG {
    ...
    'tls_ca_file' = '{{ tls_resource }}/ca.pem'
  };

Transport-specific expectations:

  • KAFKA: pass-through to librdkafka. Typically set 'security.protocol' = 'ssl', 'ssl.ca.location' = '{{ tls_resource }}/ca.pem', and optional 'ssl.certificate.location' plus 'ssl.key.location'.
  • RABBITMQ: use amqps://... in addr; Nervix honors tls_ca_file.
  • REDIS: use rediss://... in addr; Nervix honors tls_ca_file, tls_cert_file, tls_key_file.
  • MQTT: use mqtts://... in addr; Nervix requires tls_ca_file and supports tls_cert_file plus tls_key_file.
  • NATS: use tls://... in addr; Nervix honors tls_ca_file, tls_cert_file, tls_key_file.
  • PULSAR: use pulsar+ssl://... in addr; Nervix honors tls_ca_file and optional tls_allow_insecure_connection plus tls_hostname_verification_enabled. Pulsar client certificate authentication is not currently exposed.
  • SQS: use an https://... endpoint; Nervix honors tls_ca_file and optional timeout_ms.
  • SENTRY: the referenced TYPE SENTRY client carries an https://... dsn; Nervix honors the client’s tls_ca_file, tls_cert_file, and tls_key_file.
  • OTEL: use an https://... endpoint; Nervix honors tls_ca_file, tls_cert_file, and tls_key_file for both OTLP/gRPC and OTLP/HTTP-protobuf.
  • CLICKHOUSE: use an https://... addr; Nervix honors tls_ca_file and optional timeout_ms.
  • POSTGRES: use sslmode=verify-full in the addr URL; Nervix honors tls_ca_file, tls_cert_file, and tls_key_file. sslmode=disable is the only other accepted policy.
  • MYSQL: include require_ssl=true in addr; Nervix honors tls_ca_file.
  • SYSLOG: select 'protocol' = 'tls'. Optional tls_ca_file adds a server trust root; optional tls_cert_file and tls_key_file configure client authentication and must appear together.

Example Kafka TLS emitter client:

CREATE IF NOT EXISTS CLIENT kafka_tls
  TYPE KAFKA
  MOUNT dev_tls VERSION 1
  CONFIG {
    'bootstrap.servers' = '127.0.0.1:9094',
    'security.protocol' = 'ssl',
    'ssl.ca.location' = '{{ dev_tls }}/ca.pem'
  };

Supported Emitter Sinks

Kafka

TO KAFKA <client> TOPIC <topic>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
     | ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
         RETRY POLICY BACKOFF <duration> MAX <duration>

ACK waits for every record’s delivery report. Kafka’s acks, idempotence, batching, linger, and compression remain pass-through client configuration; Nervix’s ACK TIMEOUT independently bounds the report wait. NO_ACK is fire-and-forget after local admission: Nervix acknowledges the emitter’s ATTACHED ACK share when librdkafka accepts the record into its producer queue and does not wait for a delivery report. A later broker error, producer timeout, crash, or process exit can therefore lose an accepted record. A full local queue is an infrastructure condition paced by the declared retry policy with backpressure. Graceful shutdown drains queued records within the node drain bound in both modes. A definitive record-specific rejection, such as an oversized message, follows ON MESSAGE ERROR.

Pulsar

TO PULSAR <client> TOPIC <topic>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
     | ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
         RETRY POLICY BACKOFF <duration> MAX <duration>

ACK waits for each broker receipt. NO_ACK acknowledges producer acceptance and does not expose later broker errors; its throughput advantage may be smaller than Kafka’s because Pulsar already pipelines producer work.

A record whose message is larger than the maxMessageSize the broker announced for the producer’s connection, counting its metadata and properties, follows ON MESSAGE ERROR in either mode: the producer refuses it before writing it, because the broker would close the connection and fail every message in flight on it. With ACK, a message the broker receives and refuses with NotAllowedError, such as one above the topic’s own maxMessageSize policy, follows ON MESSAGE ERROR too, while any other broker error is retried.

Pulsar emitters use the same client config surface as Pulsar ingestors:

  • 'addr': broker address such as 'pulsar://127.0.0.1:6650'
  • optional 'namespace': defaults short topic names to persistent://public/default/<topic>; fully qualified topic names are accepted as-is
  • optional 'tls_ca_file': PEM-encoded CA bundle for pulsar+ssl://... connections
  • optional 'tls_allow_insecure_connection': true or false; defaults to false
  • optional 'tls_hostname_verification_enabled': true or false; defaults to true

Pulsar TLS currently supports server trust configuration only. Nervix does not yet expose Pulsar client certificate authentication.

RabbitMQ

TO RABBITMQ <client> QUEUE <queue>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
     | ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
         RETRY POLICY BACKOFF <duration> MAX <duration>

ACK enables publisher confirms and waits for the confirm of each message. A broker nack is an infrastructure failure and is retried with backpressure. In NO_ACK the broker confirms nothing, so once a write’s messages are on the channel, the emitter asks the channel for one round trip, which the broker answers only after it has taken every message written before it, and that answer acknowledges them. A NO_ACK write therefore waits for one round trip to the broker however many messages it carries, and a channel or connection lost before the answer leaves the write’s messages to the retry.

The broker’s max_message_size, 16 MiB by default in RabbitMQ 4.x, bounds each message body; headers do not count. The broker closes the channel a larger body arrives on, and the emitter rejects that message through ON MESSAGE ERROR in either mode, instead of retrying it or acknowledging it, then keeps publishing on a new channel of the same connection; see Broker and message emitters.

The emitter resolves the host of the client’s addr through the node’s configured DNS resolver when it opens and whenever it reopens after a failed publish, so a changed DNS answer takes effect on the next connection, and tries the addresses it receives in order. A literal IPv4 address, or an IPv6 address in brackets, is connected to as written. Resolution, the TCP connection and, for amqps, the TLS handshake have 30 seconds together; the broker certificate must name the host addr names. A connection that fails, including a name that does not resolve, leaves the emitter unavailable with the failure as its transient error; it confirms nothing, so its input stays unacknowledged, and it reopens on its RETRY POLICY backoff.

Redis Pub/Sub

TO REDIS PUBSUB <client> CHANNEL <channel>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>

Redis Pub/Sub has no subscriber delivery acknowledgment. The awaited PUBLISH response confirms server acceptance only. A record-specific server rejection follows ON MESSAGE ERROR; connection failures retry the undelivered work. A TYPE REDIS client declares its connection-pool bounds; see Database Client Connection Pools. Each physical pooled command connection resolves the addr hostname through the node’s asynchronous DNS resolver when it opens. A replacement connection can use a changed DNS answer; an established connection stays open until Redis or the network closes it. For rediss://, TLS still verifies the configured hostname and uses its configured CA and optional client identity.

MQTT

TO MQTT <client> TOPIC <topic>
  MODE QOS 0 RETRY POLICY BACKOFF <duration> MAX <duration>
     | QOS (1 | 2) ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
         RETRY POLICY BACKOFF <duration> MAX <duration>

QoS 0 acknowledges client acceptance. QoS 1 waits for PUBACK; QoS 2 waits for the complete exactly-once handshake. QoS 1 and 2 use a persistent session and the emitter client’s stable identity so in-flight messages survive reconnects. Reconnect pacing follows the declared retry policy. When an answer misses its ACK TIMEOUT, or the broker or client fails it for a reason that is not the record’s own, the emitter keeps every answer the broker already gave and retries only the records without one; DESCRIBE EMITTER shows why, such as the broker did not answer within ACK TIMEOUT 10s. A record the emitter stopped waiting for stays with the client, which sends it again after a reconnect that resumes the session, beside the emitter’s own retry, so the broker can receive it twice. A definitive record rejection, such as an invalid topic or payload-format rejection, follows ON MESSAGE ERROR. So does a record whose PUBLISH packet would exceed the Maximum Packet Size the broker declared when the client connected, or the largest packet MQTT can express: the emitter rejects it before handing it to the client, which would otherwise lose its connection on a packet the broker refuses to receive. The client resolves the host of its addr through the node’s asynchronous DNS resolver every time it connects, first and after every lost connection, and dials the answers in order within its five-second connect timeout. For mqtts, TLS verifies the configured host name. While the name does not resolve, the client reconnects on the declared retry policy, DESCRIBE EMITTER shows the lookup failure, and a QoS 1 or 2 record stays unacknowledged until the broker acknowledges it.

NATS

TO NATS <client> SUBJECT <subject>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
     | JETSTREAM ACK (SEQUENTIAL | PARALLEL MAX <n>) ACK TIMEOUT <duration>
         RETRY POLICY BACKOFF <duration> MAX <duration>

NO_ACK publishes through Core NATS and acknowledges after the connection flush. JETSTREAM waits for one PubAck per record. The stream must already exist and capture the subject; a missing stream is an infrastructure error that remains under backpressure until an operator provisions it.

ZeroMQ

TO ZEROMQ <client>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>

ZeroMQ has no delivery acknowledgment; success is socket acceptance. Transient socket failures are paced by the declared retry policy.

Syslog

TO SYSLOG <client>
  MODE NO_ACK RETRY POLICY BACKOFF <duration> MAX <duration>
  ENCODE USING <codec>

The client sends UDP datagrams, persistent RFC 6587 TCP frames, or RFC 5425 TLS frames. TCP uses octet-counting by default and may select non-transparent framing; TLS always uses octet counting. Success is local socket acceptance and flush, not remote delivery confirmation. The sink requires a codec and rejects header writes. See Syslog for client configuration, framing, message errors, retry behavior, and limits.

With BATCH, a SYSLOG codec encodes one RFC 5424 frame whose MSG is a JSON array of the members’ complete RFC 5424 messages. Members must share every header field except timestamp; the outer frame uses the first member’s timestamp. The same frame travels as one UDP datagram, one octet-counted TCP/TLS frame, or one non-transparent TCP frame when it contains no LF.

SQS

TO SQS <client> QUEUE <queue> [FIFO GROUP (FROM BRANCH | <string_expression>)]
  MODE (SINGLE | BATCH) RETRY POLICY BACKOFF <duration> MAX <duration>

SINGLE issues one request per message. BATCH groups messages within SQS’s fixed limit of ten entries and 256 KiB per request; both modes issue requests sequentially and acknowledge service responses. Per-entry transient failures retry only those entries, while invalid entries and a message larger than 256 KiB, counting its attributes and FIFO message group, follow ON MESSAGE ERROR individually. Nervix checks what SQS refuses before sending: a body that is not UTF-8, a body or attribute value holding a character SQS forbids, more than ten attributes, an attribute name SQS does not allow, an empty attribute value, and an invalid FIFO message group. SQS accepts the characters of the XML 1.0 Char production: tab, line feed, carriage return, U+0020 to U+D7FF, U+E000 to U+FFFD and U+10000 to U+10FFFF. Every other C0 control and the noncharacters U+FFFE and U+FFFF are forbidden, while the other noncharacters fall inside those ranges and are sent.

Set the SQS client’s optional timeout_ms CONFIG key to bound both the complete service operation and its single SDK attempt. Nervix disables the AWS SDK’s internal retries, so a timeout returns to the emitter and the mode’s declared RETRY POLICY owns all retry pacing.

The client resolves the host of its endpoint through the node’s asynchronous resolver each time it opens a connection, and tries the answers in order; a literal IPv4 or IPv6 address is dialled as written. Every request is still signed for the configured host, and over HTTPS the service certificate must name that host, whichever address accepted the connection. The lookup counts against the SDK’s 3.1-second connect timeout and against timeout_ms. Without tls_ca_file the client trusts the platform’s native roots and follows the HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY environment variables; with it, the client trusts that CA alone and connects directly. A missing name, a silent name server or an unreachable answer fails the queue lookup or the send, which the emitter retries on its RETRY POLICY; nothing is acknowledged until SQS answers.

For FIFO queues, one batch request contains at most one record from each message group. A partial batch failure therefore cannot deliver a later record from a group ahead of the failed record; other groups may still make progress independently.

A queue name ending in .fifo requires FIFO GROUP, and FIFO GROUP is rejected for a queue without that suffix. FROM BRANCH requires branched input and uses the record’s branch key as its message group. Otherwise the expression must have exact STRING type for every record and obey normal external-sensitivity leakage rules. Nervix relies on content-based deduplication, which the operator must enable while provisioning the FIFO queue. Sends to a FIFO queue without it fail as publish errors; Nervix never creates or reconfigures the queue.

FIFO GROUP is the emitter’s ordering group. Nervix evaluates it against each record’s input row after any FROM ... WHERE filter, and every record the emitter sends carries the group its own input row produced, whatever the emitter’s WHERE and construction keep or build. A record whose group expression fails for its row, or that reaches FROM BRANCH without a branch key, is not sent. It follows ON MESSAGE ERROR as an external error of the publish operation whose message begins ordering group, and the emitter’s other records are still sent under their own groups. A record the emitter’s WHERE drops is never sent, so a failure of its group is never reported.

Sentry

Sentry emission uses a TYPE SENTRY client whose required dsn contains the project endpoint and public key:

CREATE CLIENT sentry_main
  TYPE SENTRY
  CONFIG {
    'dsn' = 'https://<public-key>@sentry.example.com/<project-id>',
    'timeout_ms' = 5000
  };

CREATE EMITTER sentry_errors
  FROM errors
  TO SENTRY sentry_main
    MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING sentry_event_codec
  INHERIT ALL
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The codec must produce one top-level JSON object per record. Its fields use the Sentry event protocol, such as message, level, environment, release, tags, extra, user, and exception. Nervix preserves the complete object and supplies event_id, timestamp, and platform when they are omitted. It then creates a Sentry envelope, derives the envelope URL and authentication header from the DSN, and submits the event. An invalid event object is handled as a route-local encoding error.

Sentry sends one event per envelope and acknowledges the successful HTTP response. On 429 or 503, Nervix honors Retry-After and X-Sentry-Rate-Limits; the server interval extends the declared retry delay when it is longer.

With BATCH, ON EMITTING BATCH builds one event from the candidate array, usually placing its members under extra. The envelope still has exactly one event item. MAX SIZE measures the transformed event JSON; Nervix also checks the final serialized event, after default fields are added, against Sentry’s 1 MB decompressed event limit before sending the envelope. An oversized event follows the emitter’s message error policy. The envelope header, item header, and newline framing are outside MAX SIZE.

Use a JSON wire codec or a JAQ-native codec with JSON output. Sentry emitters require ENCODE USING, do not accept write_header, and still require explicit leakage for sensitive event fields. The optional Sentry client keys timeout_ms, tls_ca_file, tls_cert_file, and tls_key_file have their usual meanings. Sentry resolves the DSN endpoint through the node’s configured DNS resolver; a timeout_ms includes that lookup, connection setup, TLS, and the response.

OTEL

OTEL emission is a codec-free direct sink for OTLP logs, traces, and metric data points. It uses a TYPE OTEL client and supports both OTLP/gRPC and OTLP/HTTP-protobuf. The protocol is always explicit; there is no hidden default:

CREATE CLIENT otel_main
  TYPE OTEL
  CONFIG {
    'endpoint' = 'http://127.0.0.1:4317',
    'protocol' = 'grpc',
    'headers' = 'authorization=Bearer <token>',
    'compression' = 'gzip',
    'timeout_ms' = 5000
  };

endpoint and protocol are required. protocol is exactly grpc or http/protobuf. headers is the OTLP comma-separated key=value form, compression accepts only gzip, and an absent compression key sends an uncompressed request. timeout_ms is an optional positive request bound. For http/protobuf, Nervix appends /v1/logs, /v1/traces, or /v1/metrics to the endpoint path. Mount TLS files and use tls_ca_file, tls_cert_file, and tls_key_file in the same client; the certificate and key must be supplied together. Both protocols resolve the endpoint’s host through the node’s configured DNS resolver for every new connection, and keep the endpoint’s host for the HTTP authority and the TLS server name. A timeout_ms covers the lookup and connection setup as well as the response, and for grpc it is also the connection timeout. Without it, only a thirty-second limit bounds the lookup, and neither the connection nor the export has a deadline of its own. A failed lookup is an export failure that the emitter retries on its RETRY POLICY.

One log record is mapped as follows:

CREATE EMITTER audit_to_otel
  FROM audit_events
  TO OTEL otel_main LOGS
  VALUES {
    'time' = input.event_ts,
    'severity_text' = input.level,
    'severity_number' = input.level_num,
    'body' = input.message,
    'trace_id' = input.trace_id,
    'span_id' = input.span_id
  }
  ATTRIBUTES {
    'user.id' = input.user_id,
    'audit.action' = leak_sensitive(input.action)
  }
  RESOURCE {
    'service.name' = 'checkout-pipeline',
    'deployment.environment.name' = 'prod'
  }
  SCOPE 'nervix/audit' VERSION '1.0'
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  FLUSH EACH 2s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The clause order is fixed: signal, required VALUES, optional ATTRIBUTES, optional RESOURCE, optional SCOPE '<name>' [VERSION '<version>'], then MODE. RESOURCE values must be literals or literal arrays. ATTRIBUTES may use exact-typed STRING, BOOL, integer-family, F32, F64, DATETIME, or array values; datetimes become RFC 3339 strings. Null attribute values are omitted. Normal sensitivity rules apply to every expression, including ATTRIBUTES.

The closed log VALUES set is:

  • time: required DATETIME
  • body: required STRING
  • severity_text: optional STRING
  • severity_number: optional I32 in 0..=24
  • trace_id: optional nonzero 32-hex-character STRING
  • span_id: optional nonzero 16-hex-character STRING

Trace emitters use TO OTEL <client> TRACES. Their closed VALUES set requires trace_id (nonzero 32-hex STRING), span_id (nonzero 16-hex STRING), name (STRING), start_time (DATETIME), and end_time (DATETIME). Optional keys are parent_span_id (nonzero 16-hex STRING), kind (SERVER, CLIENT, INTERNAL, PRODUCER, or CONSUMER), status_code (OK, ERROR, or UNSET), and status_message (STRING). Enum strings are case-sensitive.

Each metric emitter defines exactly one metric stream:

TO OTEL otel_main
METRIC 'http.server.request.count' UNIT '1'
DESCRIPTION 'Completed HTTP requests'
SUM MONOTONIC DELTA
VALUES {
  'time' = input.window_end,
  'start_time' = input.window_start,
  'value' = input.request_count
}
ATTRIBUTES { 'http.route' = input.route }
RESOURCE { 'service.name' = 'checkout-pipeline' }
MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s

Metric shapes are GAUGE, SUM [MONOTONIC] (DELTA | CUMULATIVE), and HISTOGRAM (DELTA | CUMULATIVE). DESCRIPTION is optional and follows the required UNIT. Gauge and sum points require time (DATETIME) and value (an exact integer-family, F32, or F64 value). Integer values use OTLP as_int; floating-point values use as_double. start_time is required for a delta sum and optional otherwise.

Histogram points require time, integer-family count, integer-array bucket_counts, and F32 or F64 array explicit_bounds. Optional keys are start_time, numeric sum, min, and max; start_time is required for delta histograms. At runtime, counts must be non-negative and len(bucket_counts) must equal len(explicit_bounds) + 1. Exponential histograms and summaries are not supported.

Without BATCH, each pending Arrow batch becomes one Export request containing one resource, one scope, and all successfully converted records. With BATCH, the connector keeps that resource and scope in every request, takes successfully converted records in source order, and divides them by MAX MESSAGES and the exact protobuf size of each Export request. When a candidate exceeds MAX SIZE, it is halved until each request fits, and a record whose request alone exceeds it follows ON MESSAGE ERROR as a validation error of the encode operation. The byte limit measures the uncompressed protobuf request before optional gzip; HTTP and gRPC framing and headers are outside it. FLUSH ... MAX BATCH SIZE continues to measure the Arrow batch.

Nervix prepares each Export request once. It stamps log observed_time_unix_nano from the node’s actual UTC clock when it prepares the request, and keeps the request’s exact protobuf bytes, and the records they carry, until the receiver answers for it. A request whose outcome Nervix did not learn — the connection closed before the answer, the request timed out, or the receiver asked for a retry — is sent again with the same bytes: the same records in the same order, the same resource, scope and observed timestamps, compressed the same way. The retry follows the requests the receiver already answered, never sends them again, and never regroups the kept records with records that arrived later. A receiver therefore sees a duplicate only as a repeat of a request it may already hold.

Connection failures, timeouts, lost responses, HTTP 429 and 5xx, gRPC RESOURCE_EXHAUSTED, and the gRPC codes the OTLP specification lists as retryable — CANCELLED, DEADLINE_EXCEEDED, ABORTED, OUT_OF_RANGE, UNAVAILABLE and DATA_LOSS — retry with backpressure. Retry-After and gRPC RetryInfo can extend the declared retry delay. Bad IDs, enum strings, severity values, numeric ranges, or histogram shapes reject only the affected record through ON MESSAGE ERROR. HTTP 400 and gRPC INVALID_ARGUMENT reject every record in that request without retry, with one shared error reference. Any other HTTP status or gRPC code the receiver answers with means the endpoint cannot accept the export as configured, and the emitter’s unresolved records follow ON MESSAGE ERROR. OTLP partial_success cannot be retried safely: Nervix acknowledges every record and logs a warning, so records rejected by the receiver in that response are lost. For a local conversion failure, the rejected record identifies its otel.<key> field and gives a bounded reason without quoting that field’s value. The conversion cause stays in the connector’s internal report; the failure does not reject other records in the request.

Nervix does not provision collectors, indexes, tenants, or vendor-side telemetry objects. The OTLP endpoint must already exist; an unreachable endpoint remains an initialization or publish error.

ClickHouse

CREATE EMITTER to_ch
  FROM notifications
  TO CLICKHOUSE clickhouse_client INSERT TO TABLE my_table
  VALUES {
    "clickhouse_user_id" = input.user_id,
    "clickhouse_now" = NOW(),
    "clickhouse_action" = LOWER(input.action)
  }
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  BATCH MAX MESSAGES 500 MAX SIZE 8MiB
  FLUSH EACH 10s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

ClickHouse clients use the HTTP endpoint:

CREATE CLIENT ch
  TYPE CLICKHOUSE
  CONFIG {
    'addr' = 'http://127.0.0.1:8123',
    'user' = 'default',
    'password' = 'nervix',
    'timeout_ms' = 5000
  };

Optional config keys are 'user', 'password', 'database', and 'timeout_ms'. The timeout bounds both sending an insert body and waiting for ClickHouse to finish the insert and return its result. For HTTPS endpoints, mount a TLS resource and set 'tls_ca_file' to the mounted CA path.

The client resolves the host in addr through the node’s asynchronous resolver for each new connection and tries the answers in order; a literal IPv4 or IPv6 address is dialled as written. Every request keeps addr as its authority, and over HTTPS the server certificate must name that host, whichever address accepted the connection. The connection, lookup included, is made while the insert waits for its result, so timeout_ms bounds it too. A missing name, a silent name server or an unreachable answer fails the insert, which the emitter retries on its RETRY POLICY; nothing is acknowledged until ClickHouse returns the insert’s result. A pooled connection stays in use when its host’s answer changes, and the next connection resolves again.

ClickHouse requires the batching clause. A flush is written as inserts of at most MAX MESSAGES rows whose JSONEachRow body is at most MAX SIZE bytes, as Database writes describes, and each successful insert is an acknowledgment. ClickHouse writes each JSONEachRow line directly from the mapped Arrow columns in VALUES order, using the same typed JSON column writer as schemaful JSON emission. Null mapped values are written as null, including null list elements. Column names are escaped once for each write, and string values use their carrier’s escape classification. F32 columns keep ClickHouse’s JSON number formatting after widening to F64; schemaful JSON codecs format F32 directly. A BYTES value is written as its own octets, which a String column stores unchanged, where schemaful JSON codecs write padded base64. For ClickHouse, Postgres, and MySQL, a failed multi-row insert is classified first as record-specific or infrastructure-wide. Infrastructure failures retry with backpressure. A record-specific failure is isolated by re-executing the insert one record at a time so healthy rows land and only poison rows follow ON MESSAGE ERROR. Isolation can reapply rows from the failed insert; use the sink’s idempotent write facilities where available. Dividing a flush into inserts also means one flush is not an atomic database transaction. For a rejected row, the error names a safe ClickHouse error name, Postgres SQLSTATE, or MySQL SQLSTATE and code. Destination response text can quote a bound value, so it is not included in diagnostics. Connection and pool failures retain their causes for diagnosing a retry.

Postgres

CREATE EMITTER to_pg
  FROM notifications
  TO POSTGRES postgres_client INSERT TO TABLE my_table
  VALUES {
    "postgres_user_id" = input.user_id,
    "postgres_now" = NOW() AS STRING,
    "postgres_action" = LOWER(input.action)
  }
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  BATCH MAX MESSAGES 500 MAX SIZE 8MiB
  FLUSH EACH 10s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Postgres emitters use VALUES expressions and insert batches with INSERT ... SELECT ... FROM unnest(...). The batching clause is required: each insert carries at most MAX MESSAGES records, and its statement text and bound arrays measure at most MAX SIZE bytes, as Database writes describes. The insert result acknowledges those records. On the poison-isolation path, tables without an idempotent ON CONFLICT policy may observe duplicates when healthy records are re-executed.

Postgres emitters may include an insert conflict policy after VALUES:

ON CONFLICT ("postgres_user_id") DO UPDATE
ON CONFLICT ("postgres_user_id") DO NOTHING
ON CONFLICT DO NOTHING

DO UPDATE updates every mapped VALUES column except the conflict target columns, and requires a conflict target. DO NOTHING may be used with or without a target. Postgres refuses a DO UPDATE insert that carries one conflict key twice, so such an insert is written again one record at a time, and the later record updates the earlier one.

Postgres clients declare their connection-pool bounds and connect with a postgres:// or postgresql:// URL. The URL must select one of two TLS policies: sslmode=disable for an unencrypted connection, or sslmode=verify-full for TLS with certificate-chain and hostname verification. There is no opportunistic fallback and no encrypted connection without peer verification. See Database Client Connection Pools for the accepted pool counts:

CREATE CLIENT pg
  TYPE POSTGRES
  POOL SIZE MIN 2 MAX 8
  CONFIG {
    'addr' = 'postgresql://postgres:nervix@127.0.0.1:5432/postgres?sslmode=disable'
  };

For TLS connections, use sslmode=verify-full, mount a TLS resource, and set 'tls_ca_file' to the mounted CA path. 'tls_cert_file' and 'tls_key_file' supply a client identity and must be given together. TLS files require sslmode=verify-full.

MySQL

CREATE EMITTER to_mysql
  FROM notifications
  TO MYSQL mysql_client INSERT TO TABLE my_table
  VALUES {
    "mysql_user_id" = input.user_id,
    "mysql_now" = NOW() AS STRING,
    "mysql_action" = LOWER(input.action)
  }
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  BATCH MAX MESSAGES 500 MAX SIZE 8MiB
  FLUSH EACH 10s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

MySQL emitters use VALUES expressions and insert batches with a multi-row INSERT ... VALUES (?, ...), ... command. The batching clause is required: each insert carries at most MAX MESSAGES records and no more than fit the 65,535 placeholders one statement binds, and its statement text and bound values measure at most MAX SIZE bytes, as Database writes describes. The insert result acknowledges those records. Conflict clauses are the user’s tool for bounding duplicates when poison isolation re-executes a failed insert. Declare a MAX SIZE no larger than the server’s max_allowed_packet, 64 MiB by default on MySQL 8.x, which the client applies to every packet it sends.

MySQL emitters may include an insert conflict policy after VALUES:

ON CONFLICT DO UPDATE
ON CONFLICT DO NOTHING

MySQL and MariaDB resolve conflicts through primary and unique keys already defined on the table, so the NSPL conflict policy does not accept a target list. DO UPDATE uses ON DUPLICATE KEY UPDATE for all mapped VALUES columns. DO NOTHING uses a no-op duplicate-key update.

MySQL clients declare their connection-pool bounds and use a mysql_async connection URL. See Database Client Connection Pools for the accepted counts:

CREATE CLIENT mysql
  TYPE MYSQL
  POOL SIZE MIN 2 MAX 8
  CONFIG {
    'addr' = 'mysql://nervix:nervix@127.0.0.1:3306/nervix'
  };

For TLS connections, include require_ssl=true, mount a TLS resource, and set 'tls_ca_file' to the mounted CA path.

MongoDB

CREATE EMITTER to_mongodb
  FROM notifications
  TO MONGODB mongodb_client INSERT TO COLLECTION my_collection
  VALUES {
    "mongodb_user_id" = input.user_id,
    "mongodb_now" = NOW() AS STRING,
    "mongodb_action" = LOWER(input.action)
  }
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  BATCH MAX MESSAGES 500 MAX SIZE 8MiB
  FLUSH EACH 10s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

MongoDB emitters use VALUES expressions and bulk writes. The batching clause is required: each write carries at most MAX MESSAGES documents, which measure at most MAX SIZE bytes, as Database writes describes. MongoDB reports per-document outcomes, so healthy documents acknowledge and poison documents follow ON MESSAGE ERROR without a separate isolation pass. Transient or infrastructure failures retry only the undelivered documents. A document larger than MongoDB’s 16 MiB document limit is rejected before the write that would carry it.

Every mapped integer is written as a BSON 64-bit signed integer, and a U64 value above that range has no BSON integer at all. Such a record is rejected before its document is written: it follows ON MESSAGE ERROR with a validation error whose affected field is mongodb.<column>, it is never inserted, and its value is never used as an ON CONFLICT target. The rejection is permanent, so the record does not retry. Every other record in the same write is unaffected, and a mapped value that is genuinely NULL is still written as BSON null.

MongoDB emitters may include an insert conflict policy after VALUES:

ON CONFLICT ("mongodb_user_id") DO UPDATE
ON CONFLICT ("mongodb_user_id") DO NOTHING

MongoDB conflict policies require a target list because the emitter must build an explicit upsert filter. Target fields must be mapped in VALUES. DO UPDATE updates every mapped field except the conflict target fields and inserts the full mapped document when no existing document matches. DO NOTHING inserts only when no document matches the target.

Emitters using either MongoDB ON CONFLICT form require MongoDB 8.0 or newer because those modes execute each write as one client bulk write.

MongoDB clients declare their connection-pool bounds and use a MongoDB connection URL and database name. See Database Client Connection Pools for the accepted counts:

CREATE CLIENT mongodb
  TYPE MONGODB
  POOL SIZE MIN 2 MAX 8
  CONFIG {
    'addr' = 'mongodb://root:nervix@127.0.0.1:27017/nervix?authSource=admin',
    'database' = 'nervix'
  };

For TLS connections, include tls=true, mount a TLS resource, and set 'tls_ca_file' to the mounted CA path.

Iceberg

CREATE CLIENT s3_main
  TYPE S3
  CONFIG {
    'endpoint' = 'http://127.0.0.1:9900',
    'region' = 'us-east-1',
    'access_key_id' = 'rustfsadmin',
    'secret_access_key' = 'rustfsadmin',
    'path_style_access' = true
  };

CREATE CLIENT iceberg_catalog
  TYPE ICEBERG_REST
  CONFIG {
    'uri' = 'http://127.0.0.1:8181',
    'warehouse' = 's3://nervix-iceberg/warehouse'
  };

CREATE EMITTER iceberg_notifications
  FROM notifications
  TO ICEBERG ON S3 s3_main TABLE notifications
  VALUES {
    'user_id' = input.user_id,
    'action' = input.action
  }
  LOCATION 's3://nervix-iceberg/tables/notifications'
  CATALOG iceberg_catalog COMMIT EACH 1m MAX SIZE 512MiB
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  FLUSH EACH 10s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Iceberg emitters use explicit VALUES expressions and do not declare ENCODE USING. The ON S3, ON GCS, or ON AZURE_BLOB backend clause selects the object-store implementation. The referenced blob client supplies the object-store connection for table files. The CATALOG <client> clause references a separate TYPE ICEBERG_REST client that supplies the REST catalog URI and warehouse. The referenced REST catalog namespace and table must already exist; Nervix loads that table and appends data, but does not create catalog namespaces or tables implicitly. The emitter owns the Iceberg table name, mapped output columns, table location, catalog client reference, and flush policy.

The catalog and object-store HTTP clients resolve endpoint names through the node’s configured DNS resolver. The catalog keeps its configured URL and authentication, while OpenDAL uses the resolver for object operations and credential HTTP calls. DNS failures enter the existing sink initialization or commit retry path; they do not advance the commit or ACK boundary.

GCS uses the same emitter shape with a TYPE GCS client and gs:// locations:

CREATE CLIENT gcs_main
  TYPE GCS
  CONFIG {
    'service_path' = 'https://storage.googleapis.com',
    'token' = '<oauth2-token>'
  };

CREATE CLIENT iceberg_catalog
  TYPE ICEBERG_REST
  CONFIG {
    'uri' = 'https://iceberg-rest.example.com',
    'warehouse' = 'gs://nervix-iceberg/warehouse'
  };

CREATE EMITTER iceberg_notifications
  FROM notifications
  TO ICEBERG ON GCS gcs_main TABLE notifications
  VALUES {
    'user_id' = input.user_id,
    'action' = input.action
  }
  LOCATION 'gs://nervix-iceberg/tables/notifications'
  CATALOG iceberg_catalog COMMIT EACH 1m MAX SIZE 512MiB
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  FLUSH EACH 10s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Azure Blob uses TYPE AZURE_BLOB and wasbs:// locations. wasb:// is also accepted for plain-HTTP local endpoints:

CREATE CLIENT azure_main
  TYPE AZURE_BLOB
  CONFIG {
    'account_name' = 'myaccount',
    'account_key' = '<account-key>'
  };

CREATE CLIENT iceberg_catalog
  TYPE ICEBERG_REST
  CONFIG {
    'uri' = 'https://iceberg-rest.example.com',
    'warehouse' = 'wasbs://nervix-iceberg@myaccount.blob.core.windows.net/warehouse'
  };

CREATE EMITTER iceberg_notifications
  FROM notifications
  TO ICEBERG ON AZURE_BLOB azure_main TABLE notifications
  VALUES {
    'user_id' = input.user_id,
    'action' = input.action
  }
  LOCATION 'wasbs://nervix-iceberg@myaccount.blob.core.windows.net/tables/notifications'
  CATALOG iceberg_catalog COMMIT EACH 1m MAX SIZE 512MiB
  MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
  FLUSH EACH 10s MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The REST catalog is the authority for namespace and table metadata. Nervix does not write a separate object-store catalog pointer file and does not provision catalog entries from the emitter runtime path.

Iceberg uses two explicit boundaries. FLUSH collects typed in-memory batches and writes them to local Arrow IPC files under the runtime temporary-file root. COMMIT EACH <duration> MAX SIZE <bytes> reads the staged Arrow IPC batches, concatenates them into one Arrow batch, appends that batch to the Iceberg table, and commits the catalog update. The commit duration starts when a flush stages rows while none are staged, and rows later flushes stage join that commit without moving it, so a row waits at most FLUSH EACH in memory and then at most COMMIT EACH after it is staged. Staged files that reach MAX SIZE are committed at once. Both durations are domain-logical, so a paced domain’s TIME RATE moves the flush and commit boundaries together; the maximum batch and commit sizes, the catalog retry backoff, and a drain remain independent of domain pace. The temporary-file root defaults to /tmp and can be changed with --temp-dir or NERVIX_TEMP_DIR.

The sink completion point for MODE ACK is the successful catalog commit. Local staging is not an ACK boundary. Commit conflicts, incompatible table evolution, a dropped table, and unavailable catalog or object storage are table-level infrastructure failures: Nervix reports them in runtime and drain status and retries all affected staged records with the declared policy and backpressure. Record-level expression and construction errors are attributed through ON MESSAGE ERROR before staging. An ambiguous failure after the catalog commit can append the rows again; Iceberg appends are not idempotent. See ACK Semantics And Effective Delivery for attachment and fan-out behavior.

Codec Behavior On Emission

ENCODE USING <codec> follows the sink it encodes for, because whether a codec applies is a property of the sink. Kafka, Pulsar, RabbitMQ, Redis, MQTT, NATS, ZeroMQ, SQS and Sentry publish an encoded payload and require it. ClickHouse, Postgres, MySQL, and MongoDB map columns with VALUES and do not take a codec. OTEL maps signal fields and attributes with VALUES and takes no codec. Iceberg writes typed records and takes none.

JAQ-native codecs can reshape outbound payloads with ON EMITTING before writing the selected format:

CREATE IF NOT EXISTS CODEC notification_codec
  FROM JSON
  TO SCHEMA notification
  WITH JAQ TRANSFORMATIONS ON EMITTING '{payload: .}';

That lets the emitter publish a different JSON envelope for each outbound row without changing the declared relay schema.

ACK Semantics And Effective Delivery

Nervix composes per-hop ACKs. The effective delivery semantics of a source-to-sink path are the observable duplicate and loss behavior produced by the source delivery mode, the emitter’s publishing MODE, its attachment, and the external service. They are not the ACK mechanics of any one hop.

Publishing MODE selects the sink completion point at which the emitter considers a record delivered. Attachment determines whether that outcome participates in the upstream ACK chain:

  • ATTACHED: emitter success or failure at the selected sink completion point stays part of the upstream ACK chain.
  • DETACHED: relay fan-out acknowledges upstream immediately. The emitter still waits for its declared confirmations, applies its retry policy, error-routes record failures, and exerts local backpressure, but that outcome cannot delay, retry, or fail the source ACK.

Confirming broker modes and request/response ACK modes are at least once. A confirmation timeout or lost response is not proof that the service rejected a record, so retry can duplicate it. The parallel window limits how many records are exposed to that ambiguity at one time, and Nervix resends only records not yet confirmed or definitively rejected. With the batching clause the unit is the batch payload: a retry resends exactly the payloads not yet confirmed or rejected, with the bytes and members they were first written with. NO_ACK, MQTT QoS 0, Core NATS, Redis Pub/Sub, and ZeroMQ expose earlier acceptance boundaries and can lose acknowledged records after a crash or downstream failure. External broker durability and idempotence settings remain the user’s client and service configuration.

When one source record reaches multiple emitters or multiple attached routes, the upstream ACK completes only after every attached emitter reaches its sink completion point. A failure on any attached path reopens source retry for the record on all paths. A sink that already published successfully may therefore receive the record again because a sibling sink failed. This applies to every sink without idempotent writes. Iceberg is the canonical case: rows can be appended to the table again after a sibling emitter fails.

This sibling-retry case assumes a source mode that retries when an attached ACK fails or is lost. A no-ACK source cannot create that retry duplicate, but it can lose the record instead.

A WASM processor on the path adds one more retry of the same kind. It dispatches a guest callback’s output before the checkpoint of the guest’s state completes and holds back only the source ACK, so a checkpoint that fails negatively acknowledges records whose output a sink may already have published, and the source redelivers them. See Recovery, Replay And Duplicates.

Every DETACHED path has a common loss window: a process can fail after relay fan-out acknowledges upstream but before the emitter reaches its declared sink completion point. The table below calls out the additional mode- and transport-specific duplicate and loss conditions.

SinkDuplicate conditions (ATTACHED)Additional loss conditionsIdempotency available in Nervix
KafkaACK retry after an ambiguous delivery report or timeout; either mode after a lost upstream ACK or attached sibling failureNO_ACK can lose a record after local producer-queue admission; broker durability follows Kafka client and topic configurationNone; Kafka producer idempotence is pass-through client configuration
PulsarACK retry after an ambiguous broker receipt; either mode after a lost upstream ACK or attached sibling failureNO_ACK does not expose broker failures after producer acceptance; retention and durability remain broker policyNone
NATSJetStream retry after an ambiguous PubAck; either mode after a lost upstream ACK or attached sibling failureCore NATS NO_ACK connection flush is not durable stream acknowledgementNone
RabbitMQConfirming ACK retry after a nack, timeout, or lost confirm; NO_ACK retry after its channel or connection is lost before a write’s round trip completes; either mode after a lost upstream ACK or attached sibling failureNO_ACK can lose a record after the broker’s channel has taken it; queue durability and message persistence remain broker policyNone
SQSRetry after an ambiguous SendMessage result, lost ACK, or attached sibling failureAny failure after detached relay acceptance; SQS retains its own at-least-once behaviorNone
MQTTQoS 1 or 2 retry after an ambiguous handshake; any mode after a lost upstream ACK or attached sibling failureQoS 0 can lose a record after client acceptance; later delivery follows the configured broker and session guaranteesNone
Redis Pub/SubRetry after Redis accepts PUBLISH but the Nervix ACK is lost, or after attached sibling failureAny failure after detached relay acceptance; subscribers that are absent or disconnected miss the messageNone
ZeroMQRetry after socket send acceptance followed by lost ACK or attached sibling failureAny failure after detached relay acceptance; socket send does not establish durable receiver storageNone
SentryRetry after an ambiguous HTTP result, lost ACK, or attached sibling failureAny failure after detached relay acceptance; an accepted event can still be subject to Sentry service policyNone
OTELRetry after an ambiguous Export result, which resends the same request bytes; lost ACK; or attached sibling failureAny failure after detached relay acceptance; partial_success acknowledges the whole request, so receiver-rejected records in that response are lostNone
HTTPRetry after a lost response, failed connection, or timeout following an endpoint that applied the request; a lost ACK or attached sibling failureAny failure after detached relay acceptance; a 2xx delivers on its headers, so an endpoint that later fails its own processing loses the recordNone; the endpoint can deduplicate by a stable key the emitter writes with write_header
ClickHouseRetry after an ambiguous insert result, lost ACK, or attached sibling failureAny failure after detached relay acceptance; a crash after insert but before acknowledgement can also leave an inserted batch that later retriesNone
PostgresRetry after an ambiguous transaction result, lost ACK, or attached sibling failureAny failure after detached relay acceptance; a committed insert can survive a crash before Nervix observes successON CONFLICT
MySQLRetry after an ambiguous transaction result, lost ACK, or attached sibling failureAny failure after detached relay acceptance; a committed insert can survive a crash before Nervix observes successON CONFLICT
MongoDBRetry after an ambiguous write result, lost ACK, or attached sibling failureAny failure after detached relay acceptance; a committed write can survive a crash before Nervix observes successON CONFLICT
IcebergRetry after a commit with a lost ACK or attached sibling failure; appends repeat rowsCrash before catalog commit loses staged work unless the attached source redelivers; detached mode accepts that lossNone; appends are not idempotent

The publishing-mode table names each transport’s exact completion point. ATTACHED waits only for that declared point and cannot make an earlier NO_ACK boundary durable. MQTT QoS 0, Core NATS, Redis Pub/Sub, and ZeroMQ can still lose a message after Nervix observes client-side acceptance, and RabbitMQ NO_ACK after the broker’s channel has taken it. DETACHED cannot turn a confirming mode into fire-and-forget inside the emitter; it changes only whether the result participates upstream.

Emit a stable idempotency key at ingestion, for example with uuid_v7(), and carry it through the graph. Downstream consumers and queries can use that key to suppress retries within that admitted record’s fan-out. A source-provided identifier is stronger because it also survives a fresh source redelivery. Generate the key once; regenerating it on a downstream route defeats the purpose.

For Postgres, MySQL, and MongoDB, use ON CONFLICT against a stable key. This preserves at-least-once delivery attempts while making the resulting table or collection state effectively-once for that conflict contract.

Iceberg appends are not idempotent. Deduplicate by the stable key at query time or in downstream compaction or MERGE work. Nervix does not isolate sibling-sink retries by changing ACK mechanics. That is an accepted tradeoff.

Accepted Tradeoff: Shared Retry

One input ACK represents all attached descendants. This keeps ACK composition small and preserves backpressure across the graph. It also means one attached failure retries successful siblings. Nervix accepts that coupling instead of maintaining a transactional per-sink commit ledger.

Graph Design

When the same records feed a non-idempotent sink and other emitters, consider DETACHED mode for a non-critical path or separate relays with separate ACK boundaries per sink so one sink’s failure does not drive duplicates into another. DETACHED makes that path at-most-once relative to the upstream ACK: the emitter still confirms and retries according to MODE, but a crash after the detached ACK can lose its in-memory work without causing source redelivery.

See Data Plane for the relay fan-out mechanics and What It Is Not for the persistence boundary.

Placement Policies

Placement policies control where a domain’s schedulable work executes. Use them to keep a latency-sensitive processing corridor on one machine, prefer locality without forcing it, or bias expensive stages across machines.

Nervix distinguishes two kinds of nodes throughout this chapter:

  • A runtime node is a schedulable graph entity such as a relay, ingestor, processor, lookup, generator, or emitter.
  • A cluster node is a machine participating in the Nervix cluster.

A named placement rule marks the directed paths between existing runtime nodes. For example, this rule keeps feature_normalizer, risk_scorer, and every runtime node on a path between them on one cluster node:

CREATE PLACEMENT scoring_local
  FROM feature_normalizer
  TO risk_scorer
  REQUIRE COLOCATION
  RANK 1;

Placement changes assignments, not graph routing. Records still follow the routes declared by the ingestors, processors, and emitters.

Choosing A Policy

The policies occupy one colocation scale; NEUTRAL expresses no opinion on that scale. There is no hard-separation policy.

PolicyScheduling effectExisting assignments
REQUIRE COLOCATIONEvery runtime node within each captured corridor executes on the same cluster node.A newly effective requirement can relocate runtime nodes.
PREFER COLOCATIONNew placement decisions favor the same cluster node.The preference does not move existing assignments by itself.
NEUTRALThe placement rule expresses no preference, so ordinary scheduler heuristics remain active.It does not move assignments by itself.
SUGGEST SEPARATIONNew placement decisions favor different cluster nodes.The suggestion does not move existing assignments by itself.

Use REQUIRE COLOCATION when a remote hop is unacceptable, especially for a short latency-critical path or a materialized-state reader. Use PREFER COLOCATION when locality is valuable but the scheduler should retain freedom to distribute work. Use SUGGEST SEPARATION to reduce the chance that expensive stages share one machine, not to create an isolation boundary.

For new assignments, an explicit soft policy takes precedence over the scheduler’s built-in upstream-locality preference. It remains advisory: cluster availability and the scheduler’s other placement inputs can still produce a different result. NEUTRAL means “no placement opinion”; it does not randomize placement or disable the scheduler’s normal heuristics.

Setting The Domain Default

Every domain has a fallback placement policy. Declare it when creating the domain:

CREATE [IF NOT EXISTS] UNPACED DOMAIN <domain_name>
  [PLACEMENT <policy>];

CREATE [IF NOT EXISTS] PACED DOMAIN <domain_name>
  WITH PERIOD <duration> SKEW <duration>
  [PLACEMENT <policy>];

CREATE DOMAIN remains the short spelling for CREATE UNPACED DOMAIN and accepts the same placement clause. Omitting PLACEMENT selects NEUTRAL.

CREATE UNPACED DOMAIN realtime
  PLACEMENT PREFER COLOCATION;

The default applies to directly connected runtime nodes when no named placement rule claims that relationship. Relays are runtime nodes, so producer-to-relay and relay-to-consumer are separate hops. The default is deliberately per hop:

  • REQUIRE COLOCATION places each connected graph component on one cluster node unless a stronger named rule carves out part of it.
  • PREFER COLOCATION adds a locality preference to each uncovered hop.
  • SUGGEST SEPARATION adds a spreading preference to each uncovered hop.
  • NEUTRAL leaves each uncovered hop to ordinary scheduler behavior.

Change the default for the session’s active domain with the nameless ALTER DOMAIN statement:

ALTER DOMAIN SET PLACEMENT REQUIRE COLOCATION;

Changing a running domain’s default activates a new schedule. A newly effective REQUIRE COLOCATION default can relocate runtime nodes; changing only a soft default does not migrate existing assignments.

Defining A Named Corridor

Create a named rule after every referenced member exists in the active domain. Placements are domain-owned and cannot span domains:

CREATE [IF NOT EXISTS] PLACEMENT <placement_name>
  FROM <runtime_node> [, <runtime_node> ...]
  TO <runtime_node> [, <runtime_node> ...]
  REQUIRE COLOCATION | PREFER COLOCATION | NEUTRAL | SUGGEST SEPARATION
  [RANK <positive_integer>];

Each FROM member is paired with each TO member. Nervix evaluates those directed endpoint pairs independently and applies the policy only where a path exists.

The following user-declared runtime-node kinds are placement-eligible:

  • ingestors, except cluster-wide server-listener ingestors;
  • reingestors and generators;
  • junctions, deduplicators, correlators, reorderers, and window processors;
  • inferencers and WASM processors;
  • emitters and HASH MAP lookups; and
  • every relay, whether or not it has materialized state.

A relay name always denotes its relay runtime node. Enabling materialized state adds scheduler-selected state replicas to that relay; it does not add another placement member or runtime-node kind. See Materialized Relay State for dependency behavior.

HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER ingestors execute on every cluster node and therefore cannot be constrained by a placement rule. An ingestor’s head fixes the pair it reads, so altering an ingestor never changes whether it can be a placement member. Member names are unqualified; a name shared by more than one eligible entity kind is ambiguous and must be changed before it can be used in a placement.

Duplicate names on one side collapse. Both sides must contain at least one member after duplicate removal. The same runtime node may appear in FROM and TO, but it contributes coverage only when the graph contains a directed cycle back to that runtime node.

Understanding Path-Gated Coverage

For each connected FROM/TO pair, the rule captures both endpoints and every runtime node that can carry traffic from the source to the destination. If the graph contains alternate paths, all runtime nodes on those paths are part of the corridor. A cycle within a corridor is captured in full.

For this graph:

feature_normalizer -> enrich_features -> risk_scorer
                   -> validate_features -> risk_scorer

a rule from feature_normalizer to risk_scorer covers all four runtime nodes. The policy applies across the runtime nodes captured by that corridor, not only to its two named endpoints.

Placement follows the relationships that can deliver messages or state:

  • normal output routes;
  • message-error routes;
  • correlation-timeout routes;
  • generator feeds; and
  • materialized-state dependencies, directed from the relay to each reader that declares USING MATERIALIZED STATE.

Relays are corridor members. A producer-to-relay or relay-to-consumer relationship is one placement hop, so a domain default applies independently on both sides and a named path captures relay nodes between its endpoints.

A disconnected endpoint pair contributes no coverage. A rule for which every endpoint pair is disconnected is valid and remains available for inspection with coverage=empty. Nervix does not force unconnected workloads into a group merely because their names occur in one rule.

Separate connected corridors are also evaluated independently. Runtime nodes captured by two different corridors in one rule are not related to each other unless they occur together in at least one corridor.

Coverage is recalculated whenever the graph activates. Adding or removing a route can therefore grow or shrink an existing rule without altering the rule itself. Inspect placement coverage after topology changes, especially when a broad rule uses several FROM or TO members.

Resolving Overlapping Rules

RANK decides which named rule wins where rules overlap. The policy names themselves do not establish precedence: a stronger-ranked NEUTRAL rule overrides a weaker-ranked REQUIRE COLOCATION rule on the relationships they both cover.

Precedence is:

  1. rank 1;
  2. rank 2, rank 3, and subsequent explicit ranks in ascending numeric order;
  3. the unranked tier; and
  4. the domain default.

Every explicitly ranked rule outranks every unranked rule, regardless of the numeric value. All unranked rules share one tier. At the strongest rank affecting a runtime-node relationship, equal-rank rules with the same policy agree. Different policies at that strongest rank conflict, and Nervix rejects the candidate graph activation with the rule names and an affected runtime-node pair. Weaker overridden rules do not conflict with one another.

Effective REQUIRE COLOCATION relationships are transitive. If one requirement joins a to b and another joins b to c, all three form one colocation group. A stronger soft or neutral rule can remove specific hard relationships, but a runtime node remains in the group while any other effective REQUIRE COLOCATION chain still connects it.

For example, first declare a scheduler-managed archive corridor:

CREATE PLACEMENT archive_scheduler_managed
  FROM archiver
  TO cold_sink
  NEUTRAL
  RANK 1;

Then make hard colocation the domain fallback:

ALTER DOMAIN SET PLACEMENT REQUIRE COLOCATION;

The named NEUTRAL rule overrides the default within its connected corridor. It does not guarantee that the archive stages run on different cluster nodes; it returns those relationships to ordinary scheduler heuristics. Use SUGGEST SEPARATION instead when the carve-out should also express a soft spreading preference.

Runtime Behavior

Placement coverage, rank resolution, and colocation groups are recalculated as part of graph activation. A conflicting candidate is rejected before it replaces the active graph.

Every single-owner ingestor keeps its existing primary and replica assignment across schedule recomputation while all assigned cluster nodes are live. This includes outbound WebSocket-client ingestors. Model changes, cluster-node joins and uncordons, and soft placement changes therefore do not migrate those ingestors. HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER ingestors are the only ingestors that instead follow live cluster membership, because their server listeners execute on every cluster node.

When a new REQUIRE COLOCATION relationship becomes effective, Nervix consolidates the complete colocation group onto one eligible cluster node. Existing assignments are preserved only when they satisfy the hard requirement. On a running domain, Nervix plans every relocation before holding the graph, gates all moved groups together, drains their admitted work, and publishes the placement model and assignments in one schedule update. A timeout leaves both the placement model and schedule unchanged. The immediate command, or the aggregate COMMIT result for a transaction, reports quiesce level: ENTITY_PAUSE and the number of planned relocations. A change with no owner move reports DYNAMIC.

PREFER COLOCATION and SUGGEST SEPARATION affect new placement decisions, including newly created runtime nodes and targets selected during failover or drain. They never move an existing assignment merely to improve a preference. NEUTRAL contributes no placement score. Removing a hard requirement also does not spread runtime nodes that are already on the same cluster node.

Failover and drain relocate a hard colocation group as one unit, so an executing assignment never violates REQUIRE COLOCATION. A cordoned cluster node is not considered for a new primary or ordinary replica assignment. During a successful planned move, however, a live former primary is kept as the first replica when the configured replica count provides a slot. See Control Plane for the gate, drain, failure, and activation behavior.

Explicit relocation with RELOCATE is the third planned ownership change: it moves a selected subgraph onto a cluster node you name, through the same handoff, and leaves every runtime node outside its unit untouched.

Drain, colocation consolidation, and explicit relocation use a planned handoff and restart only runtime nodes whose primary owner changed. A runtime node that keeps its assignment continues on every cluster node, including the former and new owners of a moved neighbor: it keeps buffered work, retained REQUIRED WAIT messages, branch-local state, and external source sessions. Its neighbors re-point at the committed revision. Draining a host moves hard groups and independent nodes one at a time in canonical kind/name order. Failover uses immediate termination because the former owner is already unavailable.

Placement constrains primary owners. It does not control replica count or replica placement. An ordinary relay has no replicas; a materialized relay’s replicas contain only materialized state. Branches also have no separate placement dimension: every concrete relay branch is owned by its relay owner, and every processor branch executes within that processor’s assignment.

Relocating Runtime Nodes

Placement rules constrain where the scheduler puts work. RELOCATE moves chosen work onto a cluster node you name, and DESCRIBE RELOCATION shows the plan a statement would execute without executing it:

RELOCATE <selection>
  ONTO NODE <node_id>
  FOLLOW PREFERENCES | IGNORE PREFERENCES
  [FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];

DESCRIBE RELOCATION <selection>
  ONTO NODE <node_id>
  FOLLOW PREFERENCES | IGNORE PREFERENCES
  [FOR <kind> <name> FOLLOW PREFERENCES | IGNORE PREFERENCES ...];

DESCRIBE RELOCATION accepts exactly the clauses of RELOCATE after the verb, so a plan can be inspected and then executed by changing one word.

Selecting The Subgraph

A selection is either an explicit list or a directed corridor, using the same path-gated coverage named placement rules use:

RELOCATE JUNCTION risk_scorer ONTO NODE node-2 FOLLOW PREFERENCES;
RELOCATE FROM JUNCTION feature_normalizer TO JUNCTION risk_scorer
  ONTO NODE node-2
  FOLLOW PREFERENCES
  FOR DEDUPLICATOR dedup_txns IGNORE PREFERENCES;

Every member is kind-qualified, because a name is unique only within its kind. The kind uses the same spelling as SHOW CREATE <kind> <name>:

<kind>Runtime node
INGESTORAn ingestor other than an endpoint or Syslog source.
REINGESTOR, GENERATORThe named reingestor or generator.
JUNCTION, DEDUPLICATOR, CORRELATOR, REORDERER, WINDOW PROCESSORThe named processor.
INFERENCER, WASM PROCESSORThe named processor.
EMITTERThe named emitter.
HASH MAPThe named lookup; reported as kind lookup in schedule output.
RELAYThe named relay, materialized or not.

Any other kind keyword is a parse error. Names resolve in the session’s active domain, duplicate members collapse, and an ingestor with several INSTANCES is one runtime node whose instances move together. A corridor whose every endpoint pair is disconnected is rejected; a partially connected corridor is valid and the plan reports each pair’s connectivity. The destination must be a Raft voter that is live and not cordoned.

Building The Unit

The set of hard groups a statement moves is its unit. REQUIRE COLOCATION is not a preference: a selected runtime node always moves with its whole hard group. Soft preferences are shaped by the strategy:

  • FOLLOW PREFERENCES moves each PREFER COLOCATION partner of the group along and holds each SUGGEST SEPARATION partner out of the unit.
  • IGNORE PREFERENCES moves the group and lets its preferences shape nothing: its partners are neither captured nor held back by it.

The strategy after ONTO NODE is mandatory and applies to every hard group. A FOR clause names one runtime node and sets the strategy of that runtime node’s whole hard group, because a group cannot be moved in pieces and its preferences are one set. Two FOR clauses that disagree within one hard group are rejected, and a FOR clause naming a runtime node outside the plan is rejected.

Capture is transitive and follows the statement default. A captured group inherits that default, so FOLLOW PREFERENCES closes the unit over PREFER COLOCATION relationships until a FOR ... IGNORE PREFERENCES cuts the chain, while IGNORE PREFERENCES with a FOR ... FOLLOW PREFERENCES opens capture at one group only. Under a PREFER COLOCATION domain default, a FOLLOW PREFERENCES relocation therefore moves the whole connected component reachable from the selection. Soft preferences never cause a rejection on their own; the plan reports every preference the move leaves unsatisfied.

Reading The Plan

Both statements return the same block, and RELOCATE prefixes it with the executed outcome and appends the hold duration:

relocation onto node 'node-2'
quiesce level: ENTITY_PAUSE
gated relays: normalized_features, scored_events
coverage:
- junction feature_normalizer -> junction risk_scorer connected=yes covered=2
unit:
- kind=junction name=feature_normalizer group=1 strategy=follow reason=selected owner=node-1 moves=yes replicas=node-1 promoted_replica=no
- kind=junction name=risk_scorer group=1 strategy=follow reason=selected owner=node-1 moves=yes replicas=node-1 promoted_replica=no
- kind=deduplicator name=dedup_txns group=2 strategy=ignore reason=preferred owner=node-2 moves=no replicas=node-3
unsatisfied preferences: 1
- suggest separation risk_scorer <-> archive_transform (spread_archive)

coverage: appears for the corridor form only. gated relays: lists the relays the hold gates, or - for a DYNAMIC plan. group numbers the hard groups in the unit and reason is selected, required for a hard-group mate, or preferred for a captured partner. owner is the current owner; a member that moves also reports its new replicas and whether the destination was already a replica. unsatisfied preferences counts, after the move, every PREFER COLOCATION relationship touching the unit whose two owners differ and every SUGGEST SEPARATION relationship touching the unit whose two owners are the same, naming the winning rule or domain default.

kind and name use the schedule spelling shown by SHOW CLUSTER STATUS, so a HASH MAP member appears as kind=lookup and a RELAY member as kind=relay.

Executing The Move

A relocation in a running domain that moves at least one runtime node is a planned ownership change and executes as one unit, one hold, one commit: the hold gates exactly the relays the plan lists, every moved runtime node drains its admitted work, one schedule write assigns the whole unit to the destination, and the gates open once the destination has activated the revision. The unit is atomic, because the plan is what you inspected and approved; a unit containing a runtime node that cannot drain is excluded with a narrower selection or IGNORE PREFERENCES, or the stall is resolved and the statement retried. See Control Plane for the shared hold, drain, and failure behavior.

Successful relocation returns after the destination has activated the exact schedule revision, the source has drained, and the handoff gates have opened. A following command can use the destination immediately. Cordon and uncordon similarly return after the eligibility state is authoritative; drain and node removal return after resulting ownership and schedules are usable on the remaining live set.

The reported quiesce level is ENTITY_PAUSE when the plan moves at least one runtime node in a running domain, and DYNAMIC when the plan moves nothing or the domain is stopped. A stopped domain keeps its schedule, so its runtime nodes are relocated without a hold and execute on the destination when the domain starts. A unit member whose owner is already the destination is retained in the plan and left untouched; a unit member whose owner is unavailable is rejected until failover has given it a live owner.

For each moved member the primary assignment becomes the destination, and replicas are recomputed against the configured replica count: the former owner first when it is a live schedulable voter, then the member’s existing replicas other than the destination, then the cluster nodes the scheduler would choose. A relay without materialized state has no replicas regardless of the count. Kafka OFFSET BY DOMAIN partition-to-instance assignments are preserved verbatim, and the host of a moved hard group becomes the destination.

RELOCATE is a leader operation that holds the domain’s exclusive alteration lock from planning through release, so a model change, a placement change, another RELOCATE, or a DRAIN NODE of the same domain is rejected with the concurrent-alteration diagnostic rather than queued. DESCRIBE RELOCATION is read-only and is served by any cluster node.

A relocation records no affinity. It is a one-time move, not a pin: a later failover, drain, or newly effective REQUIRE COLOCATION consolidation may move the runtime node again, and Nervix does not move it back when its former owner returns. A preference left unsatisfied by a relocation stays unsatisfied, because Nervix never moves an existing assignment to improve a preference.

Altering And Dropping Rules

ALTER PLACEMENT accepts one or more comma-separated operations:

ALTER PLACEMENT <placement>
    SET POLICY <policy>
  | SET RANK <positive_integer>
  | DROP RANK
  | SET FROM <runtime_node> [, ...] TO <runtime_node> [, ...]
  | RENAME TO <placement_name>
  [, <operation> ...];

DROP PLACEMENT <placement>;

Operations execute in written order, and the complete statement is applied atomically. SET FROM ... TO ... replaces both member lists. This makes it possible to change policy, precedence, membership, and name in one statement:

ALTER PLACEMENT scoring_local
  SET POLICY PREFER COLOCATION,
  SET RANK 2,
  RENAME TO scoring_preferred;

A placement pins the entities named in its FROM and TO lists even when its current coverage is empty. Dropping a referenced runtime node is blocked until every pinning placement is altered or dropped. A change that would make a member ineligible, such as changing an ingestor to an endpoint or Syslog source, is blocked in the same way. Adding or dropping a relay’s materialized state does not change its placement identity.

When a topology edit and its placement update depend on one another, put the complete set of model changes in one explicit transaction and order creation before reference. Nervix validates the resulting candidate graph before publishing it. See Control Plane for transaction and quiesce behavior.

Inspecting Placement

Use the placement commands in the active domain:

SHOW CREATE PLACEMENT scoring_local;
SHOW PLACEMENTS;
DESCRIBE PLACEMENT scoring_local;
DESCRIBE DOMAIN;

SHOW CREATE PLACEMENT renders the normalized stored rule as canonical NSPL and omits the creation-time IF NOT EXISTS modifier. SHOW PLACEMENTS lists each rule’s policy, rank, and one of these coverage states:

CoverageMeaning
emptyNo FROM/TO endpoint pair currently has a directed path.
effectiveAll endpoint pairs are connected and all of the rule’s resulting relationships win rank resolution.
partialOnly some endpoint pairs are connected, or only some resulting relationships remain effective.
overriddenConnected coverage exists, but stronger rules win every resulting relationship.

DESCRIBE PLACEMENT provides the stored form, policy, rank, resolved members, connectivity and covered runtime nodes for each endpoint pair, witness paths for captured intermediates, and the effective policy and winning or overriding rule for each affected relationship. For effective hard requirements it also reports the colocation group and its current host cluster node.

DESCRIBE DOMAIN reports the domain default, named-rule count, and every effective colocation group. Each group includes its runtime-node members, host cluster node, and the effective hard relationships holding it together. Use those relationships to diagnose why a runtime node remains in a larger group after a carve-out.

SHOW CLUSTER STATUS provides the complete scheduled-owner view when you need to compare placement with other runtime nodes outside hard colocation groups.

Troubleshooting

SymptomWhat To Check
A rule shows coverage=empty.Confirm the route direction and that a directed message or state path exists from a FROM member to a TO member. Disconnected rules are valid.
A rule is partial or overridden.Run DESCRIBE PLACEMENT and inspect the winning rule for each affected relationship.
Activation reports a placement conflict.Two overlapping rules have different policies at the strongest rank for an affected relationship. Change one policy or give one rule a stronger rank. A topology edit can expose a previously latent overlap.
SUGGEST SEPARATION still leaves stages together.The policy is advisory and never migrates existing assignments by itself. Re-evaluate it on a new placement decision; do not use it as an isolation control.
A soft-policy change did not move anything.This is expected. Use REQUIRE COLOCATION only when movement is justified by a hard locality requirement.
A runtime node remains in a hard group after a carve-out.Inspect the group’s hard relationships in DESCRIBE DOMAIN; another uncut requirement still connects it to the group.
Dropping or reshaping a member is blocked.Alter or drop every placement named by the diagnostic before changing the member.

Creation also rejects an unknown or ambiguous member, a non-schedulable entity, a cluster-wide server-listener ingestor, an empty side, and RANK 0.

Common Patterns

Keep a latency-critical corridor on one cluster node:

CREATE PLACEMENT fraud_scoring_local
  FROM normalize_transaction
  TO fraud_scorer
  REQUIRE COLOCATION
  RANK 1;

Keep a materialized-state owner with a reader. Here customer_state is a materialized relay and fraud_scorer declares USING MATERIALIZED STATE customer_state:

CREATE PLACEMENT state_read_local
  FROM customer_state
  TO fraud_scorer
  REQUIRE COLOCATION
  RANK 1;

Bias a heavy archive corridor across cluster nodes without guaranteeing isolation:

CREATE PLACEMENT spread_archive
  FROM archive_transform
  TO archive_emitter
  SUGGEST SEPARATION;

Prefer locality for a corridor while retaining scheduler flexibility:

CREATE PLACEMENT prefer_enrichment_local
  FROM decode_events
  TO enrich_events
  PREFER COLOCATION;

Operational Boundaries

Placement is not a capacity planner or a general load-balancing control. A hard colocation group must fit on one cluster node, and Nervix does not use placement rules to calculate CPU or memory capacity. Keep hard groups as narrow as the latency or state-locality requirement allows, then observe host resource use and inter-node delivery latency.

Placement policies do not provide:

  • hard separation or workload isolation;
  • grouping for runtime nodes with no directed path between them;
  • cluster-node labels, availability zones, or pinning to a named cluster node;
  • per-branch placement; or
  • replica-count or replica-placement control.

RELOCATE moves work onto a named cluster node once. It is not pinning: nothing keeps the moved runtime node there, and a later failover, drain, or hard-group consolidation may move it again. It also does not relocate endpoint or Syslog ingestors, an individual replica, an individual branch, or a unit spanning domains, and it never applies a unit in part.

Use SUGGEST SEPARATION as a performance hint only. Security, tenant isolation, and failure-domain requirements need controls outside placement policies.

Capacity Planning For Branched Graphs

Nervix bounds work; the operator sizes it. The runtime provides backpressure for message-batch processing through bounded relay capacity, and it bounds branch population through branch TTL and optional MAX INSTANCES <n> EVICT LRU. The capacity-planning contract also requires observable eviction and resource consumption. Current signals cover process memory, traffic, latency, relay-buffer occupancy, branch population, eviction counts, and branch-local relay inspection, but not per-node state size. Remaining signals are stated in Branch Lifecycle Signals.

Sizing inside those mechanisms is the operator’s responsibility. There is no universal branches-per-node figure. Data rate, flush policy, graph shape, and stateful-node configuration change the answer.

Per-Branch Cost Structure

One live branch instance can hold the following at each graph node that belongs to the branch:

  • branch-local task and runtime-node state;
  • route buffers until the route’s flush boundary;
  • the relay owner’s shared buffer, bounded by CAPACITY across all concrete branches;
  • pending flush timers;
  • deduplication entries retained within MAX TIME;
  • open window state retained by WIDTH and advanced by STEP;
  • reorderer and correlator buffers retained within MAX TIME;
  • for WASM processors, an isolated store and the guest linear-memory pages dirtied by that branch.

FLUSH EACH has a configured byte boundary. FLUSH IMMEDIATE has no size boundary; its pending depth depends on arrivals during the system-owned 100 µs window. See the authoritative flush rule and tuning guidance.

WASM code compilation is shared while mutable guest state is not. See Module Sharing And Branch Memory.

UDF-bearing expressions and JAQ transformations run on the node’s extension workers, which nothing else uses. Heavy UDF or JAQ use and the number of concurrent paths that run them are extension sizing inputs. A native UDF that never returns permanently occupies one extension worker, and never a worker another class needs. See the UDF watchdog consequences.

Bounded Execution And Transient Memory

Every variable-size encode, decode, validation, hash, compilation and program execution a node runs off its asynchronous runtime is admitted into one of seven bounded classes, and is charged against a reserved byte budget before it allocates. Each class has its own admission, so work saturating one cannot take the slots another is entitled to.

ClassConcurrent jobsWork
Control1Control-plane and consensus work: heartbeats, votes, acknowledgements, administrative replies
Credentials1Password hashing and verification for users being created or authenticated
Dataavailable CPUs − 1Per-message relay body encoding, decoding and validation, expressions over batches above 1,024 messages, ingest branch preparation and model inference
Extensionavailable CPUs − 1Operator-supplied code the node cannot bound: UDF-bearing expressions and JAQ transformations
Bulkavailable CPUs − 1Whole-transfer work: resource archives, large read results, and compiling UDFs, WASM modules, protobuf descriptors and ONNX models
Consensus storage1, orderedConsensus storage batches, applied in the order they were admitted
Filesystem storage2Every other synchronous filesystem and database operation, including Iceberg’s staged files

These counts bound admission, not threads. Jobs run on the process-wide blocking pool, so a class’s count is the number of its jobs that may be on that pool at once. A class is guaranteed its share of admission; it is not guaranteed an idle thread. Apart from the classes, only work that waits rather than computes uses the pool: a Kafka producer’s final drain and partition inspection, which wait on the broker, and the one read of the resolver configuration at startup. Each class also holds at most 1,024 jobs waiting for a worker; a job beyond that is refused, and the work that submitted it reports the refusal as its own failure. The one exception is work the node already accepted and keeps: the unfolding of a payload a quiesce buffer retained waits for a place instead, while its payload stays counted in the buffer. So does the unfolding of a poll a paced source handed over, and of a payload a NATS, ZeroMQ or Syslog source read without an acknowledgement. Holding one of those source loops pushes back into its transport, so a node whose extension workers stay full delays polls and lets those transports buffer or drop at their own bounds; see When The Extension Workers Are Full.

Transient memory is 275 MiB per node, divided into ceilings that cannot borrow from each other: 8 MiB for management, 24 MiB for commands and replication, 192 MiB for relay work, 32 MiB for bulk buffers and 19 MiB for credentials. One relay operation may hold at most 32 MiB of encoded body, 32 MiB of decoded data and 16 MiB of conversion scratch, and the relay budget is sized to hold two such operations at once so one blocked channel cannot exhaust the capacity another needs. The relay budget also carries the acknowledgement watches a relay owner keeps for rows another node delivered with acknowledgements: 1 KiB per row until the owner reports that row’s outcome, and 4 KiB per delivery. A delivery of 8 MiB of 164-byte rows therefore needs about 51 MiB of watches beside the 56 MiB its body and decoding reserve. When rows arrive faster than the owner reports them, their watches fill the budget and the owner holds further deliveries back until reported rows return their share. Sustained remote delivery with acknowledgements is then bounded by how fast acknowledgements return to the sending node, not by the budget’s size, and a source that sends there waits instead of failing. The budgets are fixed defaults and do not follow the node’s own memory. The commands budget also holds the 16 MiB working reservation for one normalized consensus state write; half is the keyed payload ceiling and half covers database journal encoding and descriptors. The credentials budget holds the 19 MiB Argon2id works in for one password, so a node computes one hash at a time and a burst of login attempts waits there without touching any other class. A stored hash whose parameters need more memory than that never verifies.

These budgets cover transient work: data in flight between nodes and the working memory of the jobs the classes admit. They are separate from the process memory a running graph holds, which the memory-pressure watermarks govern. The limits are checked against each other when a node starts: a budget that could not hold the largest operation of its class is reported with that class and size rather than discovered by stalling on the first such operation.

Two multipliers usually dominate:

  1. live branch count × per-branch buffered depth;
  2. stateful retention window × per-branch arrival rate.

The second multiplier applies separately to every stateful node. A long deduplication horizon does not pay for a window’s state, and a window width does not bound a correlator.

Sizing Knobs

  • Branch TTL trades branch reuse against how long idle branch-local tasks, buffers, and state remain live. Shorter TTL releases idle branches sooner. Idle time is measured from a branch’s last accepted input, and a physical maintenance scan releases expired branches, so peak branch population reflects the TTL plus that scan latency. See TTL.
  • MAX INSTANCES <n> EVICT LRU trades branch coverage against a hard branch-population ceiling. Eviction drops the least recently used branch and its suspended or buffered branch-local work.
  • Relay CAPACITY trades burst absorption against Arrow batches in the single owner buffer. Smaller capacity applies backpressure sooner. In addition to that buffer, each producer cluster node can hold one batch on its dispatch to the owner and the owner can hold one batch on its dispatch to each remote consumer cluster node. Those fixed slots do not resize with CAPACITY and do not multiply with consumers or subscriptions on the same node.
  • Route FLUSH EACH interval and MAX BATCH SIZE bound how long rows wait and how much a route buffers. MAX BATCH SIZE only clamps a batch and never grows one: the batch a route emits is roughly arrival rate × interval, up to the byte cap. Larger batches reduce per-batch cost only at boundaries that work per batch; see the flush tuning guidance for which sinks those are. FLUSH IMMEDIATE minimizes configured wait but still micro-batches.
  • Cluster interconnect carries each relay batch as one Arrow IPC body with a fixed 32 MiB limit. It reserves one unadmitted batch and one terminal outcome per active logical channel. Channels are concrete-branch local, so waiting work in one branch does not consume another branch’s ordering slot. Management subquotas independently reserve streams for discovery, liveness, replaceable domain-clock progress, relay admission, cancellation, and terminal outcomes even when ordinary management requests are full. Bulk subquotas independently reserve streams for resource transfer and for runtime and Raft snapshot transfer. A runtime state snapshot is described before it is fetched, so a node that already holds the current revision causes no scan and no encoding on the node that owns it. Consensus replication uses one ordered append stream per follower, bounded at 16 outstanding batches and 16 MiB, and charges each batch against the shared command budget for as long as it is unacknowledged. That stream reserves its own replication stream slot, so the ownership handoff requests sharing the replication pool never wait behind it. A Raft snapshot is sealed and moved one bounded section at a time, so neither construction nor transfer grows with the size of the replicated state. Keep MAX BATCH SIZE well below 32 MiB on any route whose consumer may be scheduled on another node. Each node publishes what its pools and budgets are actually holding; see the interconnection metrics for the series to size against.
  • Stateful MAX TIME, WIDTH, and STEP trade history and aggregation coverage against retained entries, open windows, and buffered rows.
  • Source INSTANCES trades source parallelism against concurrent admission pressure. It does not reduce the cost of any branch instance that becomes live.

See the existing FLUSH tuning guidance instead of treating example values as defaults.

What To Watch

  • nervix_jemalloc_allocated_bytes, nervix_jemalloc_active_bytes, and nervix_jemalloc_resident_bytes show process-memory pressure from all workloads on the node.
  • nervix_branch_instances shows the current concrete branch-key population per domain, branch declaration, and physical node.
  • nervix_branch_evictions_total shows LRU pressure and TTL churn through its reason label.
  • Sustained high nervix_relay_buffer_len percentiles show downstream backpressure at the relay owner buffer.
  • High nervix_delivery_latency_seconds percentiles show downstream lag between graph nodes.
  • DESCRIBE RELAY <relay> WHERE (...) confirms whether one concrete branch-local relay exists and reports its buffer metrics when available.
  • DESCRIBE INGESTOR <name> reports memory-backpressure: active|inactive.

Prometheus does not currently expose branch-creation counters, deduplication entry counts, or open-window counts. DESCRIBE also does not provide a branch-population inventory or eviction history.

For runtime ownership and snapshot boundaries, see Data Plane. For the current metric families and cardinality policy, see Metrics And Observability.

Metrics And Observability

Nervix exposes runtime graph metrics in two forms:

  • raw Prometheus metrics at the observability server’s /metrics endpoint
  • owner-routed summarized metrics in DESCRIBE command output

The two surfaces use the same semantic labels where they overlap, but they use separate storage. Prometheus receives raw counters and histogram buckets through a Prometheus registry for external aggregation. DESCRIBE reads Nervix runtime state and includes derived values such as rates and recent percentiles. Relay descriptions are routed to the scheduled relay owner; nonowners do not maintain relay metrics.

Errors And Diagnostics defines the error information that may be reported and its sensitivity and log-level rules. This chapter defines metric surfaces and labels.

Observability Server

The observability listener exposes:

  • /livez: process liveness
  • /readyz: readiness once a leader is known
  • /metrics: Prometheus text output for graph, interconnection, and allocator metrics

Use the node’s observability address, not the data-plane HTTP listener:

curl http://127.0.0.1:<observability-port>/metrics

Prometheus metrics are intentionally branch-aggregated. They aggregate across concrete relay branches and do not include a branch key label. This keeps Prometheus cardinality bounded when a relay is branched by high-cardinality values such as tenant, user, account, or device id. Branch lifecycle metrics identify the declared branch, not the concrete key.

Metric Labels

Graph metric series use these labels:

  • domain: owning domain
  • target_kind: runtime target kind, such as RELAY, INGESTOR, JUNCTION, DEDUPLICATOR, REINGESTOR, WINDOW_PROCESSOR, EMITTER, or LOOKUP
  • target: relay or node name
  • physical_node_id: Nervix cluster node where the metric was observed
  • direction: received or sent
  • stream: logical relay associated with the observation, or - when no relay applies
  • peer_kind and peer: relay peer labels for node-to-relay observations, or - when no peer applies
  • branch: named branch declaration on branch lifecycle metrics
  • reason: branch eviction reason, either lru or ttl
  • le: Prometheus histogram bucket boundary

For a single-input emitter, sent metrics retain that input relay as stream. A multi-input emitter’s received metrics identify the actual source relay, while its sent metrics aggregate the shared sink pipeline with stream="-" because one flush may contain work from several sources.

An emitter’s sent metrics count the records its destination delivered, each once, however many attempts it took: a flush that completes after retries counts its delivered records once, and a record the destination refused follows ON MESSAGE ERROR and is not sent. When a flush fails for good, the records its sink delivered before the failure are counted as its other records are routed to their error policy. A sink that publishes on its own commit, such as Iceberg, counts what each commit publishes. Sent bytes measure a delivered record’s payload as the emitter’s buffer measures it; an HTTP request’s method, target and headers are not payload, and a request without a body carries none. See HTTP inspection and metrics.

DESCRIBE output uses the same concepts but renders physical_node_id as physical_node for readability.

Example Prometheus series:

nervix_messages_total{domain="prod",target_kind="RELAY",target="notifications",physical_node_id="node-1",direction="received",stream="notifications",peer_kind="-",peer="-"} 42

Example DESCRIBE INGESTOR edge metric section:

metrics:
  outgoing_edges:
    messages_total sent relay=notifications physical_node=node-1 total=42 wall_rate_per_sec=12.5 domain_rate_per_sec=10 wall_rate_ema_1m_per_sec=11.2 wall_rate_ema_15m_per_sec=8.7 domain_rate_ema_1m_per_sec=9.8 domain_rate_ema_15m_per_sec=7.4

Raw Metrics

Nervix records these raw metric families:

  • nervix_messages_total: total messages received or sent
  • nervix_batches_total: total batches received or sent
  • nervix_bytes_total: total bytes received or sent
  • nervix_messages_per_batch: histogram of message count per batch. Its finite Prometheus bucket boundaries are 1, 2, 5, 10, 50, 100, 500, 1000, 1024, 2048, 4096, 8192, 16384, 32768, and 65536, followed by +Inf. The rolling HDR histograms used by DESCRIBE track batch sizes through 65,536 messages.
  • nervix_delivery_latency_seconds: histogram of delivery latency between graph nodes
  • nervix_relay_buffer_len: histogram of runtime relay buffer occupancy in queued batches
  • nervix_branch_instances: current concrete branch keys with at least one runtime instance on the physical node
  • nervix_branch_evictions_total: total concrete branches evicted on the physical node, split by reason="lru" or reason="ttl"
  • nervix_ingestor_quiesce_buffered_records: raw payloads currently retained in an ingestor’s per-instance quiesce buffers, including a payload that is draining until its messages enter their ingest group
  • nervix_ingestor_quiesce_buffered_bytes: raw payload bytes currently retained in those buffers
  • nervix_ingestor_quiesce_dropped_total: payloads deliberately discarded by DROP, buffer overflow, memory-pressure zero-capacity behavior, or an interrupting termination
  • nervix_ingestor_quiesce_rejected_total: endpoint requests or connections refused while an ingestor cannot accept them
  • nervix_ingestor_unfolding_refused_total: payloads a source handed over without an acknowledgement that an ingestor did not deliver because the node’s extension workers had no room to unfold them, for a source whose transport would not survive a held loop; see When The Extension Workers Are Full
  • nervix_ingestor_transport_redeliveries_total: pending acknowledgements and rejections an ingestor discarded after its RabbitMQ, SQS or MQTT source reconnected. The broker redelivers what the lost connection did not acknowledge, so each counted position is ingested again
  • nervix_session_subscriptions: open session subscriptions the node delivers from a relay. The node advertises interest in the relay to the cluster exactly while this is above zero, and the series stays at 0 once the relay’s last subscription on the node closes
  • nervix_session_subscription_dropped_rows_total: rows DROPPING session subscriptions on the node discarded because their session could not take them in time
  • nervix_client_ingestor_producers: producers attached to a client ingestor the node executes
  • nervix_client_ingestor_forwarded_producers: those of them whose sessions another node serves
  • nervix_client_ingestor_outstanding_batches: batches those producers submitted that have no outcome yet
  • nervix_client_ingestor_outstanding_bytes: the Arrow IPC bytes of those batches
  • nervix_client_ingestor_admitted_batches: batches holding a slot of the ingestor’s acknowledgement window
  • nervix_client_ingestor_submissions_total: batches the ingestor answered, by outcome and cause
  • nervix_jemalloc_active_bytes: bytes in active allocator pages
  • nervix_jemalloc_allocated_bytes: bytes allocated by the process
  • nervix_jemalloc_mapped_bytes: bytes mapped by active allocator extents
  • nervix_jemalloc_metadata_bytes: bytes dedicated to allocator metadata
  • nervix_jemalloc_resident_bytes: resident data-page bytes mapped by the allocator
  • nervix_jemalloc_retained_bytes: retained virtual-memory mapping bytes

Histograms follow Prometheus conventions and include _bucket, _sum, and _count series. Current bucket boundaries are:

  • messages per batch: 1, 2, 5, 10, 50, 100, 500, 1000, +Inf
  • delivery latency seconds: 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 30, +Inf
  • relay buffer length: 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, +Inf

Prometheus receives raw values only. The /metrics endpoint is encoded by the Prometheus client registry, not by Nervix internal summary state. Prometheus should compute external queries, alerts, and dashboards with normal PromQL aggregation.

The four ingestor-quiesce families, nervix_ingestor_unfolding_refused_total and nervix_ingestor_transport_redeliveries_total use domain, ingestor, and physical_node_id labels. Buffer families are gauges; dropped, rejected, refused and redelivered families are monotonic counters. They are process-local: quiesce buffers do not migrate during termination or failover.

Quiesce, refusal and redelivery children are registered when the ingestor starts, and DESCRIBE INGESTOR registers the redelivery child at zero on the node that runs the ingestor. Client-ingestor outcome children are registered when its endpoint starts, and subscription loss counters when delivery starts. These bounded series may therefore appear at zero before their first observation. Their labels and counting contracts are unchanged.

The two session-subscription families use domain and relay labels and describe the node that exports them: the subscriptions its sessions hold and the rows those sessions lost. A client is told of its own losses directly, as described in Sessions. The server exports no other session metrics; Client Session Protocol describes what clients and operators observe instead.

Client Ingestors

The six client-ingestor families describe the node that executes the ingestor and use domain and ingestor labels. The first five are gauges, rewritten whenever the ingestor’s endpoint changes them, and they are 0 once the endpoint ends. admitted_batches never exceeds the window the ingestor declares, however many producers are attached.

nervix_client_ingestor_submissions_total is a counter with two more labels drawn from fixed sets. outcome is not_admitted, completed, processing_failed, or outcome_unknown. cause is the refusal of a batch that was not admitted (invalid_batch, suspended, busy, draining, producer_ended, credit_exceeded), the failure of one that failed (ack_timeout, rejected), the uncertainty of one whose outcome is unknown (interrupted, owner_lost), or none for a completed batch. No label carries a payload value, a producer, or an attachment identity. A batch the serving session refused before it reached the node that executes the ingestor, such as one beyond its producer’s credit, is answered by the session and not counted here. So is every batch the node that forwards a producer answers itself once it lost the node that executes the ingestor: a batch it never cleared for admission as not_admitted with producer_ended, and a cleared one as outcome_unknown with owner_lost. Ingestors describes the same counts in SHOW INGESTORS and DESCRIBE INGESTOR.

Client Emitters

Client emitter metrics use domain and emitter labels on the executing node. The gauges nervix_client_emitter_consumers, nervix_client_emitter_forwarded_consumers, nervix_client_emitter_forwarded_credit_bytes, nervix_client_emitter_retained_batches, and nervix_client_emitter_retained_bytes describe current in-memory attachments and prepared Arrow IPC awaiting an application decision. nervix_client_emitter_forwarded_retained_batches and nervix_client_emitter_forwarded_retained_bytes count the subset assigned to consumers served on another node. nervix_client_emitter_incomplete_batches counts assigned batches whose application decision is still pending. These gauges return to zero when that endpoint ends. The counters nervix_client_emitter_retries_total, nervix_client_emitter_acks_total, and nervix_client_emitter_rejections_total retain results across endpoint restarts on the same node. Retries include explicit application retry, ACK timeout, and consumer loss; a repeated confirmed ACK does not increment the ACK counter. DESCRIBE EMITTER reports the same values for the scheduled owner.

Restoring a client producer or consumer creates a new server attachment. The attachment gauges fall when the prior session detaches and rise only after the replacement open succeeds; retained batch and incomplete-attempt gauges remain bounded by the existing endpoint budgets. None uses a session, producer, consumer, delivery or attempt identity as a metric label. Applications can sample the Rust handles’ connection() states to distinguish an active, interrupted, restoring or reopen-required handle. An uncertain producer result and an uncertain consumer settlement remain typed call outcomes; these gauges do not imply replay or a confirmed ACK.

Delivery Latency

Every processor, reingestor, and emitter input records the delivery latency of each batch it accepts. A row’s latency runs from its ingestion high watermark to the instant the input accepts the batch. Processors and reingestors read that instant from the domain clock, so in a paced domain their latency is measured in domain time; emitters read the wall clock. A row whose high watermark is later than that instant has no latency and adds nothing to the histograms, although it still counts as a received message.

A batch is recorded as a whole. Each row’s ingestion watermarks travel with the batch in Arrow buffers of Unix-nanosecond timestamps, and one pass over the high watermarks finds the batch’s latest watermark and folds every row’s latency into buckets. Each latency series then takes the whole batch in one update: the rolling histograms behind DESCRIBE merge the buckets under one lock with one wall-clock reading, and the Prometheus histogram takes the batch in one flush.

  • The batch’s latest high watermark is its domain timestamp. It stamps the batch’s traffic counters and places its latencies in the domain-clock windows. A batch older than a window’s current step lands in its own step only while that step is still retained; an older step is never reopened.
  • The rolling histograms record each latency to the nearest millisecond, halves rounding up, and record anything above 30 seconds as 30 seconds, so a slow tail is never dropped from the percentiles.
  • The Prometheus histogram receives every latency in seconds and counts it in the boundaries listed under Raw Metrics; a latency above 30 seconds counts only in +Inf.

Interconnection Metrics

Every node also exposes its own interconnection: the authenticated HTTP/2 pools it carries internal traffic over, the execution admission every variable-size operation passes through, its consensus retention, and the reactor delay they share. These series describe the exporting node only, so they carry no node label, and they are aggregated by dimensions whose value sets are fixed: class, direction, operation, reason, and outcome. None of them carries a branch key, a peer identity, a domain, or an operation identifier.

class is the traffic class on transport series — management, commands, replication, relay, or bulk — and the execution class on admission series — management, commands, relay, bulk, credentials for memory, and control_cpu, credentials_cpu, data_cpu, extension_cpu, bulk_cpu, consensus_storage, filesystem_storage for workers. operation is the reserved request subquota: shared, append, resource, snapshot, discovery, liveness, progress, admission, cancellation, or terminal.

Transport pools:

  • nervix_interconnect_connections: physical connections held, by class and direction
  • nervix_interconnect_streams: HTTP/2 stream slots leased on outbound connections, by class
  • nervix_interconnect_pending_operations: requests admitted and not yet resolved, by direction and operation
  • nervix_interconnect_connections_established_total: connections established, by class
  • nervix_interconnect_connection_failures_total: connections that failed to establish or ended, by class and reason, where reason is setup, handshake, capacity, closed, or resolution for an attempt whose peer’s advertised host did not resolve
  • nervix_interconnect_stream_resets_total: streams that ended without a result, by class and reason, where reason is deadline, capacity, peer, shutdown, or malformed
  • nervix_interconnect_quota_failures_total: requests refused because their reserved subquota was full, by direction and operation
  • nervix_interconnect_requests_total and nervix_interconnect_request_seconds_total: typed requests completed and the round trip they took, by operation. Divide the seconds by the count for average latency; operation="liveness" is the peer health probe, so that ratio is health RTT, and operation="progress" is replaceable fenced domain-clock progress
  • nervix_interconnect_bulk_bytes_total: bytes carried by streamed bodies, by class and direction. This rises while a transfer is running, so it reads as bulk progress

Relay admission:

  • nervix_interconnect_relay_channels, nervix_interconnect_relay_attempts, nervix_interconnect_relay_grants: logical channels with unresolved work, attempts whose outcome has not been retired, and transfer grants issued and not yet spent. These aggregate bounded authenticated peer owners. Ending a peer releases its protocol capacity immediately, including when the application still borrows an admitted intake; that intake’s retained metadata stays charged to relay memory until its last borrow ends. A delivery the receiver holds back because its relay memory has no room for the delivery’s acknowledgement watches stays an unresolved attempt here, with its relay memory near nervix_execution_memory_capacity_bytes, and counts no rejection until it is admitted
  • nervix_interconnect_relay_admissions_total and nervix_interconnect_relay_admission_wait_seconds_total: attempts resolved, by outcome (admitted, rejected, cancelled), and the time they spent between an accepted reservation and their resolution
  • nervix_interconnect_unresolved_outcome_age_seconds: how long the oldest unacknowledged relay outcome has been waiting. A rising value means acknowledgements are not coming back

Execution admission:

  • nervix_execution_memory_capacity_bytes and nervix_execution_memory_reserved_bytes: the transient memory reserved for one class, and what it is currently holding
  • nervix_execution_memory_reservations_total and nervix_execution_memory_rejections_total: charges granted and charges refused outright
  • nervix_execution_workers, nervix_execution_jobs_running, nervix_execution_jobs_pending: how many jobs one class may have on the blocking pool at once, how many it holds, and how many wait
  • nervix_execution_jobs_total, nervix_execution_jobs_completed_total, nervix_execution_job_rejections_total: jobs admitted, jobs that left a worker, and jobs refused because the class already held its whole wait queue
  • nervix_execution_job_queue_seconds_total and nervix_execution_job_work_seconds_total: time admitted jobs spent waiting for a worker, and time completed jobs spent holding one

Node scheduling:

  • nervix_node_scheduler_delay_seconds_total, nervix_node_scheduler_delay_peak_seconds, and nervix_node_scheduler_samples_total: how late the node’s reactor has been polling work that was already runnable. A timer asks to be woken every 250 milliseconds; the excess it is woken by is the delay every other task on that worker paid

Consensus retention:

  • nervix_consensus_log_last_index, nervix_consensus_log_snapshot_index, nervix_consensus_log_purged_index: the highest Raft index the log holds, the highest the current snapshot covers, and the highest already removed. A position that does not exist yet reports -1
  • nervix_consensus_log_retained_bytes: encoded bytes held by the node’s current Raft log entries
  • nervix_consensus_snapshot_pinned_generations, nervix_consensus_snapshot_pinned_readers, nervix_consensus_snapshot_unreferenced_generations: snapshot generations an outgoing transfer holds against deletion, the readers those pins are held for, and generations waiting to be deleted

These series answer two operational questions directly. A class that is saturated shows a reserved byte count at its capacity, or running jobs at its worker count with pending jobs behind them, while the other classes stay free — that is the isolation the pools exist for. A control answer arriving late while bulk work runs shows up as reactor delay and as request seconds rising for the shared or liveness operation, not as a connection failure.

DESCRIBE Metrics

DESCRIBE commands include metric summaries for the described target when metrics exist. For a scheduled target, the request is answered from its owner. Node traffic is grouped under incoming_edges and outgoing_edges; relay descriptions keep owner-buffer utilization under relay_buffers rather than mixing relay traffic into the relay node:

DESCRIBE RELAY notifications WHERE (user_id = 42);
DESCRIBE INGESTOR kafka_notifications;
DESCRIBE JUNCTION route_notifications;
DESCRIBE DEDUPLICATOR dedup_txns;
DESCRIBE REINGESTOR repartition_notifications;
DESCRIBE WINDOW PROCESSOR latency_window;
DESCRIBE EMITTER kafka_notifications_out;
DESCRIBE HASH MAP user_profiles;
DESCRIBE DOMAIN;

DESCRIBE DOMAIN summarizes active-domain traffic from per-node metric state. Its input_output section aggregates ingestor and emitter metrics. Its processed section aggregates metrics for all runtime nodes in the domain, including processing nodes.

DESCRIBE EMITTER reports a status: of OK, WAITING, or ERROR, with a detail: line explaining anything other than ordinary operation. WAITING means the emitter is healthy but holds no connection because it has asked a shared client’s pool for one and has not been given it yet; see Database Client Connection Pools. A full pool is a waiting state, not a failure, so an acquisition timeout or a lost connection is reported as ERROR instead.

DESCRIBE INGESTOR also reports the declared quiesce: policy, current quiesce state:, all four quiesce values and the node’s nervix_ingestor_transport_redeliveries_total, using their Prometheus family names. A connected ingestor in any active mode is reported as status: quiesced, not stopped. Per-payload activity remains at debug or trace log levels and payload values are never logged.

Counter summaries include:

  • total: accumulated raw counter value
  • wall_rate_per_sec: total divided by wall-clock elapsed time since the local series started
  • domain_rate_per_sec: total divided by the observed domain-time span, when records carry domain timestamps
  • wall_rate_ema_1m_per_sec and wall_rate_ema_15m_per_sec: exponentially decayed wall-clock rates
  • domain_rate_ema_1m_per_sec and domain_rate_ema_15m_per_sec: exponentially decayed domain-clock rates

Histogram summaries include:

  • p50_1m, p90_1m, p99_1m: one-minute wall-clock decayed percentiles
  • p50_15m, p90_15m, p99_15m: fifteen-minute wall-clock decayed percentiles
  • domain_p50_1m, domain_p90_1m, domain_p99_1m: one-minute domain-clock decayed percentiles
  • domain_p50_15m, domain_p90_15m, domain_p99_15m: fifteen-minute domain-clock decayed percentiles

Histogram DESCRIBE lines do not include raw count / sum values or rates. For messages_per_batch, the raw observation count is the batch count and the raw sum is the message count; those are already reported clearly by batches_total and messages_total. The histogram answers distribution questions such as typical and tail batch size. Percentiles are rendered as decimal estimates interpolated within the configured histogram bucket range rather than as raw bucket boundary labels. The same rule applies to delivery latency: messages_total and batches_total answer throughput questions, while delivery_latency_seconds answers latency distribution questions.

Relay buffer summaries use relay_buffer_len. The percentile values are queued batch slots observed in the single buffer on the relay owner, and capacity=<n> shows that buffer’s declared bound. A branched relay can hold interleaved concrete branches in the same owner buffer, so DESCRIBE RELAY reports observed lengths as percentiles instead of rendering a separate current depth per branch. The fixed one-batch producer and consumer-node dispatch slots are not additional relay-buffer metric series.

A - value means the derived value is not available. This is common for domain-clock values when no domain timestamp has been observed or when the observed domain-time span is zero.

Branch Lifecycle Signals

Prometheus exposes branch lifecycle state without a concrete branch-key label:

  • nervix_branch_instances{domain,branch,physical_node_id} is a gauge of live concrete branch keys on that physical node. A key is counted once even when multiple local graph nodes hold runtime state for it.
  • nervix_branch_evictions_total{domain,branch,physical_node_id,reason} counts each concrete key once when branch eviction begins on that physical node, even if multiple local graph nodes hold the key. reason="lru" is a MAX INSTANCES ... EVICT LRU capacity eviction; reason="ttl" is idle expiration.

Normal shutdown, schedule replacement, and runtime detachment reduce the live gauge but do not increment the eviction counter. Relay branch presence and its TTL or LRU decisions exist only on the relay owner, so a relay-owner failure loses those live values. A schedule replacement reduces state only for runtime nodes whose assignment changed; a drain, failover, or colocation consolidation leaves unaffected owners continuous. Lifecycle metrics are live process-local Prometheus state and are not persisted or replicated.

Concrete branch-local inspection remains available through DESCRIBE RELAY <relay> WHERE (...). DESCRIBE does not provide a common branch inventory or eviction history. Prometheus deliberately has no branch-key label. See Capacity Planning For Branched Graphs for the cost model.

Wall Clock And Domain Clock

Nervix reports two time bases because they answer different questions:

  • wall-clock rates describe actual handled load per second of real process time
  • domain-clock rates describe records per second of event/domain time

For unpaced domains or records without usable timestamps, domain-clock values may be unavailable. For paced domains, domain-clock values follow the event timestamps and domain pace rather than the speed of test execution or wall-clock ingestion.

The moving rates are online exponential summaries. The percentile windows keep one bounded HDR histogram per step, 10 seconds for the one-minute windows and one minute for the fifteen-minute windows, rather than every observation. Both keep memory bounded and let metric state be snapshotted and replicated without retaining the observations themselves.

Replication And Drain Behavior

Nervix maintains internal metric state for DESCRIBE, edge statistics, and runtime recovery. Stateful processor summaries may use their node-owned snapshot path. Relay metrics are different: the relay owner is their sole authority, and buffer, traffic, concrete-presence, and branch-local relay summaries are neither persisted nor replicated.

DESCRIBE RELAY and DESCRIBE RELAY ... WHERE (...) are routed to that authority. A planned move fences the moved subgraph, drains admitted work, and activates the committed revision before the gate opens. Relay metrics start fresh on the new owner because they do not travel with materialized state. When a state-replica slot exists, a live former primary becomes the first replica candidate after the move.

Only moved runtime nodes cross that boundary. Internal DESCRIBE and edge metrics for unaffected nodes and concrete branches remain continuous during drain, graceful-shutdown drain, and placement consolidation. There is no shutdown-specific metric family: a draining node is identified by its phase log records and by terminating: true in its status, as described in Shutdown And Recovery. A timed-out precommit handoff leaves the schedule and metric authority unchanged. Unexpected owner loss continues through failover immediately and starts fresh relay metrics on the new owner.

Prometheus export is a separate, live process-local registry. Traffic metrics ignore branch identity, while lifecycle metrics retain only the bounded declared branch name. Prometheus registry values are not snapshotted into Nervix internal metric state and do not migrate during a planned handoff or failover.

User-Defined Functions

NSPL user-defined functions (UDFs) are domain-owned functions written in Roto and invoked anywhere an ordinary expression function can be used. They execute in process over Arrow columns: one UDF invocation receives every message of a batch that reaches it, or, inside a conditional arm, only the messages that select that arm, and column methods perform vectorized work without serializing rows.

UDF creation is trusted-code administration, like uploading a WASM resource. Roto code is native JIT-compiled code, not a sandbox for third-party programs.

See Choosing An Extension Tier before choosing between builtins, Roto UDFs, and WASM processors.

Roto execution is synchronous. Nervix therefore runs every UDF-bearing expression on the node’s extension workers, including small Arrow batches and UDFs used while constructing branch keys, so native UDF work never occupies an asynchronous runtime worker or the data workers that relay bodies are encoded and decoded on.

The Roto language itself — syntax, types, functions, and test blocks — is covered by the generated Roto Language Reference, downloaded from upstream at the exact Roto release Nervix embeds for ROTO_0_13; the language is developed at NLnetLabs/roto. The column types and operations available inside a UDF body are the Nervix-provided catalog described on this page, not part of the Roto standard library.

Declaration

CREATE [IF NOT EXISTS] UDF <name>
  WITH ROTO_0_13
  ARGS (<argument> <type> [OPTIONAL], ...)
  RETURNS <type> [OPTIONAL]
  [VOLATILE]
  CODE $roto$
    fn <name>(...) -> ... {
        ...
    }
  $roto$;

A declaration has between one and eight arguments. Argument and return types use the schema type grammar: all scalar types, VEC<T>, and ARRAY<T, n> are accepted. Code is limited to 64 KiB. The declared signature must exactly match the named Roto entry function; implicit casts are never inserted.

Dollar-quoted strings ($$...$$ and $tag$...$tag$) are verbatim and may span lines. They are valid wherever NSPL accepts a string literal.

For example:

CREATE UDF risk_band
  WITH ROTO_0_13
  ARGS (score F64)
  RETURNS STRING
  CODE $roto$
    fn risk_band(score: F64Column) -> StringColumn {
        when_s(score.gt_s(0.9), "critical")
            .when_s(score.gt_s(0.7), "high")
            .otherwise_s("normal")
    }
  $roto$;

The Roto column names for scalar declarations are U8Column, I8Column, U16Column, I16Column, U32Column, I32Column, U64Column, I64Column, F32Column, F64Column, BoolColumn, StringColumn, BytesColumn, and DatetimeColumn. A one-level sequence uses VecU8Column through VecDatetimeColumn, including VecBytesColumn; deeper nesting uses AnyColumn. A bytes column carries Arrow Binary values directly.

Calling UDFs

Calls use the dedicated, case-insensitive udf:: qualifier and compose with fields, literals, builtins, and other call sites:

CREATE JUNCTION scoring FROM raw_events
  UNBRANCHED
  TO scored_events
    SET band = udf::risk_band(message.score)
    WHERE udf::risk_band(message.score) != 'normal'
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

The NSPL compiler validates exact arity and types when the consuming model is created. Use an explicit AS or TRY_CAST conversion when conversion is intended. An unqualified call never resolves to a UDF. The separate namespace allows a UDF name to equal a builtin name without changing either call when a later Nervix version adds builtins. Results retain the maximum sensitivity of their arguments.

Roto test blocks execute during CREATE UDF. If any test rejects, creation fails with Roto test block failed and the UDF is not persisted.

The web console’s Create → Roto UDF form edits ordered arguments and the result with the exact schema types, optional flags, volatility, and a source editor that keeps the function and its test blocks verbatim. It renders the completed Model as canonical NSPL and shows Completed only after compilation and all Roto tests succeed. A rejected test leaves the entered declaration and source in the form for correction.

Nulls, errors, and volatility

OPTIONAL controls boundary nullability; it does not change the Roto column type.

  • A null row in a non-optional argument strictly produces a null result row without exposing that row to the UDF.
  • An optional argument exposes its validity bitmap so the body can use null-aware operations.
  • A non-optional result may contain nulls only for strict-propagation rows or rows carrying a side error. Any other null is a batch error.
  • Checked column operations and reject_where_s add per-row errors to the same channel used by builtins, so the owning route’s ON MESSAGE ERROR policy applies.
  • A Roto trap, invalid result type or row count, or watchdog expiry is a whole-batch error.

The watchdog detects an overrun after native code returns; Roto provides no in-process preemption. A non-terminating UDF occupies one extension worker permanently. The watchdog converts a late completion into a whole-batch error, but it cannot reclaim a worker from native code that never returns. A node whose extension workers are all held, and whose wait queue for them is full, refuses further UDF-bearing executions, which the owning processor’s general error policy handles; the node’s relay, control and bulk work keeps its own workers throughout.

Treat trusted authorship, code review, and passing Roto test blocks as admission policy. The extension class runs one job per available CPU but one, shared with JAQ transformations, and is not an NSPL setting. Limit concurrent UDF-bearing paths or isolate them onto separate Nervix processes when one workload could hold every extension worker. See Capacity Planning For Branched Graphs.

Non-terminating or third-party code must stay on the isolated WASM processor path.

UDFs are deterministic unless declared VOLATILE. Only a volatile UDF can call now(), rand_f64(), or uuid_v4(). now() uses the execution-local domain clock. Deterministic calls may be reused within one compiled expression; volatile calls are evaluated at every occurrence. User code is never executed by constant folding.

Roto column operations

Column methods operate elementwise and preserve the input row count. A base method takes another column of the identical type; an _s method broadcasts a Roto scalar.

The ROTO_0_13 catalog currently includes:

  • integer and floating columns: add, sub, mul, div, their _s forms, eq, lt, gt, their _s forms, and batch min/max;
  • boolean columns: and, or, not, plus string-column select;
  • string columns: trim, contains_s, regexp_replace, and the slow-path get;
  • I64Column.cast_f64() and DatetimeColumn.lt_s(Timestamp);
  • VecStringColumn.contains_s;
  • string coalesce, when_s(...).when_s(...).otherwise_s(...), and reject_where_s;
  • ColumnBuilder.bool, push, push_null, and finish for explicit per-row fallback code;
  • volatile now, rand_f64, and uuid_v4.

Arithmetic overflow, division by zero, and explicit rejection produce per-row errors. Invalid scalar configuration such as a malformed regular expression fails the batch.

Use column operations whenever possible. get and a builder are the explicit slow path for logic that has no vectorized kernel.

Introspection and lifecycle

SHOW UDFS;
DESCRIBE UDF risk_band;
SHOW CREATE UDF risk_band;
DROP UDF risk_band;

DESCRIBE UDF reports the language tag, signature, volatility, content hash, and referencing nodes. SHOW CREATE preserves the Roto source bytes and chooses a safe dollar-quote delimiter. Dropping a UDF is rejected while a stored model references it.

Changing a UDF body means dropping the old UDF and recreating it, and the drop is rejected until every referencing model has been removed. While a node references a UDF, that node’s UDF semantics are fixed by the UDF content hash reported by DESCRIBE UDF. A UDF body change quiesces the domain because its callers are spread across the graph.

Roto source is persisted; native JIT output is rebuilt in memory during domain activation. A server that does not support a stored language tag rejects activation rather than reinterpreting the source.

Nervix does not currently expose per-UDF invocation, latency, or row-error metrics.

Roto Language Reference

Generated at book-build time from the upstream Roto documentation source docs/source/reference/language_reference.md at tag v0.12.0 of NLnetLabs/roto, © NLnet Labs, BSD-3-Clause, lightly normalized from MyST Markdown. Nervix UDFs embed this exact Roto package release. The column operations available inside a UDF body come from the Nervix catalog in User-Defined Functions, not from the Roto standard library.

This section describes the basic syntax of Roto scripts. This is written in a reference style, mostly intended to be used as a cheat sheet, not as an introduction to the language.

Shebang

The first line of a script is allowed to be a shebang; if it starts with #! then it will be ignored.

Comments

Comments in Roto start with // and continue until the end of the line. They can be inserted anywhere in the script and are ignored.

// this is a comment

Block comments are not supported; to create a multiline comment, prefix every line with //.

// one comment line
// another comment line

Literals

Roto supports literals for primitive types:

  • 0, 1, 34, -10, 0xFF etc. for integers
  • 0.0, 10., 10e5, 5E-5 etc. for floating point numbers
  • true and false for booleans
  • 0.0.0.0, 2001:DB8:2CA1:0000:0000:0567:5673:23b5, ::, etc. for IP addresses
  • 0.0.0.0/10 for prefixes
  • AS1234 for AS numbers
  • 'a' for characters
  • "Hello" for strings

Note: Floating point literals need either a ., e or E to distinguish them from integer literals.

Identifiers

Identifiers in Roto can consist of any character from Unicode’s XID_Start character set or an _ followed by characters from XID_Continue. In practice, this means that identifiers can use ASCII, numbers, diacritics and other alphabets, with the restriction that they cannot start with a number. Language keywords are also not valid identifiers.

Identifiers are case-sensitive, so foo is not considered to be equal to Foo or FOO. Every character in the identifier is significant.

Here are some examples of valid identifiers:

  • foo
  • _bar
  • foo_bar
  • foo1234
  • Straße
  • Москва
  • 東京

The following strings are not valid identifiers:

  • 12foo (cannot start with a number)
  • foo.bar (cannot contain .)
  • filter (cannot be a keyword)

Additionally, we have the following conventions:

  • The names of local variables, modules, functions and function arguments should use snake_case, i.e. all lowercase with words separated by _.
  • The names of types and enum constructors should use PascalCase, i.e. each word capitalized. The exceptions are the built-in boolean, integer and floating point types.
  • The names of constants should use SCREAMING_SNAKE_CASE, i.e. all uppercase with words separated by _.
  • A leading underscore can be used to signal that a value is unused.

Local Variables

Local variables are declared with a let statement.

fn greater_than_square(x: i32, y: i32) -> bool {
    let y_squared = y * y;
    x > y_squared
}

Any local variable can be overwritten with an assignment, which is expressed as = without let:

let x = 0;
x = x + 1;

A let-binding can take an optional type annotation for readability or to help with type inference. In the example below, 0 has an unknown type as it can be any integer type, so the type annotation forces it to be u32.

let x: u32 = 0;

Local variables are dropped (i.e., deleted) at the end of the scope where they are declared. A new scope is created with {}, including when that is part of the syntax. For example, the body of an if expression creates a new scope.

let x = true;
if x {
    let y = false;
    print(f"{y}"); // ok!
    // y is implicitly dropped here
}
print(f"{x}"); // ok!
print(f"{y}"); // this is not possible: y is no longer in scope!

Built-in Types

Primitive Types

There are several core types at Roto which can be expressed as literals:

  • bool: boolean
  • u8, u16, u32, u64: unsigned integer of 8, 16, 32 and 64 bits, respectively
  • i8, i16, i32, i64: signed integer of 8, 16, 32 and 64 bits, respectively
  • f32, f64: floating point numbers of 32 and 64 bits, respectively
  • char: character
  • String: string
  • IpAddr: IP address
  • Prefix: IP prefix
  • Asn: AS number

There are many more types available that have more to do with BGP. These are described elsewhere. Note that as for identifiers, Roto types are case-sensitive; writing the String type as STRING or string won't work.

Unit Type

The unit type is a special type written as () with only one value: (). It is the type of expressions that do not have meaningful values to evaluate to. For functions, returning () is equivalent to returning nothing.

Never Type

The never type ! is an uninhabited type, meaning that it cannot be constructed. It appears in code paths that are unreachable. For example, it is the type of a return expression. It can be unified with any other type.

Booleans

The boolean type in Roto is called bool and it has two possible values: true and false. Booleans can be manipulated via several operators such as && (logical and), || (logical or) and ! (logical negation).

Integers

There are several integer types in Roto. This might be familiar to users of languages such as C and Rust, but not for users of Python and similar languages which only have one integer type.

Roto is a compiled language and as such needs to know how many bytes to use for a given integer. Hence, the number of bits are included in the type. The prefix u is used for unsigned (i.e. non-negative) numbers and i for signed integers.

Below is a table of all available integer types.

TypeBitsSignedMinimumMaximum
u88No0255
u1616No065,535
u3232No04,294,967,295
u6464No018,446,744,073,709,551,615
i88Yes-128127
i1616Yes-3276865,535
i3232Yes-21474836484,294,967,295
i6464Yes-9,223,372,036,854,775,8089,223,372,036,854,775,807

Literals for integers can contain underscores for grouping digits. For example, 1_000_000 is a valid integer that is identical to 1000000. Any number of underscores at any point in the literal can be used.

Additionally, integer literals can end with the type of the integer, such as 10u8.

Floating Point Numbers

There are two floating point types: f32 and f64, of 32 and 64 bits length, respectively.

TypeBits
f3232
f6464

Literals for floating point numbers can contain underscores for grouping digits. For example, 1_234.567 is a valid integer that is identical to 1234.567. Any number of underscores at any point in the literal can be used.

Additionally, integer literals can end with the type of the integer, such as 10.2f32.

Characters

A character of a string has type char and represents a Unicode code point. Character literals consist of a character enclosed in single quotes.

let character_a: char = 'a';

Strings

Strings are enclosed in double quotes like so:

let s = "This is a string!";

Strings can be concatenated with +:

let s = "race" + "car"; // yields the string "racecar"

The String type has some useful built-in methods such as String.contains. See the documentation for the String type for more information.

Escape sequences

Strings can contain the following escape sequences:

Escape sequenceEscaped valueCommon name
\0U+0000 (NUL)Nul
\tU+0009 (HT)Tab
\nU+000A (LF)Newline
\rU+000D (CR)Carriage return
\"U+0022 (QUOTATION MARK)Double quote
\'U+0027 (APOSTROPHE)Single quote
\\U+005C (REVERSE SOLIDUS)Backslash

In addition, any Unicode character can be represented by its scalar value. This can be done with \x followed by 2 hexadecimal digits or with \u{...} where the ... is a hexadecimal number representing a Unicode code point.

Finally, Roto will ignore any whitespace after a \ followed by a newline, so long lines can be broken into multiple indented lines without introducing spaces when they are reassembled.

String Formatting

Roto supports a Python-like syntax for string formatting. Any string literal prefixed with f will become a format string (or “f-string”), that interpolates the expressions between { and }. The f-string will insert a call to the to_string method for displaying the value. Therefore, any type implementing a to_string method can be put in an f-string, including registered types.

let x = 10;
print(f"x is {x}");

Output:

x is 10

Arbitrary expressions are allowed to appear in format strings, including other strings and format strings.

let x = 10;
print(f"Twice x is {2 * x}");

print(f"x is {if x > 100 {
    "big"
} else {
    "small"
}}");

Output:

Twice x is 20
x is small

The { and } characters need to be escaped to be used in an f-string by duplicating them: {{, }}.

print(f"x is {{ x }}");

Output:

x is { x }

Lists

A list is a growable array of items. In Roto, all elements of a list must be of the same type. Therefore, the type of a list has a type parameter: List[T]. You can create lists with [ and ] with expressions separated by commas.

let my_list: List[i32] = [1, 2, 3];
let first: i32? = my_list.get(0);
match first {
    Some(first) => print(f"First element was: {first}"),
    None => print("No elements!"),
}

Output:

First element was: 1

The + operator can be used to concatenate two lists.

let a = ["one", "two", "three"];
let b = ["four", "five", "six"];
let concatenated = a + b;

See List[T] for all methods available on lists. Lists are passed by reference, meaning that if we assign a list a to a variable b and we modify a then b will see the same modifications.

Optional Values

Roto does not feature a value like None, null or nil. Instead, it has optional values. The type of an optional value is written T?, which is shorthand for Option[T]. For example, an optional u32 is u32?, or equivalently, Option[u32].

The Option type is an enum type with 2 constructors: None and Some. A value of T? is constructed with either Option.None or Option.Some(t) where t is a value of type T.

Like any variant type it is possible to match on a value of type T?

match x {
    Some(x) => x,
    None => 0,
}

In addition, there is a ? operator, which will evaluate to the value of Some or return Option.None. That is, if x is of type T?, then x? is equivalent to the following match expression:

match x {
    Some(x) => x,
    None => return Option.None,
}

Results

The Result[T, E] type is used for functions that might fail. It is an enum with two variants: Ok(T) and Err(E). The Ok variant should be returned when the function exits successfully and the Err variant when it has failed.

The caller can then match on this value and decide what to do.

Note: More syntactic sugar for dealing with Result will be added in the future.

Custom Types

Anonymous Records

Multiple values can be grouped into records. A record is constructed with {} and contains key-value pairs.

let foo = { bar: 5, baz: 10 };

These records are statically typed, which means that records with different field names or different field types are separate types. For example, this is a type checking error:

if x {
    { foo: 5, bar: 10 }
} else {
    { foo: 5 }  // error!
}

Note that this makes records significantly different from dictionaries in Python and objects in JavaScript, which resemble hash-maps and are far more dynamic.

Fields of records can be accessed with the . operator.

filtermap example_filter_map() {
    let x = { foo: 5 };
    accept x.foo
}

Fields can also be updated with an assignment.

let x = { foo: 5 };
x.foo = 6;

Named Records

Named records provide a more principled approach to grouping values which will yield more readable type checking errors.

record SomeRecord {
    foo: i32,
    bar: bool,
}

// ...

let x = SomeRecord { foo: 3, bar: false };

Roto checks that all declared values are provided and are of the same type.

There is an automatic coercion from anonymous records to named records:

fn foo(int: i32) -> SomeRecord {
    { foo: int, bar: false }  // implicitly coerced to SomeRecord
}

Enum Types

Another kind of custom type in Roto are enum types. These types have a set of constructors and values of these types are always constructed using one of these. Each of the constructors can take arguments. To inspect enum types, we can match on them.

// A `Number` enum type that has the constructors `Int`, `Float` and `Nan`.
enum Number {
    Int(i32),
    Float(f32),
    Nan,
}

fn use_number() {
    let x = Number.Int(10);
    let y = Number.Float(21.5);
    let z = Number.Nan;

    match x {
        Int(i) => print(f"int: {i}"),
        Float(f) => print(f"float: {f}"),
        Nan => print(f"nan!"),
    }
}

Enum types can be generic over other types by taking type parameters.

enum Either[L, R] {
    Left(L),
    Right(R),
}

fn to_string(x: Either[i32, String]) -> String {
    match x {
        Left(i) => f"{i}",
        Right(s) => s,
    }
}

Note: Enum types are also used to model optional values. See optional values.

Operators

Arithmetic Operators

The unary - operator will negate a number. It requires that its operand is a signed integer or a floating point number (i.e. not an unsigned integer).

There are binary operators for common arithmetic operations, which are implemented for numeric types:

OperatorDescription
+addition
-subtraction
*multiplication
/division
%remainder (integers only)

These operators follow the conventional PEMDAS rule for precedence. The order is

  • Parentheses
  • Multiplication, division and remainder
  • Addition and subtraction

Parentheses can always be used to force a certain order of operations. For example, this expression:

1 + 2 * 3;    // evaluates to 7

is interpreted as

1 + (2 * 3);  // evaluates to 7

and not as

(1 + 2) * 3;  // evaluates to 9

Comparison Operators

In addition to arithmetic operators, there are operators to compare values. Comparison operators have a lower precedence than arithmetic operators. The script won't compile if the operands have different types.

OperatorDescription
==Equals
!=Does not equal
>Greater than
>=Greater than or equal
<Less than
<=Less than or equal

Examples:

5 > 10      // evaluates to false
10 > 5      // evaluates to true
5 == 5      // evaluates to true
5 == true   // compile error!
1 < x < 10  // compile error!

Logical Operators

Operators to combine boolean values are called logical operators. These are the logical operators in Roto:

OperatorDescription
&&Logical and (short-circuiting)
||Logical or (short-circuiting)
!Negation

Now that we have all the rules for precedence, here is an example using all types of operators (arithmetic, comparison and logical):

1 + x * 3 == 5 && y < 10

This is equivalent to:

((1 + (x * 3)) == 5) && (y < 10)

The && and || are short-circuiting, meaning that if the left-hand operand of && evaluates to false or the left-hand operand of || evaluates to true, the right-hand side won’t be evaluated.

Control Flow

If-else

To conditionally execute some code, use an if block. The braces in the example below are required. The condition does not require parentheses. The condition must evaluate to a boolean.

if x > 0 {
    // if the condition is true
}

An else-clause can optionally follow the if-block. The if-else construct is an expression and therefore evaluates to a value.

if x > 0 {
    // if the condition is true
} else {
    // if the condition is false
}

The if-else is an expression, not a statement, which means that it evaluates to a value. This means that it can be used as a replacement for a ternary operator.

let x = if y { 1 } else { 0 };

If-else expressions can be chained without additional braces.

if x > 0 {
    print("x is positive!");
} else if x < 0 {
    print("x is negative!");
} else {
    print("x is zero!");
}

Match

Pattern matching in Roto is supported via match expressions. These take a value and a set of patterns to check against, with an expression associated with each of the patterns.

The pattern is separated from the associated expression with ->. The arms should be separated with commas, unless the expression is a block, i.e., when it is wrapped in {}.

The current implementation of this feature is very limited: you can only match against variant types and only match against the constructor, not against the contents of the constructor. See issue 124 for the status of these limitations.

let x = Some(10);
match x {
    None => print("x is None"),
    Some(i) => {
        print("x is Some");
        print(f"x is {i}");
    }
}

Note: In contrast with Rust, Roto currently doesn’t use the full path to the variant constructor, but only the name. So Option.None is not allowed as a pattern, but None is. This will probably change once match expressions become more general.

While Loops

A while loop takes a condition and a block. It will keep executing the block until the condition evaluates to false.

let i = 0;
while i < 10 {
    i = i + 1;
}

A while loop is an expression of the type (). Like with if, while does not require parentheses around the condition.

For Loops

A for loop can be used to iterate over the elements in a list. The variable created by the loop is available only within the loop and received a copy of the data in the list.

let total = 0;
for x in [1, 2, 3, 4] {
    total = total + x;
}

A for loop is an expression of the type (). Like with if, while does not require parentheses around the condition.

Functions

Functions

Functions can be defined with the fn keyword, followed by the name and parameters of the function. It is required to specify the types of the parameters. The return type is specified with ->. A function without a return type does not return anything.

fn add_one(x: u64) -> u64 {
    x + 1
}

This function can then be called like so:

add_one(10);

A function can contain multiple expressions. The last expression is returned if it is not terminated by a ;. The return can also be made explicit with the return keyword. This function is equivalent to the previous example.

fn add_one(x: u64) -> u64 {
    return x + 1;
}

The following function uses multiple statements to return 0 if the input is 0 and subtract 1 otherwise.

fn subtract_one(x: u64) -> u64 {
    if x == 0 {
        return 0;
    }
    x - 1
}

This function does not return anything:

fn returns_nothing(x: u64) {
    x + 1;
}

The return keyword can still be used in functions that don't return a value to exit the function early.

Filtermap

A filtermap is a function that filters and transforms some incoming value.

Filter-maps resemble functions but they don’t return. Instead, they either accept or reject, which determines what happens to the value. Generally, an accepted value is stored or fed to some other component and a reject value is dropped.

filtermap reject_zeros(input: IpAddr) {
    if input == 0.0.0.0 {
        reject
    } else {
        accept
    }
}

This describes a filter which takes in an IP address and accepts it if it is not equal to 0.0.0.0.

Like with functions, intermediate results can be stored in variables with let bindings.

filtermap reject_zeros(input: IpAddr) {
    let zeros = 0.0.0.0;
    if input == zeros {
        reject
    } else {
        accept
    }
}

A filtermap can also accept or reject with a value.

filtermap small_enough(x: i32) {
    if x < 10 {
        accept x
    } else {
        reject "value was too big!"
    }
}

This filtermap is identical to the following function:

fn small_enough(x: i32) -> Verdict[i32, String] {
    if x < 10 {
        return Verdict.Accept(x)
    } else {
        return Verdict.Reject("value was too big!")
    }
}

On the Rust side, a filtermap is a function that returns a Verdict<A, R>. The type parameters of a Verdict specify the types of the values given in the accept and reject cases, respectively.

Tests

Tests are written with the test keyword followed by an identifier and a block. The name must be unique. Inside the block you can write any Roto code that you want but it must return a Verdict[(), ()]. If the test returns with accept, the test passes, it it returns with reject, the test fails.

Tests look a lot like functions with a few crucial differences: they cannot be called directly and they do not have any arguments. Instead, Roto’s test runner finds the tests and runs them.

fn add(x: u32, y: u32) -> u32 {
    x + y
}

test test_add_function {
    if add(10, 20) == 30 {
        accept
    } else {
        reject
    }
}

If you’re using Roto as a binary or with the generated CLI, you can run the tests with the test subcommand:

$ roto test

Constants

Constants in Roto are declared with the const keyword. They are evaluated at the end of compilation, before functions are extracted.

const FOO: u32 = 10;

Unlike let declarations, const declarations always require a type annotation. By convention, the names of constants are written in all capital letters with words separated by underscores (also known as SCREAMING_SNAKE_CASE).

The declarations of constants can be in any order. Context variables is not available during the evaluation of constants. Therefore, it is not possible to use any functions that use context variables. Any other function, both from the runtime and from the script can be used.

If constant A is defined in terms of constant B, then Roto will ensure that B is evaluated first. Otherwise, the evaluation order is undefined and you should not rely on any order. If there is any cycle in the dependencies between constants, then Roto will yield a compile-time error before any constants are evaluated.

const FOO: u32 = BAR;
const BAR: u32 = FOO;

Context Variables

Context variables are global variables that are passed to each invocation of a script. They are declared by the runtime. There is no way to create context variables in a Roto script.

The use case of context variables is to pass some implicit values to a script that do not need to be passed explicitly to functions in the script.

For example, if the runtime declares a context variable FOO of type u32, then we can access it by name directly.

fn foo() {
    let foo = FOO;
    print(f"{x}");
}

Modules

Modules

A Roto script can be split over multiple files. To do this, we have to create a folder with the name of the script and create a Roto file directly in it called pkg.roto. This file is the root of our script. The contents of pkg.roto will form the pkg module. No other files in the directory can be called pkg.roto.

Files adjacent to pkg.roto are submodules of pkg. For example, a file called foo.roto will define the module pkg.foo.

A directory next to pkg.roto will also be a submodule if it contains a file called lib.roto. A file foo/lib.roto is therefore equivalent to foo.roto and defines the module called pkg.foo. We can do this recursively, so we can define the module pkg.foo.bar with either a file called foo/bar.roto or foo/bar/lib.roto and so forth.

The files foo.roto and foo/lib.roto cannot both exist and only foo/lib.roto can have submodules.

An item such as a function, filtermap or type can be used from other modules in a couple of ways. To access them, we must know the path, which is the period-separated list of identifiers to follow to get to the item, starting with the module name and ending with the name of the item.

For the following examples, we will work with the following files:

pkg.roto
foo.roto
bar/lib.roto
bar/baz.roto

These define the following modules:

pkg
pkg.foo
pkg.bar
pkg.bar.baz

Now assume that foo.roto contains a function called square, this function can be referenced in any of the other files with the absolute path pkg.foo.square. For example:

fn add_and_square(x: i32, y: i32) -> i32 {
    pkg.foo.square(x + y)
}

We can also use the relative path, which is different for each file. We can use the super keyword in a path to reference the parent module of the current module. Multiple super keywords can appear at the start of a path.

// in pkg.roto
foo.square

// in foo.roto
square

// in bar.roto
super.foo.square

// in bar/baz.roto
super.super.foo.square

There are 3 special identifiers that can only be used at the start of a path and automatically make the path an absolute path:

  • pkg for the current package
  • std for the Roto standard library
  • dep for dependencies (not implemented yet, but the identifier is reserved)

Imports

Of course, writing out the full path to anything you want to use can become quite tedious. We can import items from other modules into the current module with the import keyword. The import keyword is followed by a path. The item the path references will be available by name in the current scope.

import foo.square;

fn fourth_power(x: i32) -> i32 {
    square(square(x))
}

We can also import entire modules. Imported modules are not available in other modules.

An import does not need to be at the top-level, they can be in any scope. We can rewrite the previous example as follows.

fn fourth_power(x: i32) -> i32 {
    import foo.square;
    square(square(x))
}

Now the name square can only be used within the fourth_power function and not in any other functions we define. But we can define even more granular imports such as in the following example, where we use a function foo from either module A or B, depending on a boolean flag.

fn use_foo(x: i32, choice: bool) -> i32 {
    if choice {
        import A.foo;
        foo(x)
    } else {
        import B.foo;
        foo(x)
    }
}

Next Steps

You can learn more about Roto by looking at the documentation for the Nervix Roto column operations.

Upstream Roto License

Copyright (c) 2022, NLnet Labs. All rights reserved.

This software is open source.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:

Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.

Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

Neither the name of the NLNET LABS nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Lookups

Lookups are resource-backed reference data models that can be queried directly or used inside structured NSPL expressions.

Choosing An Enrichment Mechanism

Choose by ownership and update path:

NeedMechanismCost shape
Reference data changes when an operator uploads a resource versionHASH MAP with LOOKUP_HASH_MAPStatic in-memory lookup data for the selected resource version
Live state is keyed by the current branchUSING MATERIALIZED STATE with relay_state.<relay>.<field>Branch-local state persisted and snapshot-replicated with the runtime node
Live data is keyed differently from the current branchRe-key through a REINGESTOR into the branch that owns the data, or join outside Nervix in an external storeReingestion adds a graph stage and creates branch instances under the new key; an external join leaves storage and query cost outside Nervix

Materialized dependencies require the exact compatible branch. See Materialized relay state for the linked REQUIRED WAIT, REQUIRED SKIP, and DEFAULT semantics. Do not use a materialized relay as a cross-branch index.

This boundary is structural. Hash maps amortize a versioned reference dataset. Materialized state amortizes the latest record inside the branch that owns it. Neither mechanism turns runtime state into an arbitrary-key query service.

The current lookup model is a hash map:

CREATE [IF NOT EXISTS] HASH MAP <name>
  KEY <field>
  FROM RESOURCE <resource> VERSION <n>|LATEST
  PATH '<file>'
  DECODE USING <codec>;

The hash map loads records from a versioned RESOURCE file in the same domain as the hash map. The file is decoded through the declared codec, and the KEY <field> value becomes the lookup key. VERSION is mandatory. A number binds that exact completed version. VERSION LATEST resolves the highest completed version when the statement is applied and stores the resulting number, so later uploads and restarts do not move the hash map to another version. SHOW CREATE HASH MAP renders the stored number. Creation succeeds only after the selected file has decoded and the index is usable on every current live node, including while the domain is stopped. The next command may run LOOKUP immediately; malformed input fails creation.

The web console’s Create → Hash map form gathers the name, key field, resource, completed version or LATEST, file path, and codec as structured controls. The key-field picker lists the selected codec’s output schema fields with their exact types. The form submits the current Model’s canonical NSPL and waits for this same loading boundary before showing Completed. A failed load leaves the entered draft visible for inspection. The transaction can already have committed the Model before a malformed line fails activation, so a retry under the same name does not replace that Model.

A resource file is read line by line, and each non-blank line is one payload for the codec. With a schemaful codec, such as a JSON wire schema over JSON Lines, each line is one entry:

CREATE RESOURCE zip_codes;
UPLOAD RESOURCE zip_codes VERSION './lookups/zip_codes';

CREATE SCHEMA zip_code_entry (
  zip STRING,
  city STRING,
  region STRING OPTIONAL
);

CREATE WIRE JSON SCHEMA zip_code_entry_wire MODE STRICT (
  zip string,
  city string,
  region string OPTIONAL
);

CREATE CODEC zip_code_entry_codec
  FROM WIRE JSON SCHEMA zip_code_entry_wire
  TO SCHEMA zip_code_entry;

CREATE HASH MAP zip_codes_by_zip
  KEY zip
  FROM RESOURCE zip_codes VERSION 1
  PATH 'lookup.jsonl'
  DECODE USING zip_code_entry_codec;

A JAQ-backed codec unfolds each line, so one line may contribute zero, one, or several entries, in the order its program yields them. When two entries carry the same key, whether from two lines or from one, the later entry replaces the earlier one. A line that fails to decode fails the hash map’s creation with a diagnostic naming the line.

Direct lookup commands are session/control commands:

DESCRIBE HASH MAP <name>;
LOOKUP <name> KEY '<key>';

DESCRIBE HASH MAP reports the loaded resource version, path, codec, owner/replica placement, key field, and entry count, which counts distinct keys. LOOKUP returns the matching decoded record when the key exists, written as JSON. JSON has no number for a NaN or an infinity, so a float field holding one is written as the string NaN, Infinity or -Infinity; a key holding one matches the entry keyed by the same name, every NaN alike. These are read-only control-plane commands: they do not execute the graph or require a running domain clock, so a loaded hash map remains directly queryable while its domain is stopped.

Expressions can call LOOKUP_HASH_MAP:

LOOKUP_HASH_MAP("<hash_map>", <key_expr>, "<field>")

The function evaluates <key_expr>, looks up that key in the named hash map, and returns the requested field from the matched lookup record. A missing key or missing optional field returns null. Referencing a field that is not present in the hash map schema is a statement-validation error.

Example enrichment:

CREATE BRANCH by_zip
  SCHEMA zip_branch TTL 5m;

CREATE DEDUPLICATOR enrich_zip
  FROM inbound
  DEDUPLICATE ON input.zip
  MAX TIME 10m
  BRANCHED BY by_zip
  TO enriched
    INHERIT ALL
    SET city = LOOKUP_HASH_MAP("zip_codes_by_zip", input.zip, "city"),
        region = LOOKUP_HASH_MAP("zip_codes_by_zip", input.zip, "region")
    WHERE NOT is_null(output.city)
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

Lookup models are domain-owned. A hash map name must be unique within the active domain, and CREATE IF NOT EXISTS HASH MAP ... follows the same idempotent-create behavior as other model create statements.

Sessions

Nervix supports session-local commands over its session protocol.

These commands are not persisted in the registry:

CREATE SUBSCRIPTION acme_notifications TO notifications WHERE tenant = 'acme';
CREATE SUBSCRIPTION sampled_telemetry TO telemetry DROPPING BATCH SAMPLE RATE 0.1 WHERE input.tenant = 'acme';
DELETE SUBSCRIPTION acme_notifications;
DESCRIBE RELAY notifications WHERE (tenant = 'acme');
ATTACH DOMAIN CLOCK;
DETACH DOMAIN CLOCK;

Following a domain clock is not a subscription; see Domain Clock Attachment. Publishing to a client ingestor uses producers, which are not statements.

The Client Ingestors And Emitters architecture chapter connects these transient handles to their persisted graph endpoints, ACK boundaries and restoration.

This page describes sessions as a user of NSPL sees them. Client Session Protocol explains the protocol that carries them, and the Client Implementation Manual states what a client implementation must do.

Current session behavior:

  • subscription creation validates the statement against the relay as the cluster schedule declares it, and attaches only while the serving node declares the relay with that same definition, whether or not it owns the relay and whether or not the domain is running
  • subscription names are unique within one connected session and may refer to relays in different domains
  • DELETE SUBSCRIPTION resolves only the session-local subscription name, independent of the currently active domain
  • subscribing to a relay collects records from all active branch groups for that relay
  • a branched subscription reports the concrete branch key of every batch of rows it delivers, and the text view repeats it on each row; sensitive branch-key fields are masked using the same rules as sensitive relay fields
  • subscriptions are read-only views; only an optional WHERE predicate is supported, and a selected record is delivered without construction or transformation
  • the predicate is an ordinary BOOL expression, including membership, range, and null-safe equality tests such as input.status IN ('open', 'held'); it selects a record only where it is true, so a null predicate, such as IN over a null field, does not select it
  • bare fields, message.<field>, and input.<field> all read the subscribed relay record; the compiler rejects output, branch, and relay_state scopes when the subscription is created
  • subscription syntax does not accept INHERIT, SET, VALUES, INVOKE, or other side effects
  • a subscription with a WHERE predicate reads one snapshot of the domain’s clock on the serving node for each batch it evaluates; every predicate expression and volatile UDF call for that batch sees the same instant, and a batch whose domain time cannot be read is skipped and reported
  • optional BATCH SAMPLE RATE <rate> samples each selected row, after WHERE has been evaluated, with a pseudo-random draw; a rate of 1 delivers every selected row and 0 delivers none
  • BLOCKING delivery waits for room in the session’s subscription queue, which holds back the relay it reads, while DROPPING discards rows when that queue is full and reports how many it discarded before its next rows; the queue holds 16 frames shared by every subscription of the session, and a count still outstanding when the subscription ends is not reported
  • subscription changes belong to the session rather than to a transaction, so CREATE SUBSCRIPTION and DELETE SUBSCRIPTION are refused while the session holds a transaction
  • subscription events are delivered asynchronously to the connected client session
  • the relay owner is the sole subscription fan-out source, so each admitted batch is delivered at most once to a subscription even when producers and consumers run on several cluster nodes
  • when the subscription’s cluster node also hosts a runtime consumer, subscription delivery piggybacks on the same owner-to-node Arrow IPC batch instead of adding another serialized copy
  • runtime and server errors are also delivered asynchronously
  • cluster membership updates are also delivered asynchronously

Sessions are runtime-facing protocol interactions, not part of the persisted namespace model.

A terminal reply may hold up to 64 MiB. A reply above the 4 MiB frame bound is carried as ordered transfer parts that each fit one frame, then validated and reassembled before the client exposes it. This is how a rendered transaction report larger than one frame reaches the Rust client, CLI, and browser without truncation. A reply above the transfer bound is rejected whole; no client receives a successful partial result.

Subscription Lifecycle

A subscription is identified by its name together with the generation its session assigns when it opens it. Every frame about a subscription carries both, and a name reused after deletion opens a new generation, so a frame about an earlier generation is never taken for a later one. A subscription belongs to the session that created it and moves through one lifecycle:

  • Creating. The server validates the statement, makes the node’s interest in the relay visible to every live node, and attaches to the relay. A refusal at any step leaves nothing behind: no interest, no attachment, and the name remains free. Reopening after the last subscription closed waits for the renewed interest to become visible; an advertisement from before that closure does not establish readiness.
  • Active. The reply that opens the subscription carries the schema of its rows, and no row precedes it. When that reply cannot be delivered, because the request was cancelled, the session ended, or the reply did not fit the session limits, the subscription is abandoned before it delivers anything.
  • Ended by the server. When its relay is redefined, so that a field’s name, position, type, nullability, or sensitivity changes, the relay becomes branched or unbranched, or its branch or branch key schema changes, the subscription receives SubscriptionEnded with reason RelayChanged. When its relay or the relay’s domain is removed, the reason is RelayRemoved. Either is the last frame about that generation, and no row of a redefined relay reaches a subscription announced under its earlier definition. Stopping and starting a domain, and rebuilds that keep the relay’s definition, keep its subscriptions delivering. An ended subscription can still be deleted by name, and its name can be used again once its end has been sent; because replies travel ahead of queued subscription frames, that end can reach the client after the reply that opens the new generation.
  • Deleted. DELETE SUBSCRIPTION, or the end of the session, stops the subscription at once even while its client reads nothing, and discards the frames it still has queued. Nothing about that generation follows the reply that deleted it.

Replies and session events travel ahead of the subscription rows a session has queued, so rows waiting for a slow client never hold back a command reply or the reply to the unsubscribe that stops them. Frames already handed to the transport stay in order: a reply follows the rows the transport took before it. A node advertises interest in a relay while at least one of its subscriptions holds it; see Cluster Interconnect and the nervix_session_subscriptions and nervix_session_subscription_dropped_rows_total series in Metrics and Observability.

Subscriptions are live views. They do not replay rows, keep durable offsets, or deliver exactly once. A session is told of the rows it lost where the server knows of them: a DROPPING subscription reports the rows it discarded, and rows a filter could not evaluate, rows of a batch whose domain time could not be read, and every selected row of a batch the encoder could not fit into a frame are reported as skipped. Rows in transit can also be lost without a report while relay ownership moves between nodes or a node-to-node delivery fails. Row Subscriptions lists every gap and whether it is reported.

Typed Row subscription frames carry a BYTES field as raw octets in a BytesCell; clients read the value as borrowed bytes, including empty and non-UTF-8 sequences. JSON subscription views render that field as padded standard base64 text. Sensitive byte fields are redacted in both views.

Persistent administrative commands carry a stable execution reference. The cluster binds that reference to the authenticated owner, selected domain, and semantic command, continues admitted work after the session disconnects, and retains one terminal result for the command retry validity, 15 minutes by default. The reference is a UUIDv7 whose creation time bounds how long it may be retried. The CLI, web console, and Rust client reuse the reference through redirects and reconnects. Reuse for changed content fails. While a long command waits, server events, subscription delivery, completion, and transaction inspection continue independently; the session’s later commands wait for it, because a session runs its commands in the order it received them. See Command Completion and Exact Recovery.

Domain Clock Attachment

A session can follow the clock of a domain: its START generation and what the serving node has installed for it. ATTACH DOMAIN CLOCK; starts following the clock of the active domain and DETACH DOMAIN CLOCK; stops following it. Clients send them as an AttachDomainClockRequest and a DetachDomainClockRequest naming the active domain, so each addresses the domain active when it runs. The Rust client, the CLI and every host of the shared C binding send each statement on its own: they refuse it beside other statements, and fail it when no domain is active. Selecting another domain with USE does not change what the session follows, and a session follows several domain clocks by attaching while each domain is active. The web console instead follows the clock of the domain it has selected: selecting another domain, from its domain menu or with USE, detaches the previous clock and attaches the selected one.

An attachment is keyed by its domain alone. It is not a subscription: it reads no relay, has no name or generation of its own, and a session follows each domain clock at most once. The observed clock carries the START generation and one state:

  • Stopped. The generation exists but executes no domain work. Generation 0 means the domain has never started.
  • Uninstalled. A paced generation is running, but the serving node has no assigned clock authority or mapping for it, so it cannot execute paced work.
  • Unpaced. The domain reads actual UTC.
  • Paced. The committed mapping the domain executes with: PERIOD, SKEW, the logical origin, the UTC anchor, and the time rate. Timestamps are signed Unix nanoseconds, period and skew are unsigned nanoseconds, and the rate is a double.

An attachment moves through one lifecycle:

  • Attached. The reply carries the clock as the serving node has it installed, and no frame of this attachment precedes it. A second attach is refused as already attached, and a domain the serving node does not have is refused as not found. A node that has not installed the cluster’s committed domains since it started, as right after a restart, answers only once it has, so it never refuses a domain the cluster has as not found. That wait ends early only with the session. While it waits, the attach holds the session’s ordered lane, so the session’s later commands and ordered requests wait behind it. When the reply cannot be delivered, because the request was cancelled, the session ended, or the reply did not fit the session limits, the attachment is abandoned before it delivers anything; cancelling a waiting attach therefore attaches nothing, though it does not end the wait. If the installation has not changed and the node already holds a tick of that generation, its newest tick is the first frame after the reply.
  • Following. Each later change of the installed clock arrives as a DomainClockObserved frame carrying the new clock: STOP delivers stopped, a START delivers its new generation, paced with its mapping or unpaced, and a paced generation left without an assigned clock authority delivers uninstalled, then its mapping again once an authority is assigned. A frame carries the clock installed when the server built it. Changes made while an earlier frame waits for room on the session arrive together as the newest clock, and a change that leaves the clock as the client last received it sends nothing: moving the authority to another node and the pause that alters a running model are not observed. For a paced installation, each newer accepted tick arrives as a DomainClockTicked frame with its generation, one-based id, logical boundary, the authority’s UTC observation, and the serving node’s own logical reading taken when it built the frame. A tick of a new generation follows that generation’s state frame. Ticks are replaceable on the control lane: each attached domain holds at most one queued tick, updated to the newest while the client is slow, and a changed clock withdraws a queued tick the transport has not taken yet. Tick ids may skip after coalesced periods. An unpaced, stopped or uninstalled clock emits no ticks.
  • Detached. DETACH DOMAIN CLOCK stops delivery before its reply, so nothing about the domain follows the reply. A detach without an attachment is refused as not attached. The Rust client, the CLI and every host of the shared C binding never attach a clock again on their own behind a detach of it: a restoration attach they already decided to send reaches the session first, and the detach releases what it attached.
  • Ended by the server. When the domain no longer exists on the serving node, the session receives DomainClockAttachmentEnded with reason DomainRemoved, the last frame about that attachment. A request sent after that frame finds the attachment gone: an attach is served as a new attachment, which the serving node refuses as not found unless it holds the domain again, and a detach is refused as not attached.
  • Session end. The attachment ends with the session, and nothing is sent about it. The Rust client, the CLI, and every host of the shared C binding attach every clock they followed again on their next session, and the web console attaches its selected domain’s clock again when it reconnects. A console that holds a transaction attaches it first on the new connection, so the server refuses that clock attach, and the console shows the refusal until its selection or connection changes. The new attachment delivers the clock as it is then and the newest tick its serving node holds. That tick can repeat one the client already received, or precede it when another node serves the new session. When the new session’s attach is answered while the old session still holds the attachment, as after a redirect to the leader, the Rust client and the shared C binding keep the newest tick of an unchanged clock and report a tick of the new session only when it is newer.

Both statements run in order with the session’s commands. While the session holds a transaction, the server refuses them as it refuses a subscription change, and the Rust client, the CLI, every host of the shared C binding and the web console refuse them before sending, as they refuse every client-local statement. Neither is persisted or becomes transaction content. See Domains And Time for what a client computes from a paced clock, and Domain Clock for how the serving node delivers an attachment.

Producers

A session can publish typed batches to a client ingestor through producers it opens. A producer is not a statement: clients open, feed, and close it with the OpenIngestor, SubmitBatch, and CloseIngestor requests, and the Rust client exposes it as Client::open_ingestor; see Client Library.

  • An open names its domain explicitly, so USE never retargets a producer, and it is answered on the session’s ordered lane with the ingestor’s input schema, its START generation, the endpoint contract and attachment it bound to, the ingestor’s acknowledgement policy, the credit it was granted, and whether admission is open. A refused open leaves nothing attached.
  • Producers belong to the session, not to a transaction: an open is refused while the session holds a transaction.
  • A session holds at most 32 producers and 32 MiB of producer credit; the serving node holds at most 128 MiB for every producer it serves or retains batches for.
  • A submitted batch is handed to its producer without the session’s receive loop waiting on it, so commands, subscriptions, domain clock frames, and every other request keep moving while batches await their graph outcome. Batches are not counted against the session’s in-flight request limit; the producer’s credit bounds them instead.
  • Every batch receives exactly one terminal reply: not admitted, completed, processing failed, or of unknown outcome. A batch cannot be cancelled once sent; CancelRequest is refused for it, because withdrawing an admitted batch is not possible.
  • A batch sent beyond the producer’s credit is refused and ends the producer as a protocol violation. Its earlier batches still receive their outcomes.
  • The session tells the client when a producer’s admission is suspended or open again, and when the server ends a producer, which is the last frame about it. Closing a producer refuses its queued batches, waits for its admitted ones, and is answered once every batch has its outcome.
  • When the session’s node forwards a producer to the node that executes its ingestor and loses that node, whether it crashed or stopped answering, the producer ends as owner lost. Before the end, every batch that node could not have admitted is refused as producer ended, because the session’s node clears each forwarded batch before it may be admitted; only the cleared batches are reported as of unknown outcome.
  • When the session ends, its producers detach. Admitted batches continue through the graph with nobody left to answer them, so a client reports them as of unknown outcome. The Rust client keeps the producer handle and restores a fresh attachment if the domain generation and endpoint contract still match; it does not resend those uncertain batches.

Emitter consumers

An application opens a consumer of a TO CLIENT emitter by naming its domain, emitter, and the exact output fields, including order, optionality, and sensitivity. The open reserves room for one maximum-sized Arrow delivery before reporting success. A session holds at most 32 consumers and 32 MiB of consumer credit; the serving node reserves at most 128 MiB for this direction, independently of its producer budget. Thus one session’s producer and consumer ceilings total 64 MiB and a node’s total 256 MiB. No consumer means retained output fills the bounded node budget and backpressures the graph.

OpenEmitter, ReadEmitterBatch, SettleEmitterBatch, and CloseEmitter are session operations, not relay subscriptions. A read is concurrent with the session’s ordered control lane; the receive loop can keep processing producer submissions, ACKs, commands, and clock observations while that read waits. A returned batch is still unacknowledged. Its current reference must be confirmed with ACK, retried, or rejected; close and session loss revoke unresolved attempts. The server attachment belongs to its session exchange. The Rust client keeps the consumer handle and restores a fresh attachment when the generation and endpoint contract still match. The first read after a gap reports an interruption. Delivery references from the former attachment cannot ACK through the new one. The server does not persist a consumer position. A session may attach through a different node from the emitter; that node forwards output and settlement over the authenticated interconnect. Client Session Protocol defines the frames, and Emitters defines their graph ACK boundary.

Suggestions

The session’s SuggestRequest carries the full NSPL input, a UTF-8 byte cursor on a character boundary, the selected domain, a page size from 1 through 100, and an optional continuation. The server asks the composed client/server grammar for expectations at that cursor, then resolves semantic references from one read of the selected domain’s committed configuration with the authenticated session’s ordered queued transaction prefix applied. The prefix includes staged model creation, alteration, renaming, and removal. Other sessions see committed configuration. For ALTER SCHEMA and ALTER WIRE ... SCHEMA, field references are drawn from the named schema after that prefix has been applied, so a staged field rename or removal changes the available candidates immediately. Inside a route expression, completion also offers ordinary VM builtins. For a junction, input.<field> resolves through the input relay’s declared schema, and a SET field resolves through the output relay’s declared schema. Qualified udf::<name> calls use the domain’s UDF models, including the session’s queued changes. In an ingestor construction expression, read_header and read_headers are offered only when the selected source supports header reads. In an emitter INVOKE expression, write_header is offered only when the selected sink supports header writes.

Each suggestion carries a display value, a kind, and a text edit with UTF-8 byte start and end offsets and replacement text. Clients apply that edit to the original input; they do not infer the range from the display value. A local path suggestion asks the native CLI to search its own filesystem and carries the path fragment’s source range. The grammar offers one wherever a statement names a file or directory on the client’s machine: the directory of UPLOAD RESOURCE, the destination of BACKUP, and the archive DESCRIBE BACKUP or RESTORE reads. The server sorts and deduplicates the candidate set before returning a bounded page. A continuation binds the input, cursor, domain, configuration revision, and candidate set; if any of these changes, the server returns StaleContext instead of silently paging through a different set.

Ready with no suggestions means there are no matches. MissingContext means a domain required for a semantic reference is absent or unavailable. StaleContext means a transaction binding or page basis no longer matches. LookupFailed means the semantic configuration read failed. These statuses are part of the suggestion reply, so a client can distinguish them without interpreting an empty candidate list.

Suggestions are read-only session requests. They can complete while a command is still pending; they neither enter the command admission gate nor change the transaction queue.

Backup Downloads

The session service’s DownloadBackup method is a server-streaming gRPC call that sends the archive of one completed backup. Its single request names the backup’s execution reference. The call authenticates from its metadata before it reads the request, like every session method, and a call without valid credentials ends with UNAUTHENTICATED.

A node that retains the archive answers with a start frame carrying the archive’s total size and BLAKE3 digest, then the archive’s bytes in chunks of at most 256 KiB, then a completion frame. It reads the archive only as fast as the client takes frames, at most four frames ahead, so a slow client holds back the reads instead of growing what the node holds for it. Every other answer is a single frame:

AnswerWhen
Refused InvalidRequestThe request does not decode.
Refused ExpiredThe retry validity of the execution reference has ended.
Refused NotOwnerAnother user ran the backup.
Redirect to the leaderThe node does not retain the archive and is not the leader.
Refused NotRetainedThe leader does not retain the archive: a download already collected it, the node that assembled it restarted, or the reference names no backup.
Refused ReadFailedThe node could not read the archive it retains; the archive stays retained.

The first download whose completion frame was queued collects the archive, and a later download of it is refused. A download whose client goes away releases only its own hold, so the archive stays retained for the client to download again. The call is not bounded by the request timeout; the Rust client bounds the wait for each frame by it instead, and starts a failed download again from the first byte. Backup And Restore describes backups and their archives.

The web console makes the same call over a WebSocket of its own on the console listener, /console/backups/download, authenticated like the console session. The connection carries the one request and the frames that answer it, one frame per binary message, and the node closes it normally after the last frame. The console checks the archive against the backup’s summary exactly as the Rust client does before it hands the archive to the browser.

Restore Streams

The session service’s RestoreBackup method is a client-streaming gRPC call that carries one restore and its archive, and a single reply answers it. The call authenticates from its metadata before it reads a frame, like every session method, and a call without valid credentials ends with UNAUTHENTICATED.

The first frame is the restore’s start. It names the request identity of the reply, the restore’s execution reference, the RESTORE statement as canonical NSPL, and the archive’s exact size and BLAKE3 digest. Every later frame carries the next bytes of the archive, at most 256 KiB each. The execution reference, the statement, and the archive’s size and digest together are the restore’s request identity, so sending them again joins the same restore.

The reply is either the restore’s outcome as a command outcome, or a typed refusal of the stream itself, which leaves nothing changed:

AnswerWhen
Refused InvalidStreamThe first frame is not a start, a later frame is not a chunk, or a frame does not decode.
Refused InvalidStatementThe start does not name exactly one RESTORE statement.
Refused SizeMismatchThe chunks add up to more, or fewer, bytes than the start declares.
Refused DigestMismatchThe archive’s bytes do not have the digest the start declares.
Refused QuotaExceededThe archive is larger than the leader stages, or its staging area cannot hold the archive now.
Refused StagingFailedThe leader could not write the archive to its staging area.
A redirect outcomeThe node is not the leader.
An outcome at onceA restore under the reference already finished, which answers with its recorded outcome, or still applies on this node, which answers OutcomeUnknown with the StillApplying cause.
The restore’s outcomeThe archive arrived, and the restore was refused, failed at a step, or completed.

A refusal is not recorded: sending the stream again stages the archive again. The leader stages the archive only as fast as the client sends it and refuses, rather than queues, an archive its staging area cannot hold now. The call is not bounded by the request timeout, because an archive may take far longer to send than a command takes to run; the Rust client bounds each frame by it instead, and then the wait for the reply once the last frame was sent. A call that ends before the last frame arrived changes nothing and releases what the leader staged for it. Once the whole archive arrived, the restore goes on without the call. Backup And Restore describes restores.

The web console makes the same call over a WebSocket of its own on the console listener, /console/backups/restore, authenticated like the console session. A WebSocket client cannot half-close the stream and still read the reply, so the stream ends with the chunk that completes the size the start declares, and the node answers on the open connection, then closes it normally. A connection that closes before that chunk ends the call as a failed gRPC call does.

Structured Choices

ChoiceLookupRequest resolves values for structured client controls without constructing partial NSPL. It carries a semantic target, typed dependent selections, search text, a page size from 1 through 100, and an optional page cursor. Targets resolve domain pace, placement policy, and a domain’s internal schemas, each wire-schema format, branches, relays, codecs, VHOSTs, signaling protocols, relay fields, codec output fields, resource catalogs, and completed resource versions. Ingestor controls additionally ask for a source reference by connector and entity, a decoding codec, relays with exact branch declarations, and fields of a selected branch’s key schema. Each source target requires a domain and returns what its entity reads through: clients of its connector, or for HTTP ENDPOINT and WEBSOCKETS ENDPOINT the endpoints of that TYPE. The ingestor codec target requires a domain and filters out codecs without decoding. An unbranched relay target requires a domain and also supplies ingestor error relay choices, while a branched relay target requires a domain then a branch model reference. Branch fields require the same pair. Decoded fields and output fields use the existing codec-field and relay-field targets. Placement requires exactly one domain-pace dependency. Schema, wire-schema, branch, relay, VHOST, signaling-protocol, resource, and codec lookups require exactly one domain reference. A relay-field lookup requires that domain reference followed by a relay model reference, and returns the fields of the relay’s records in the order its schema declares them; the other configuration lookups order their models by name. A codec-field lookup requires the domain followed by a codec model reference and returns the fields of that codec’s output schema in declaration order. A completed-version lookup requires the domain followed by a resource reference. It offers LATEST and each completed uploaded version, excluding applying or failed uploads. Resource catalog choices include resources staged in the session’s attached transaction. These lookups read the domain’s current Models with the requesting session’s attached transaction prefix applied, so a model staged earlier in that transaction appears before commit. An absent or differently typed dependency, a domain that does not exist, or a relay or codec the configuration no longer has returns MissingContext. A relay or codec whose output schema the configuration does not declare returns LookupFailed.

Each result separates semantics from presentation. ChoiceValue carries a domain-pace or placement-policy variant, a typed domain, resource, or model reference, or a reference to a field of the record the dependencies select, or a requested resource version as an explicit number or LATEST. ChoicePresentation carries its label, optional detail, and optional group; a relay or codec field’s detail is its exact type followed by OPTIONAL and SENSITIVE as its schema declares them. A client selects by the typed value and never derives behavior from the label. Ready with no values is an ordinary empty match; MissingContext, StaleContext, and LookupFailed remain distinct outcomes.

A page cursor binds the target, every dependent value, search text, application revision, and the ordered typed candidate set including its presentation metadata. Changing any part returns StaleContext instead of continuing through a different result. Schema, branch, and relay pages also bind the canonical definition of each matching model, as do VHOST and signaling-protocol pages. Relay-field pages bind the relay and its schema, so a change within an attached transaction invalidates a cursor even when names and field counts stay the same. Wire-schema pages bind their canonical definitions; resource pages bind names and completed-version counts, and version pages bind the offered version values. Choice lookups are read-only and can run concurrently with each other and with commands. The web console additionally correlates each lookup with its control, draft revision, and session generation, so a late reply cannot replace the choices for a newer edit or connection.

Transaction Binding

An NSPL transaction is replicated control-plane state, but its binding to a live session is leader-local soft state. A session may bind one transaction, and a transaction may be bound to at most one session. The session protocol can attach by transaction id; the authenticated user must match the transaction owner. A later attach takes over the binding and the displaced session gets an explicit takeover error on its next transaction operation.

A transaction also carries the domain it is bound to, and every statement queued in it must name that domain. The CLI, web console, and Rust client adopt the transaction’s domain as their selected domain when they attach it, and refuse USE while a transaction is active.

The CLI, web console, and Rust client retain the transaction id, each append’s execution reference and expected position, and the commit execution reference. They automatically attach after a redirect or transport reconnect before resuming a command. A leader that has no binding for the session’s transaction answers with a distinct detached result rather than an ordinary error, and the client attaches again and replays the command instead of surfacing it. An unclean transport loss, node loss, or leadership change therefore leaves an open transaction intact until attach or idle expiry. A client matches the attached progress to the outstanding append or commit and does not satisfy that request from an unrelated transaction count. During election convergence, a client also retries a bounded interval when a peer cannot yet advertise the new leader. A session that its client closes cleanly, with no request in flight, reverts the open transaction bound to it on the leader; any other ending only releases the binding. Ending a session never reverts admitted append work or a transaction whose replicated state is already COMMITTING; the leader finishes it without a client.

Finished transaction outcomes remain available during tombstone retention. Attach during that window reports COMMITTED, FAILED, REVERTED, or EXPIRED with the structured final status and the transaction’s aggregate outcome. After retention, attach reports an unknown transaction id. See Replicated NSPL Transactions and Transactions Over The Protocol.

Backup And Restore

A backup writes a whole cluster, or one domain, into one archive file on the machine of the client that ran it. The archive holds each domain’s committed models as NSPL, each domain’s lifecycle and clock, its resource catalog with the original bytes of every resource version, and, for a cluster backup, every user. The archive is a public format: a tar stream whose sections standard tools can list and extract, and whose records every reader validates before it trusts them.

A normal backup pauses each running domain in turn, waits for its intake and acknowledged work to drain, then captures its committed configuration, WASM guest checkpoints, Kafka source offsets, branch lifecycle, fresh materialized relay generations, deduplicator keyspaces and the rows every window retains. It resumes that domain before transferring the captured sections into the archive. Stopped domains need no pause. WITHOUT PAUSE captures the latest published state while the domain runs and has crash-consistent rather than quiesced semantics; WITHOUT STATE captures configuration only. Queued relay batches and acknowledgements remain volatile.

A restore recreates what an archive holds: every domain and user of a cluster archive in a fresh cluster, whatever its nodes are named, or one domain beside the domains a cluster already has, under its archived name or a new one.

Backing Up

BACKUP CLUSTER TO '/var/backups/nervix/cluster.nvxb';
BACKUP DOMAIN payments TO './payments.nvxb';
BACKUP DOMAIN TO './current-domain.nvxb' WITHOUT RESOURCES;
BACKUP DOMAIN payments TO './payments-live.nvxb' WITHOUT PAUSE;
BACKUP CLUSTER TO './cluster-config.nvxb' WITHOUT STATE;
  • BACKUP CLUSTER covers every domain and every user.
  • BACKUP DOMAIN <name> covers one domain. Without a name it covers the session’s selected domain. A domain backup holds no users.
  • WITHOUT RESOURCES records every resource version with its checksums and sizes, and leaves out the version’s bytes.
  • WITHOUT STATE omits runtime state and takes no domain mutation lease.
  • WITHOUT PAUSE includes published runtime checkpoints without pausing a running domain. Its state and configuration need not be from one quiesced cut.
  • TIMEOUT <duration> bounds each running domain’s quiesce wait. It is valid with the normal quiesced capture. Domains are captured in sequence and each receives its own budget. Each owner stages its share of a domain’s state within that same budget, however long its state takes to stage; a stopped domain’s owners stage within the budget too, and a WITHOUT PAUSE capture’s owners within the default one. The command’s total wait includes all cuts, admission and archive assembly.
  • The path names a file on the client’s machine. A leading ~/ refers to the client user’s home directory. In the web console it names the file the browser saves the archive as.

BACKUP runs in nervix-cli, in the Rust and C clients, and in the web console, which saves the archive as a browser download; see From The Web Console. It must be sent alone: not in a batch with other statements and not while a transaction is open.

Each domain records the applied configuration revision and Raft log entry of its own capture. Before reading state, each owner waits up to five seconds to apply the selected log revision, brings its runtime plan current, and checks the sending leader again. An owner that cannot reach the revision refuses capture. This also applies to stopped and live captures. For a quiesced running domain, the leader holds a replicated domain mutation lease, pauses and drains the domain across live nodes, asks state owners to publish and stage their checkpoints, and reads the configuration while the cut is held. The leader resumes the domain and releases its lease before copying staged state into the final archive. Other domains continue independently. Each owner asks its active ingestor, reingestor, and processor supervisors to checkpoint their current branch lifecycle, including branches created since the periodic snapshot. Capture retains the immutable checkpoint published by each entity’s replication handle; a handle awaiting its first checkpoint contributes no lifecycle state. It seals those checkpoints and the current Kafka offsets durably before opening one database snapshot that also contains the already durable WASM guest saves. A stopped domain reads its stored checkpoints without active task requests. The cut lists that snapshot’s checkpoints without reading their payloads, and reads only those it archives, all from the same snapshot however late they are read. A branch lifecycle is read while the cut is taken only when a WASM, deduplicator or window branch of its entity needs a typed key, one lifecycle at a time, and only the keys those branches need are kept. Each selected WASM save stays in that snapshot until its scheduled generation has been checked. The capture storage job inspects a selected segmented header’s bounded size; staging admission uses that recorded size without a database read on an async worker. The owner then opens one save at a time, charges its inline checkpoint conversion to restore_metadata, and streams its raw bytes into a quota-owned staged section under a fixed 192 KiB bulk working charge. The checkpoint read supplies both the bytes and the descriptor’s revision. The writer seals the section’s actual length and BLAKE3 digest. Neither another save nor an encoded guest section is retained whole during the write; a superseded save is never opened. Each Kafka offset and branch lifecycle checkpoint then becomes its own archive section. It is read whole into one aligned allocation charged to restore_metadata, its entries convert one at a time, and its record streams through a 64 KiB bulk buffer into a quota-owned staged file that seals the section’s exact length and digest. The record’s serializer scratch, one resolver per entry, is charged to restore_metadata beside the checkpoint. Neither the converted entries nor the encoded record are held whole, so native metadata larger than the bulk budget is archived within it. Materialized relays capture shared Arrow row views, typed branch keys, per-row watermarks, revision, ownership fence and branch generation under their assignment barrier. Capture reads the current entries at the cut, including updates since the periodic snapshot; it never waits for that interval. An empty relay contributes an explicit empty generation. A live capture obtains the same complete relay generation under that barrier, while its domain-wide cut retains live capture semantics. The lifecycle checkpoint also makes every active deduplicator and window processor branch task publish its state. For each branch a captured lifecycle holds, capture takes the keyspace its deduplicator published, every key with the time it was first seen, and the rows its window retains, with their aggregate argument values, admission sequences and watermarks, the branch incarnation the window belongs to, and every linear histogram’s pending delayed removals. A branch with no live publication on its owner, including an inactive window with a sealed publication, contributes its stored checkpoint. A stopped domain reads stored checkpoints only. An evicted branch contributes nothing. Stored keyspace and window selections retain the cut’s immutable database view until their scheduled owner and schema are checked. Each owner opens one selected checkpoint at a time, streams it to quota-owned disk in 64 KiB blocks, and admits an immutable mapping after unlinking its temporary name. The checkpoint revision comes from the same read as its bytes. A running branch’s live publication supersedes its stored checkpoint before that checkpoint is opened.

Keyspace validation compares borrowed native keys for uniqueness and records their sizes; only the next archive group is converted into owned key parts. A stored window validates its header, sequences, identities and delayed removals, then indexes its native column sections. It decodes one native group at a time to determine the canonical archive boundaries, releases those decoded columns, and subsequently reads only sections overlapping each input or argument group. The descriptor and every group’s order, bytes, length and digest use the same archive contracts as capture of a live publication. The drain uses the shutdown admitted-work view: active intake and generators, active source ACK roots, relay and node buffers, relays that admitted a batch while the node read that view, and emitter buffers or publishes. Parked REQUIRED WAIT messages and the rows a window retains until it advances are exempt: their acknowledgements wait for input the paused domain does not admit, and the cut captures them as state. Once every node reports no admitted work, the leader requests a confirming force-flush generation on every node and waits for its obligations to finish before asking owners to capture. An unavailable sink keeps its publish and ACK counts outstanding until TIMEOUT expires; the failed backup reports those counts and resumes the domain. A leader-tenure change during a cut refuses the archive. The paused-domain recovery path releases the old coordinator’s lease and resumes the domain under the new leader. A client that retries the same execution reference may complete a new cut under that leader.

The server renders each domain’s models as NSPL, parses the rendered text back, and refuses the backup unless it yields exactly the committed models. A resource version’s bytes are read from the leader’s resource store and checked against the size the catalog records.

A completed backup reports the archive it wrote:

backed up the cluster: 2 domains, 3 users, 48213504 bytes, digest 9f86d081…; archive written to '/var/backups/nervix/cluster.nvxb'

The client writes the archive into a private file beside the destination, with permissions that only the owner may read and write (0600), and replaces the destination only once every byte arrived and matched the archive’s size and BLAKE3 digest. A destination never holds a partial or foreign archive.

From The Command Line

nervix-cli backup runs one backup and exits:

nervix-cli backup cluster --output cluster.nvxb
nervix-cli backup domain payments --output payments.nvxb --without-resources
nervix-cli backup domain payments --output payments.nvxb --timeout 30s
nervix-cli backup domain payments --output live.nvxb --without-pause
nervix-cli backup cluster --output config.nvxb --without-state
nervix-cli backup domain --output - > current-domain.nvxb
nervix-cli backup cluster --output cluster.nvxb --format json
ArgumentMeaning
cluster or domain [NAME]What the archive covers. domain without a name covers --domain.
--output PATHWhere the archive is written. - writes it to standard output.
--without-resourcesLeaves resource version bytes out of the archive.
--without-stateCaptures configuration only.
--without-pauseCaptures published runtime state without quiescing a running domain.
--timeout DURATIONBounds the normal quiesced capture of each running domain.
--backup-wait-timeout DURATIONBounds the overall command wait across all cuts, redirects and reconnects. Defaults to 10m; also applies to NSPL through --command and the REPL.
--execution-reference REFERENCERecovers the same backup under its durable reference, with the original selected domain, scope, resources and capture options. The output destination may change.
--format text or --format jsonHow the report is printed. Text is the default.

When the archive goes to standard output, the report goes to standard error, so standard output carries only the archive. The command exits with a nonzero status whenever the archive was not delivered. With --format json the report is one JSON document:

{
  "execution_reference": "0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44",
  "output": "cluster.nvxb",
  "total_bytes": 48213504,
  "blake3": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "captured_at": "2026-09-27 12:00:00 UTC",
  "resources": "included",
  "users": 3,
  "domains": [
    { "domain": "payments", "revision": 812, "cut": { "kind": "quiesced", "engaged_at": "2026-09-29 10:00:00 UTC", "released_at": "2026-09-29 10:00:01 UTC", "buffered_records": 0, "buffered_bytes": 0, "dropped_records": 0, "rejected_records": 0 }, "sections": 7, "section_bytes": 48190112 }
  ]
}

A failure prints {"error": {"code": "...", "message": "..."}} instead. The codes are INVALID_ARGUMENTS, CONNECTION_FAILED, BACKUP_FAILED for a failure between client and server, BACKUP_REFUSED for a backup the server refused, WRITE_FAILED for an archive that did not reach standard output, and CLEANUP_FAILED for an archive that reached standard output while its staging directory could not be removed.

Delivering To Standard Output

With --output -, the CLI first makes sure standard output can receive the archive. It then downloads the archive into a staging directory of its own under the system’s temporary directory (TMPDIR on Unix), as a file only its owner may read and write (0600), and verifies it there before it copies a byte to standard output. The complete download releases the server’s copy of the archive, so from then on the staged archive is the only one. The copy goes through a descriptor of the CLI’s own for standard output, without a buffer, so every write reaches the operating system and every failed write is reported.

  • A standard output that would discard the archive fails with WRITE_FAILED before anything is staged or sent: the null device, which accepts every byte and keeps none, and a standard output that was closed when the CLI started, where the CLI finds the null device instead. The CLI cannot tell the two apart and refuses both. No backup was taken, so the report names no execution reference; give the CLI a pipe, a file or a terminal and run the backup again.
  • A staging directory that cannot be created fails with WRITE_FAILED before the backup is sent. No backup was admitted, so the report names no execution reference.
  • A delivery that fails after the download fails with WRITE_FAILED: a reader that closed the pipe, a full disk behind a redirection, or a staged archive that could not be read. A closed pipe does not end the CLI through SIGPIPE; its failed write is reported like any other. Standard output may hold part of the archive, and the report goes to standard error as always. The CLI keeps the verified archive in its staging directory, and the report names it together with the backup’s execution reference: JSON adds error.execution_reference and error.archive, and text ends with recover backup REFERENCE from its verified archive at 'PATH'; ....
  • When every byte reached standard output but the staging directory could not be removed, the command fails with CLEANUP_FAILED and names the execution reference and, in JSON, the directory as error.staging. The archive on standard output is complete; remove the directory, which may still hold a copy of it.

Recover an undelivered archive by delivering the kept file, for example by moving it to a working destination. It is the archive the backup assembled, with the summary and cuts its execution reference records, so no new capture is needed. Running the backup again with --execution-reference returns that recorded outcome, but its download is refused as NotRetained, because the first complete download collected the archive. Nothing removes a kept archive: delete it once it is delivered, and treat it as the secret it is.

nervix-cli backup cluster --output - --format json | ssh backup-host 'cat > cluster.nvxb'
# {"error":{"code":"WRITE_FAILED","message":"the backup archive could not be written to standard output: Broken pipe (os error 32)","execution_reference":"0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44","archive":"/tmp/.tmpQ2x9Lk/backup.nvxb"}}
mv /tmp/.tmpQ2x9Lk/backup.nvxb cluster.nvxb

Waiting And Recovering

The native client uses one bounded overall command wait for BACKUP, ten minutes by default. --backup-wait-timeout sets it independently of each domain’s --timeout, without counting or predicting domains. Native wait options accept durations from one millisecond through 24 hours. Redirects and reconnects share the same deadline and execution reference. Ordinary request and retry deadlines remain separate; an archive download begins after the command completes and bounds each frame’s wait with the ordinary request timeout.

When the command wait expires, admission may already have happened. The CLI exits unsuccessfully and reports the durable reference. A JSON BACKUP_FAILED report includes error.execution_reference for an uncertain command or failed archive download. Recover promptly with the same selected --domain, scope, resource inclusion and capture options:

nervix-cli --domain payments backup cluster --output cluster.nvxb --timeout 30s --backup-wait-timeout 4m --format json
nervix-cli --domain payments backup cluster --output recovered.nvxb --timeout 30s --backup-wait-timeout 10m --execution-reference 0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44

Use the reference returned by the first command. Recovery waits for the applying command or returns its recorded outcome, then downloads the retained archive. --output - may also be used for recovery. A WRITE_FAILED report that names a kept archive follows a complete download, which already collected the server’s copy, so it is recovered from that file instead; see Delivering To Standard Output. Changing capture inputs conflicts with the bound request. A fresh reference starts a separate backup. Expiration of the client wait ends only that waiter; it does not cancel the admitted command or extend the server’s retry validity or archive retention. A longer wait can therefore recover a terminal outcome whose archive is already unavailable, especially when the server has a shorter retry validity. Such a download is reported as a failure with its reference.

Downloading The Archive

The leader assembles the archive in its staging area and retains it under the backup’s execution reference. The client then downloads it through the session service’s DownloadBackup streaming call, which the web console reaches through a console WebSocket of its own; Sessions describes the call.

  • The archive is retained until a download receives all of it, or until the retry validity of the backup’s execution reference ends, 15 minutes by default; see Command Completion.
  • A download is not bounded by the request timeout, because an archive may take far longer to transfer than a command takes to run. Each frame must arrive within the request timeout instead.
  • A download that fails in transport starts again from the first byte. Running the backup again with the same execution reference returns its recorded outcome, and the client downloads the archive again while it is retained.
  • The first download that receives the whole archive releases it. A later download of the same backup is refused, and a new backup is needed for another copy.
  • Only the user who ran the backup may download its archive.
  • The archive stays on the node that assembled it. A download sent to another node that does not hold it is redirected to the leader.
  • A node that restarts loses the archives it retained, and their downloads are refused. If leadership moves while a backup runs, the new leader runs the backup again under the same execution reference and retains the archive it assembles.

Inspecting An Archive

DESCRIBE BACKUP './cluster.nvxb';
DESCRIBE BACKUP './cluster.nvxb' FORMAT JSON;

DESCRIBE BACKUP runs in nervix-cli against a file on its own machine and contacts no server. It reads the whole archive and verifies every section’s length and digest before it prints anything. The description names the archive format, the Nervix and NSPL releases that wrote it, the cluster, the capture time, the scope, whether resource bytes are included, and the users of a cluster archive. For each domain it names the revision and Raft log entry the domain was read at, its status, cut kind, pace and start count, the size and digest of its models, and each resource version with the root_checksum and manifest_checksum that DESCRIBE RESOURCE prints for the same version. It also reports the latest start point, clock mapping and logical frontier, and inventories WASM guest saves by processor and branch fingerprint, Kafka positions by topic and partition, branch lifecycle records by processor and branch count, and materialized relays by schema fingerprint, revision, fence, branch generation, row count and bounded Arrow groups. Deduplicator keyspaces are listed by branch fingerprint with their key count, groups, revision and schema fingerprint. Window processor state is listed by branch fingerprint with its retained rows, groups, branch incarnation, next admission sequence, pending delayed histogram removals, revision, schema fingerprint and window model digest. The inspection does not print branch-key field values or deduplicator keys.

backup: ./cluster.nvxb
format: 1
producer_version: 0.1.0
language_version: 0.1.0
cluster: nervix-3f1c
captured_at: 2026-09-27 12:00:00 UTC
scope: cluster
resources: included
users: default,operator
domains:
- domain=payments revision=812 raft_term=4 raft_index=812 status=RUNNING pace=unpaced start_version=2
  cut: quiesced
  models: bytes=4213 blake3=5d41402abc4b2a76b9719d911017c592…
  resource_versions:
  - resource=proto version=1 state=completed root_checksum=… manifest_checksum=… file_count=3 total_bytes=18204 archive_bytes=24576 created_by_node=node-1 created_at=2026-09-20 08:15:00 UTC archive=included blake3=…
  runtime_state:
  - deduplicator=unique_payment_filter branch=4f2a… keys=1204 groups=1 revision=37 schema_fingerprint=…
  - window_processor=latency_window branch=4f2a… rows=1 groups=1 incarnation=1 next_sequence=6 delayed_removals=4 revision=12 schema_fingerprint=… model=…

An archive that fails verification is refused whole, with the section and the check that failed.

Restoring

RESTORE CLUSTER FROM '/var/backups/nervix/cluster.nvxb' ON EXISTING USER SKIP;
RESTORE DOMAIN payments FROM './payments.nvxb';
RESTORE DOMAIN payments FROM './payments.nvxb' RESUME;
RESTORE DOMAIN payments AS payments_copy FROM '/var/backups/nervix/cluster.nvxb';
RESTORE DOMAIN payments AS payments_copy FROM './payments.nvxb' DRY RUN;
RESTORE DOMAIN payments FROM './payments.nvxb' WITHOUT SOURCE OFFSETS;
  • RESTORE CLUSTER recreates every domain and imports every user of a cluster archive. It refuses a domain archive.
  • RESTORE DOMAIN <name> recreates the archived domain <name> of a cluster or domain archive, and imports no users. AS <new_name> restores it under another name, which is how a domain is copied beside its original in the same cluster.
  • ON EXISTING USER decides what a cluster restore does with an archived user the cluster already has. FAIL, the default, refuses the restore before it changes anything. SKIP keeps the existing user and its password. REPLACE gives the existing user the archived password hash. Every other archived user is created with its archived password hash, so its original password authenticates.
  • DRY RUN receives and verifies the archive and plans the whole restore, and changes nothing.
  • RESUME makes each restored domain running after its complete state set is published on every assigned owner and replica. Its start generation and latest start point stay exactly as archived. Omit it to leave the restored domain stopped. It precedes ON EXISTING USER and DRY RUN.
  • WITHOUT STATE restores the configuration and purges state for the target domain.
  • WITHOUT SOURCE OFFSETS restores WASM, branch lifecycle, materialized, deduplicator and window state but starts sources without the archived Kafka positions.
  • A state section whose entity is absent or whose schema fingerprint differs from the published schedule is skipped. The restore succeeds and reports a warning naming the skipped state in its command diagnostics and CLI output; that entity starts without the skipped checkpoint.
  • Every other archived branch key must be a key of the branching its restored entity declares: each branch lifecycle entry, the branch each WASM, deduplicator and window descriptor names, and the branch of each materialized record identity. See Archived Branch Keys.
  • A window whose restored window processor is not the archived window model, or whose branch incarnation is not the one the restored branch lifecycle holds for that branch, is skipped with a warning naming the reason, and that branch’s window starts empty.
  • A verified state record with an unknown kind tag or unsupported record version is also skipped with a warning. The archive reader still rejects malformed records of a supported kind, and always validates section lengths and digests.
  • The path names a file on the client’s machine. A leading ~/ refers to the client user’s home directory. In the web console it names the archive file the operator chose.

A fresh cluster already has the user its configuration creates when the cluster starts, and a cluster archive holds that user too, so restoring a cluster archive into a fresh cluster takes ON EXISTING USER SKIP to keep the fresh cluster’s password, or ON EXISTING USER REPLACE to take the archived one.

RESTORE runs in nervix-cli, in the Rust and C clients, and in the web console, which streams an archive file the operator chooses; see From The Web Console. It must be sent alone: not in a batch with other statements and not while a transaction is open.

What A Restore Recreates

  • Users. Each archived user, with its password hash exactly as the archive holds it.
  • Domains. Each restored domain is initially created stopped, with its archived pace and placement default, start generation and latest start point. Complete publication releases its installation gate. With RESUME, that same consensus effect changes it to running, without a new START. LIST DOMAINS, DESCRIBE DOMAIN, restore reports and dry-run reports expose the status and start generation; DESCRIBE DOMAIN also exposes the start point and installed mapping.
  • Resources. Each resource the domain declared, and each completed version under its archived number, with its checksums, file count, sizes, creation time and creating node as DESCRIBE RESOURCE showed them in the source cluster. A number the source assigned to an upload that failed or was still installing when the backup read it stays a gap, and the next upload of the resource receives the number the source would have assigned next. See Resource Versions.
  • Models. Every model of the domain, bound to exactly the resource versions it was bound to in the source cluster. SHOW CREATE prints each restored model as it printed it in the source.

A restore stages the archive’s compatible branch lifecycle, WASM guest checkpoints, Kafka positions, materialized relay checkpoints, deduplicator keyspaces and windows on every newly assigned owner and replica after its models are scheduled. Each node validates the complete staged inventory, synchronizes its generation namespace, then atomically selects it through one durably synchronized active-generation pointer. Nodes without assigned checkpoints publish an empty set. In-memory state handles are cleared only after that publication. A replicated installation gate prevents START until every target node has published the complete set; it survives failure, lease release and node restart. A source uses its restored next offset when it starts, clamped to the partitions its current source assignment contains and clamped to their live low and high watermarks. Materialized state binds to the exact restored schema fingerprint and archived start generation before a branch or generator starts. Its rows, typed keys, watermarks, revision and branch generation are preserved. A new assignment establishes its own ownership fence.

A deduplicator branch restores every archived key under the restored schema fingerprint, with the time the key was first seen in the source cluster. Each key is normalized as the restored DEDUPLICATE ON expressions key the values they produce, so a duplicate of an archived message is suppressed once the restored domain runs, and the key expires MAX TIME after its archived first sighting rather than after the restore. A window branch task re-admits the archived rows in their admission order when it starts, which rebuilds every aggregate structure, including sketches, and then restores each linear histogram’s pending delayed removals. A window that was partly filled at the cut therefore closes over rows from both sides of the backup. The window must still be the archived window model, and its branch must be the lifetime the restored branch lifecycle holds: otherwise the restore reports a warning and that branch’s window starts empty. Branch keys, admission sequences, watermarks and aggregate argument values are preserved exactly.

Archived Kafka positions follow acknowledged records, and a window acknowledges a row only when it steps past it. The positions of a domain that reads Kafka records into a window therefore stand before the rows its windows retain at the cut, and a source that resumes from them reads those rows again, which the restored window then admits beside the rows it restored. A restart has the same effect; see Shutdown And Recovery.

For a paced RESUME, the archived wall/logical origin and time rate are installed verbatim. The mapping projects elapsed downtime; the leader chooses a fresh clock authority for this cluster. The archived frontier is inspection metadata, rather than a new clock origin. TIMESTAMP AT admission follows the projected reached windows. A paced archive without its committed mapping cannot resume and is refused before admission. An unpaced resume needs no clock mapping.

A normal START after a default stopped restore, or after STOP, advances the start generation and clears materialized state. It preserves compatible WASM state, source positions, branch lifecycle, deduplicator keys and windows. To establish a new clock at the saved logical frontier instead, leave the domain stopped and use START AT '<frontier>' TIME RATE <rate> explicitly.

Archived Branch Keys

A restore installs an archived branch key only when it is a key of the branching its restored entity declares in the schedule the restore publishes. A processor runs every branch under its one declaration, and a relay’s materialized records carry its declared branch. An ingestor or a reingestor keeps one branch lifecycle for all of its routes, and each route writes the branch of the relay it targets, so a key it archived may belong to the branching of any of those relays, unbranched included. A key belongs to a branching when it is absent exactly where the branching is unbranched, and otherwise holds exactly the fields of the branch’s key schema, each with a value of exactly the field’s declared type: the same integer width, a float that is finite, a datetime that reads as RFC 3339 text, and an ARRAY of its declared length. A key lists its fields in field-name order, the order the archive reader requires. No value is converted to fit, and a key that does not belong is never staged, so no installed checkpoint names a branch the restored entity’s branch routing could not reach.

Planning checks every branch lifecycle entry and the branch of every WASM, deduplicator and window descriptor whose entity the planned schedule holds under the archived schema fingerprint; a section the restore skips is not checked. The conversion of a materialized relay checks the branch of each record identity as it reads it. A dry run runs the same checks, and a restore WITHOUT STATE installs no archived key and checks none. A key that does not belong refuses the whole restore before it changes anything. The refusal names the archive section, the lifecycle entry, descriptor or record identity, numbered from 1, the entity, the branches it runs in and, for a key of another shape, the field that is missing, undeclared or of another type. It never includes a key value, which a branch schema may declare SENSITIVE:

restore refused: the branch key of lifecycle entry 1 in archive section 'domains/payments/state/branch_lifecycle/deduplicator/unique_payment_filter/branches.rkyv' does not belong to the branching of deduplicator 'unique_payment_filter' in domain 'payments': it is not a key of branch 'by_tenant': field 'tenant' does not hold a value of its declared type: expected STRING, found I64

Installation checks the same keys against the schedule it installs under before it stages any state, so a restore resumed by a new leader or a retry stages none that does not belong either.

Publishing The State Generation

A node writes restored checkpoint bytes into an installation-specific namespace in chunks of at most 64 KiB. A completed checkpoint has a revision, length and BLAKE3 digest and an authority-bound receipt; incomplete chunks have no completed receipt. Guest saves stream directly from the staged archive on the coordinator and from the sealed upload file on a remote node. Neither path assembles a guest save in a second full-size buffer or reserves a file-read chunk inside an already reserved full checkpoint. Materialized payloads use archive-owned rkyv descriptors and scalar identity records with exact-schema Arrow IPC column sections. Planning, including a dry run, verifies their Arrow schema, row count and unique typed branch identities. Conversion stages bounded native header, identity and column pieces, then concatenates them into a quota-owned file with a 64 KiB buffer. That file uses the same streamed publisher as a guest save, including when one relay’s container exceeds the 32 MiB bulk budget. It never enters encoded metadata installation. Temporary conversion pieces and the completed container can briefly occupy twice the container’s disk space. Deduplicator and window sections follow the same boundary, including a keyspace or a window whose native checkpoint exceeds the 32 MiB bulk budget. Planning, including a dry run, and installation decode every key group, input group and argument group under the exact Arrow schema the restored models give it, one bounded group at a time on the bulk CPU workers. Each decoded group is charged to bulk memory only while it converts. Conversion checks the key and row counts, rejects a key that appears twice, and checks each delayed histogram bucket against the restored histogram’s bucket count.

Every Arrow section of an archive is scanned before Arrow’s reader reads it, whatever digests the archive carries for its own bytes. A section whose stream is not framed within its bytes is refused as misframed, and one that declares a field type Nervix does not carry, or a record batch at odds with its own schema, as undecodable, when planning reads it, a dry run included. Nothing is allocated from a length the section only declares.

A deduplicator conversion holds the keyspace as the runtime holds a restored one: every key normalized as the restored DEDUPLICATE ON expressions key it, in an expiry map. That is how a key the archive holds twice is found. The parts and values of the keys follow their Arrow columns, and are charged to the restore_metadata class as each group is admitted; a conversion that class cannot hold now is refused rather than kept waiting. The fixed share of every key, its map entry and its serializer resolver, was admitted with the archive description. The native checkpoint then streams from the resident keyspace into a quota-owned file through a 64 KiB buffered writer under the fixed 2 MiB bulk grant, converting one entry at a time. It is the same encoding the runtime writes for the same keys.

A window conversion seals each archived group’s input rows, and then its argument columns, into quota-owned pieces as soon as each is admitted, so it holds one archived section and its decoded batch at a time and never the whole window. When a group’s row identities fit one 1 MiB identity record, its archived input and argument sections become the checkpoint’s Arrow sections unchanged. Otherwise the group splits into groups that fit, whose columns are projected again. The window header, which counts the rows and the nested containers’ bytes, is written last and placed first; the delayed histogram removals follow in bounded typed sections. The pieces are then concatenated into the checkpoint file with one 64 KiB buffer, and briefly occupy twice the checkpoint’s disk space.

A conversion the node refuses only for room does not fail the restore. A memory class with no room left, a worker queue that is full and a staging quota that cannot hold another piece judged nothing of the archive, so the conversion releases what it holds and converts the refused unit again. For a window the unit is one section: it is read and decoded again into a checkpoint that is exactly as it was before the refused attempt, whose staged pieces were dropped. A window refused while its sealed pieces are finished into one checkpoint is converted again from its first group. For a keyspace the unit is the keyspace from its first group, because the keys already admitted are what held its charge. A unit is converted again every 50 ms for up to 30 seconds after the node first refuses it. A refusal that outlasts that wait ends the conversion and is reported as the node having had no room for it, since what the restore itself holds can be what fills the budget. A request larger than a whole budget, and archived content that does not fit the restored shapes, end it at once.

Backup section openings share Snapshot admission with materialized readers. A capacity-only refusal retries within the opening’s single 30-second deadline; other failures end the fetch. Completed response streams release their transport permits before local verification and decoding.

Publication checks each receipt, checkpoint header and ordered chunk against the complete expected inventory. It synchronizes those writes before committing the small pointer containing the exact installation authority and inventory, then synchronizes that pointer before clearing runtime handles or acknowledging publication. Cancellation or failure on either side of pointer publication leaves the replicated start gate closed. An exact retry repeats pointer durability and cleanup; it cannot select different counts or a different authority under the same generation.

Every read selects the pointer, checkpoint headers and chunks from one database snapshot. Readers that opened a view before publication keep its complete prior set while obsolete keys are deleted. Checkpoint jobs retain the namespace they selected before execution and compare it again under the installation barrier; a queued job for another namespace cannot write into the restored set. Normal checkpoint writes and replica installation use the selected namespace. Generation identity is separate from a WASM branch’s guest-state lifetime and its checkpoint revision.

Successful publication removes obsolete namespace keys and completed or abandoned receipts through bounded deletion batches. A snapshot can retain the data it still reads after that deletion. Within the selected namespace, whether initial or restored, the same maintenance owner removes chunk sets whose selected header is absent, inline, or names another checkpoint revision. Segmented replacement and incomplete streamed revisions therefore use the same liveness check. Ordinary checkpoints, replica installation, handoff, forced recovery, module rebinding and entity or stale-identity purge therefore release unreachable restored payloads without requiring another restore generation. Existing snapshot readers retain complete values through Fjall’s snapshot retention. Every node runs restore checkpoint maintenance at startup and then one second after each completed sweep, independently of leadership. It borrows one applied consensus revision and then takes the same installation barrier as checkpoint writers and publishers. A generation is retained while its exact installation belongs to an applying restore, even when leadership or its mutation lease has changed. A generation ahead of the node’s applied log is retained until catch-up establishes its outcome; a node without an applied log retains every generation. The selected durable publication is always retained. Terminal, expired, missing and superseded attempts become reclaimable once the node has applied their generation. Cleanup covers headers, revision indices, receipts and chunks, including chunks written before a receipt existed. It uses bounded deletion batches and synchronizes completed deletion; cancellation or restart resumes from remaining keys. Cursors seek past initial headers, indices and receipts and selected headers/indices; deletion also seeks past retained applying or future generations. In the selected chunk namespace, one header check selects each placement/revision set, and a cursor skips the whole referenced set. Unreferenced sets use the same bounded deletion batches. Checkpoint writers, purges and pointer publication share the installation barrier, so the selected view stays authoritative during cleanup. Readers keep their complete database snapshots. Reclamation never releases the start gate or changes the selected publication.

A segmented header consists only of its fixed archived root. Maintenance checks the value size before copying or decoding that bounded header; a larger valid current value is an inline checkpoint. Fjall’s ordinary LSM size query can still read and cache the underlying value. Database reads, caches, memtables, snapshot retention and compaction are separate allocation owners; the fixed 2 MiB maintenance reservation bounds its caller-owned buffers rather than total database memory. Runtime storage requires its current format marker. A missing or invalid marker in nonempty checkpoint storage fails with an instruction to recreate the node state directory.

Local archive readers and received upload readers retain their staged artifact and disk quota through the storage job, including cancellation.

All checkpoint installation and generation-publication jobs reserve 2 MiB of bulk memory per node, independent of checkpoint bytes and count. Native lifecycle and Kafka conversion writes the current checkpoint encoding directly to a quota-owned file, using a 64 KiB buffered writer; it never collects converted entries or complete encoded output. The same file source drives local installation and ordered 64 KiB transfers to remote owners and replicas. Completed native files retain their actual length, digest and rounded disk quota until the final reader exits.

Restore preparation has a separate, per-node restore_metadata admission class, defaulting to 2 GiB. Before decoding, an admitted 512-byte read validates the first physical tar header as the regular manifest.rkyv entry and checks its length against the record limit; the manifest then verifies off the async workers under its own charge. A verified manifest sizes the retained preparation reservation: 16 times encoded record bytes (including the manifest), 64 times NSPL bytes, and 2 MiB of fixed overhead. Resource archives, guest saves and the materialized, deduplicator and window Arrow groups remain on disk; their conversion is charged separately. The charge covers overlapping owned archive values, aligned decode buffers, model parsing and planning copies, branch text and typed-key conversion, and native serializer resolvers. Once the description is decoded, the reservation also grows by the fixed share of every key the archive’s deduplicator descriptors count: the key’s entry in the resident keyspace and its resolver in the streamed checkpoint’s serializer scratch. A window descriptor’s record already carries each retained row’s watermarks, so its record charge covers the per-row identities a window conversion builds one group at a time. Admission refuses unaddressable estimates or unavailable capacity before a restore changes the cluster. Parsing and the pure restore planner run as admitted bulk CPU work.

The description, parsed Models and reservation have one shared lifetime. A native encoding job retains that owner through cancellation; each entry is converted separately, and fixed-capacity serializer scratch holds entry resolvers plus the largest entry’s nested resolvers. Resolver scratch therefore depends on entry count, while strings and encoded checkpoint output stream. The 2 MiB bulk charge measures bounded I/O and storage working memory, and does not describe total restore memory. Physical checkpoint placement encodings remain limited to 60 KiB. Database caches, memtables, compaction, and live-state materialization at START keep their respective owners and allocation bounds. The generation publisher has no aggregate-payload admission limit.

Materialized capture row views and grouping arrays have an 8 MiB metadata ceiling per relay and are charged before capture. Archive identities are at most 1 MiB per group, and Arrow columns at most 8 MiB. Typed branch uniqueness conversion has an independent 8 MiB commands reservation; native restore row views have an independent 8 MiB relay reservation. Decoded columns are charged cumulatively to relay memory through installation; loading refuses further groups when that class is full. Row projection and Arrow encoding share one admission and one bounded bulk CPU job, so projected columns never wait for a second grant while retaining the first. Typed native identities are also converted only after their allocation is charged. Arrow decoding is admitted one section at a time on the bulk CPU workers. Native checkpoint readers keep one database snapshot and at most one 64 KiB stored chunk across all their section reads. Ordinary materialized sealing also writes bounded pieces to quota-owned disk; cross-node generator reads stream the sealed file, and periodic persistence replaces a segmented checkpoint only after its chunks are durable. Those paths support a container above the default bulk budget. The ownership-handoff metadata boundary remains bounded by its resident-entry admission.

Restore Checkpoint Storage Quota And Metrics

nervix-server --restore-staging-max-bytes <bytes> and NERVIX_RESTORE_STAGING_MAX_BYTES set the node-local unpublished checkpoint limit, defaulting to 128GiB. This is separate from the quota for archive and transfer files. Admission counts the current key and value bytes of every unpublished restored namespace across all domains, including partial chunks, headers, indices and receipts. It measures value lengths without assembling payloads. The complete incoming checkpoint footprint must fit before the first chunk is written; the installation barrier prevents concurrent jobs from oversubscribing the allowance. An exact staging retry first removes its own incomplete checkpoint data and reuses that allowance.

A node measures that usage by reading every unpublished entry once, and keeps the measurement for as long as it stays exact. A checkpoint that stages completely adds its admitted footprint, which is exactly the keys and values it wrote. Whatever else changes what the measurement counts forgets it: a staging attempt that fails after its first chunk or replaces an earlier attempt at the same checkpoint, a sweep that reclaims a namespace or leftover receipts, and a publication. The next admission or sweep then measures again, and so does the first one after a node starts. Admission and an idle sweep therefore read no staged entry, however much another restore left unpublished on the node, and a restore staged beside a generation that is still retained costs what it stages itself. The measurement is taken, kept and changed only under the installation barrier. Published checkpoint data is outside this quota; leftover receipts remain staging until removed. Lowering the allowance on restart preserves retained installations that already exceed it and refuses additional staging until usage fits or those attempts end. A quota refusal reports the limit, used bytes and requested bytes and leaves the target stopped with its installation gate closed. After terminal failure, maintenance reclaims the attempt. Use the retained execution reference to recover an uncertain applying command; a terminal failed restore requires a fresh target domain for another restore.

The public /metrics endpoint reports these node-local series after completed sweeps:

SeriesMeaning
nervix_restore_staging_bytesCurrent unpublished checkpoint key and value bytes
nervix_restore_staging_keysCurrent unpublished checkpoint key count
nervix_restore_staging_limit_bytesConfigured unpublished checkpoint allowance
nervix_restore_staging_reclaimed_bytes_totalUnpublished and unreferenced active checkpoint key/value bytes removed by completed sweeps since node start
nervix_restore_staging_sweeps_totalCompleted sweeps since node start
nervix_restore_keyspaces_disk_bytesSST allocation of shared checkpoint and publication keyspaces

Logical usage excludes tombstones, database overhead and the bytes older snapshots retain. The SST allocation includes initial and published checkpoints, retained snapshot data and space awaiting compaction. Fjall reports its current LSM levels; files awaiting final unlink need not appear. It excludes the shared journal and other keyspaces. This quota bounds live unpublished data and does not impose a hard filesystem limit. Physical space falls as snapshots close and database compaction reclaims files. A cancelled or failed sweep updates no series and does not count its partial deletion; the next completed sweep reconstructs current usage, because a sweep forgets the kept measurement before it removes anything it counts.

Order Of Steps

A restore first receives the archive and plans everything it will do. Nothing changes before both have finished:

  1. Receive. The client streams the archive to the leader, which stages it in its staging area and checks that it is exactly the size and BLAKE3 digest the client declared.
  2. Verify. The leader reads the whole archive and verifies its manifest, every section’s length and digest, every record, and each resource version’s bytes against the version’s root checksum. It parses the models.nspl of every domain the archive holds, which must hold one CREATE of a model per statement.
  3. Plan. The leader resolves every archived user under the user policy, checks that no restored domain name exists, pins every model to the resource versions the restore imports, and plans each domain’s models with the transaction planner against the domain as the restore will create it. A dry run ends here and reports the plan.

The restore then applies its steps in order, and records each step in the replicated state as it completes:

  1. Import users, for a cluster restore.
  2. For each restored domain, in archive order:
    1. Create the domain, stopped, with its declared resources.
    2. Import its resource versions: each version’s bytes are installed from the archive on the leader and checked against the version’s checksums, the version is published under its archived number, and the restore waits until every live node has installed it, exactly as an upload completes.
    3. Apply its models as one batch, with full graph validation and the leader’s content checks: TLS material loads, lookup data loads, WASM modules compile, inference models load, and UDFs prepare. The batch is not bounded by the statement and source-byte limits of a transaction. Before recording this step, admit an installation generation bound to the leader tenure, restore execution and mutation lease. Stage compatible branch lifecycle, source offsets, WASM saves and materialized generations on the newly scheduled owners and replicas, then publish the complete set on every target node. Recording completion releases the replicated start gate. With RESUME, that same effect installs the archived clock mapping and makes the domain running; a delayed coordinator cannot republish after activation. WITHOUT STATE publishes an empty set, and WITHOUT SOURCE OFFSETS omits source positions.

A completed restore reports what it recreated, and each step:

restored the cluster from '/var/backups/nervix/cluster.nvxb': 2 domains, 5 resource versions, 14 models, users 1 created, 1 skipped, 0 replaced; restored domains are stopped
- applied: import users
- applied: create domain 'payments'
- applied: import resource versions of domain 'payments'
- applied: apply models of domain 'payments'
- applied: create domain 'ledger'
- applied: import resource versions of domain 'ledger'
- applied: apply models of domain 'ledger'

A dry run reports every step as planned, and for each domain the report of its model run: the transaction impact report the transaction planner produces for the batch of the domain’s models. Each domain also reports its planned or applied status and archived start_version.

A step that fails ends the restore. The steps before it stay applied, and the outcome names the step and the reason, followed by the report of every step:

restore failed at step 'apply models of domain 'payments'': ...; the steps before it stay applied

A domain the failed restore created stays stopped, with the resource versions and models it committed. Its durable installation gate keeps START blocked if state installation did not complete. Staging failures leave the complete previously published set visible on each node; a failure during distributed publication may leave nodes at different complete generations, but the domain cannot run. A restore never creates a domain whose name exists, so restore the archived domain again under another name with AS. A cluster restore that failed before it reached some domains leaves them for RESTORE DOMAIN.

Retries, Disconnects And Leader Changes

A restore is a persistent command under its execution reference, like any command that changes the cluster; see Command Completion. The leader admits it once the archive is verified and planned, and from then on it holds the mutation lease of every domain it restores, so no other command changes those domains while it applies.

  • A restore sent to a follower is redirected to the leader, and the client sends the archive there.
  • Once the leader has received the whole archive, the restore goes on when the client disconnects.
  • Sending the restore again with the same execution reference joins it: while it still applies on the leader, the answer says so at once, and once it finished, the answer is its recorded outcome and report. The Rust client sends it again until it has the outcome.
  • A restore stream the client abandons before the archive arrived whole changes nothing, and releases the staging space it reserved. Sending the restore again with the same execution reference sends the archive again from its first byte.
  • Only the leader the archive was streamed to holds it. If leadership moves, or the leader restarts, while a restore applies, the restore stays applying, and the next leader resumes it from its first step not recorded once the client sends the archive again with the same execution reference. The Rust client does so when it is redirected or its connection fails. A step is recorded together with its effect, so no step is applied twice. Resuming an unfinished state installation admits a new generation. Local and remote staging and publication revalidate authority at the storage mutation boundary; a delayed request from the preceding coordinator cannot replace the completed generation or clear its runtime handles.
  • If no client sends the archive to the new leader before the retry validity of the execution reference ends, 15 minutes by default, the new leader ends the restore as failed. The steps it recorded stay applied, and the outcome says how many.

The staged archive is released when its restore finishes, or when the retry validity of the execution reference ends.

Restoring From The Command Line

nervix-cli restore runs one restore and exits:

nervix-cli restore cluster --input cluster.nvxb --on-existing-user skip
nervix-cli restore domain payments --input payments.nvxb
nervix-cli restore domain payments --input payments.nvxb --resume
nervix-cli restore domain payments --as payments_copy --input cluster.nvxb --dry-run
nervix-cli restore domain payments --input payments.nvxb --without-source-offsets
nervix-cli restore domain payments --input payments.nvxb --without-state
nervix-cli restore cluster --input cluster.nvxb --on-existing-user replace --format json
ArgumentMeaning
cluster or domain NAMEWhat the restore recreates: every domain and user of a cluster archive, or the archived domain NAME.
--input PATHThe archive file to restore from.
--as NEW_NAMERestores the domain under NEW_NAME. Only for domain.
--on-existing-user fail, skip or replaceThe user policy of a cluster restore. fail when omitted.
--dry-runVerifies the archive and plans the restore, changing nothing.
--resumeMakes the complete restored domain running at its archived generation and clock mapping.
--without-stateInstalls configuration without runtime checkpoints.
--without-source-offsetsInstalls WASM, branch and materialized state, and leaves Kafka source positions unset.
--format text or --format jsonHow the report is printed. Text is the default.

While the archive streams, the command shows how much of it was sent on standard error, when standard error is a terminal. It exits with a nonzero status whenever the restore did not complete. With --format json the report is one JSON document:

{
  "execution_reference": "0192d4e4-7b36-7c3e-9f00-5b2d8c3a1e44",
  "input": "cluster.nvxb",
  "mode": "apply",
  "message": "restored the cluster from 'cluster.nvxb': ...",
  "total_bytes": 48213504,
  "blake3": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "captured_at": "2026-09-27 12:00:00 UTC",
  "users": { "created": 1, "skipped": 1, "replaced": 0 },
  "domains": [
    {
      "source": "payments",
      "domain": "payments",
      "resource_versions": 3,
      "models": 9,
      "status": "STOPPED",
      "start_version": 2,
      "planned_models": null
    }
  ],
  "steps": [
    { "step": "import users", "outcome": "applied" },
    { "step": "create domain 'payments'", "outcome": "applied" },
    { "step": "import resource versions of domain 'payments'", "outcome": "applied" },
    { "step": "apply models of domain 'payments'", "outcome": "applied" }
  ],
  "warnings": []
}

mode is dry_run for a dry run, whose steps are planned and whose domains carry their model run’s transaction impact report in planned_models. A step’s outcome is applied, planned, failed or not_attempted. users is null for a domain restore. warnings lists state sections skipped because their record kind or version is unsupported, or their entity or schema no longer matches the restored schedule. Text output prints the same warnings after the steps.

A failure prints {"error": {"code": "...", "message": "..."}} instead. The codes are INVALID_ARGUMENTS, CONNECTION_FAILED, RESTORE_FAILED for a failure between client and server, RESTORE_REFUSED for a restore refused before it changed anything, and RESTORE_INCOMPLETE for a restore that failed at a step. A RESTORE_INCOMPLETE error carries the restore’s report as report, in the shape above.

From The Web Console

The web console’s Backups dialog takes a backup as a browser download and restores an archive the operator uploads, with the same statements, options, reports, and failures as nervix-cli. It runs on the console’s own session and follows its leader redirect; a BACKUP or RESTORE typed in the console’s REPL runs through the same dialog. Web Console describes the dialog itself.

  • Backup. The console sends BACKUP as a command under an execution reference, then downloads the archive with the DownloadBackup call over a console WebSocket, from the node that reported the backup. It hands the archive to the browser as a download only once its size and BLAKE3 digest match the backup’s summary, under the last component of the file the statement names. A download that fails in transport starts again from the first byte while the archive is retained. A download the leader refuses ends with its reason, and the backup itself completed.
  • A reload resumes a backup. The browser tab records the backup’s execution reference, query text, and domain until its archive is downloaded. A reloaded page sends the backup again under that reference, which returns the recorded outcome instead of running the backup twice, and downloads the archive again while it is retained, unless the download the reload interrupted had already collected it by queuing its last bytes. Closing the tab or signing in as another user forgets the record.
  • Restore. The console reads the chosen file once to measure its size and BLAKE3 digest, and streams it with the RestoreBackup call over a console WebSocket to the leader. The dialog runs the DRY RUN form first: its report, and each domain’s planned model run drawn as an impact report, must plan the current options and file before the restore itself can run. The restore’s report lists each step as applied, failed, or not attempted, and the result lists the warnings for the state the restore skipped.
  • Retries. Every attempt of a restore sends the same execution reference, statement, and archive. A redirect streams it again to the leader, and a lost connection or an unknown outcome streams it again from its first byte, for about ten minutes; after that the console reports the outcome as unknown and names the reference.
  • What the browser shows. The dialog shows the summary a backup reports and the report a restore returns, and never what an archive holds beyond them. DESCRIBE BACKUP runs only in nervix-cli.
  • Sensitivity. The browser saves an archive with its downloads directory’s permissions, not the owner-only file nervix-cli writes. A downloaded archive holds password hashes, client secrets, and TLS private keys; keep it where only its owner can read it.

Archive Format

An archive is a tar stream. Every entry is a regular file with owner-only permissions and a zero modification time, and names longer than a tar header holds use GNU long-name entries. The manifest is always the first entry, so a reader validates everything that follows against it as the stream arrives.

SectionContentPresent
manifest.rkyvThe manifest recordAlways
users.rkyvThe users recordCluster archives
domains/<domain>/domain.rkyvThe domain recordEvery domain
domains/<domain>/models.nsplThe domain’s models as NSPLEvery domain
domains/<domain>/resources/<resource>/<version>/version.rkyvOne resource version recordEvery resource version
domains/<domain>/resources/<resource>/<version>/archive.tarThe version’s original uploadPublished versions, unless WITHOUT RESOURCES
domains/<domain>/state/wasm_processor/<processor>/<branch>/descriptor.rkyvTyped WASM checkpoint identity, schema fingerprint, generation and revisionSaved WASM branches unless WITHOUT STATE
domains/<domain>/state/wasm_processor/<processor>/<branch>/guest.binRaw guest save bytesWith each WASM descriptor
domains/<domain>/state/kafka_offset/<ingestor>/offsets.rkyvIngestor schema fingerprint and next offsets by topic and partitionPublished Kafka domain offsets unless WITHOUT STATE
domains/<domain>/state/branch_lifecycle/<kind>/<processor>/branches.rkyvTyped branch keys, incarnations and LRU orderPublished branch lifecycle unless WITHOUT STATE
domains/<domain>/state/materialized_relay/<relay>/descriptor.rkyvDomain/relay identity, raw schema fingerprint, revision, fence, branch generation, row and group countsEvery captured materialized relay unless WITHOUT STATE
domains/<domain>/state/materialized_relay/<relay>/groups/<group>/identities.rkyvTyped branch keys and low/high watermarks for the group’s rowsEvery nonempty materialized group
domains/<domain>/state/materialized_relay/<relay>/groups/<group>/columns.arrowExact-schema Arrow IPC columns in identity orderWith each identity group
domains/<domain>/state/deduplicator/<deduplicator>/<branch>/descriptor.rkyvDeduplicator identity, schema fingerprint, typed branch key, revision, key count and group countEvery captured deduplicator branch unless WITHOUT STATE
domains/<domain>/state/deduplicator/<deduplicator>/<branch>/groups/<group>/keys.arrowArrow IPC with one typed column per DEDUPLICATE ON expression and the seen_at timestamp of each keyEvery nonempty deduplicator group
domains/<domain>/state/window_processor/<processor>/<branch>/descriptor.rkyvWindow identity, schema fingerprint, window model digest, typed branch key, revision, branch incarnation, row sequences and watermarks, group count and delayed histogram removalsEvery captured window branch with a branch lifetime unless WITHOUT STATE
domains/<domain>/state/window_processor/<processor>/<branch>/groups/<group>/input.arrowThe retained input rows as Arrow IPC of the window’s input relay schemaEvery nonempty window group
domains/<domain>/state/window_processor/<processor>/<branch>/groups/<group>/arguments.arrowEach retained row’s aggregate argument values, one nullable column per argument in demand order, row-aligned with the input rowsWith each window input group

Materialized group coordinates are zero-based decimal numbers padded to ten digits. A descriptor with zero rows has zero groups. The archive reader accepts arbitrary section ordering, verifies both sections of every group, and rejects missing, duplicate or inconsistent current groups. Deduplicator and window groups use the same coordinates. <branch> is the branch key’s fingerprint in hexadecimal, or unbranched. A deduplicator or window descriptor names at most as many groups as it has keys or rows, and none when it has none; every named group must be present, and a window group must hold both its input rows and its argument columns.

Every .arrow section is one canonical Arrow IPC stream: its schema message, one record batch and the end-of-stream marker, which ends the section. Every message opens with the continuation marker, and every length the stream declares, a message’s metadata, its body and each column buffer, lies within the section. Its schema declares only the field types Nervix carries, without a dictionary encoding, and its record batch declares the field nodes and buffers that schema’s fields take. A restore refuses any other section before it decodes or allocates anything from it.

A resource named . or .. appears in a section path as %2E or %2E%2E.

A record section begins with a twelve-byte header: the magic NVXBKREC, then the record’s kind and its format version, each a little-endian 16-bit integer. The rest is an rkyv payload that a reader validates with bytecheck before it reads any value.

KindTagFormat versionHolds
Manifest12Archive format major version, producer and NSPL releases, cluster id, capture time, scope, whether resource bytes are included, each domain’s revision, Raft log entry and cut kind, and each section’s path, content kind, length and BLAKE3 digest
Users21Every user’s name and password hash as a PHC string
Domain31Pace, default placement policy, lifecycle status, start count, the point the latest start began from, the committed clock mapping, the logical instant the clock had reached, and each declared resource with its next version number
Resource version41The version’s domain, resource and number, its upload state, and for a published version its checksums, file count, sizes, creation time and creating node
WASM guest state descriptor51The saved branch key, schema fingerprint, guest state generation and checkpoint revision
Kafka domain offsets61The ingestor schema fingerprint and ordered topic and partition positions, expressed as the next offset to consume
Branch lifecycle71The owner kind, schema fingerprint, branch keys, last ingestion times and incarnations in LRU order
Materialized relay descriptor81Domain and relay names, schema fingerprint, revision, ownership fence, branch generation, row count and group count
Materialized group identities91Ordered row identities: an unbranched identity or typed branch fields, and low/high ingestion watermarks
Deduplicator keyspace descriptor101Domain and deduplicator names, schema fingerprint, branch fingerprint and typed branch fields, revision, key count and group count
Window state descriptor111Domain and window processor names, schema fingerprint, window model digest, branch fingerprint and typed branch fields, revision, branch incarnation, first and next admission sequence, each retained row’s low/high ingestion watermarks, group count, and each aggregate’s structure: rebuilt from the retained rows, or a linear histogram with its delayed removals as expiry time and bucket

The archive format major version is 1. A reader refuses invalid record magic, an unsupported manifest or required configuration record, a first entry other than the manifest, and any section whose place, length or digest differs from what the manifest declares. Unknown optional state record kinds and unsupported state record versions produce restore warnings and are skipped.

models.nspl holds one CREATE statement per model, separated by blank lines, in an order that creates every model after each model its configuration names. Resource bindings name explicit versions.

An archive holds password hashes, and its models and resources can hold credentials and other secrets. Store it as a secret.

Archive Fidelity Checks

The registered archive properties compare complete current manifests, users, domain lifecycle and clocks, resource catalogs, WASM descriptors and guest saves, Kafka offsets, ordered branch lifecycle entries, materialized generation descriptors and every identity group’s typed branch keys and watermarks. Valid synthetic Arrow groups include nullable, sensitive, nested, byte and floating-point columns. They exercise the production record validators, manifest-first tar writer, streaming reader and restore-content reader, checking every section’s membership, order, length, digest and bytes. Re-exporting verified extracted values produces the same archive bytes. Canonical NSPL documents also reparse to their complete ordered Models across multiple domains. Synthetic payloads cover empty data, tar block boundaries and long section paths; entry permissions remain owner-only.

Separate malformed-input targets check current record headers and fields, inconsistent metadata, missing or reordered sections and truncated streams before verified contents reach installation. Materialized cases also reject inconsistent row/group counts, missing identity or column sections, invalid keys/watermarks and damaged Arrow bytes. Deduplicator and window properties compare complete descriptors, typed branch keys, row sequences and watermarks, delayed histogram removals and every Arrow group’s bytes. The restore conversion property streams every rebuilt keyspace and checks it against the runtime’s own encoding of the same keys byte for byte, and rebuilds windows both from archived sections kept whole and from groups split by a narrow identity record; their malformed cases reject reversed watermarks, impossible sequences and group counts, a zero incarnation, missing key, input or argument groups, misplaced group numbers and descriptors that name more groups than the archive holds. The owning runtime column property separately checks exact-schema encoding, restore conversion and complete native generation equality. Schema agreement with the restored schedule remains the restore planner’s contract, exercised by public restore scenarios. The one-node and three-node WASM/Kafka restore scenario compares every archived field and raw resource/guest byte before the restored domain starts, then proves subsequent START behavior and two-branch isolation. It asserts domain renaming, the stopped lifecycle and the new guest lifetime explicitly; capture metadata belongs to each new backup. The materialized one-node and three-node restore scenario also re-exports the stopped domain before START, comparing every descriptor field, identity and Arrow byte, including generations larger than the bulk budget. Its acknowledged materialized inputs retain an explicit event timestamp across retries. An identical successful replay must preserve the exact revision count and all archived fields, even when a failed earlier attempt reached only part of the relay fan-out. Kafka offsets and branch lifecycle records are written by one streamed writer, whose output a registered property compares byte for byte with rkyv’s encoding of the complete wire shape, and two more properties stream records from stored native lifecycles and from Kafka offset checkpoints in any partition order, with partitions recorded twice, comparing every archived value. The native metadata backup scenario restores lifecycle and Kafka metadata above the bulk budget stopped, interrupts its first re-export partway through a section, which publishes no archive, and re-exports it again with every value identical while each node stays within its bulk budget. It then restores that archive with RESUME and backs up the running domain, whose every lifecycle branch and partition offset is the same. The native metadata restore scenario also restarts the restored owner and replicas, preserves their placements and exact stored values, and checks subsequent Kafka input and isolated output for both tenants. just test-native-metadata-restore-repeat qualifies its three-node example ten consecutive times with concurrency four and no retries. Each attempt retains its source revision, source patch, scenario output and node logs under target/native-metadata-restore; only ten passing examples produce a completion record. The same scenario remains registered in both complete Deloxide selections, which additionally exercise the tracked blocking locks on the restore path. The guest-save scenario uses two 40 MiB branch saves in both one- and three-node clusters. It measures every owner’s bulk reservations during public backup, interrupts a guest section after two chunks and verifies that no archive appears, then retries, restores with --resume, compares the guest bytes exactly and checks the next even output of each branch. A mismatch names each differing save with its lengths, digests, first differing offset and the bytes there, never the whole saves. A resumed restore replays the inputs whose acknowledgements its archive does not hold, so the exact comparison relies on every input of the source cut being acknowledged; a checkpoint of a save this large can outlast the source’s ACK TIMEOUT under load, which the branch’s keepalive of the inputs that checkpoint holds prevents from becoming a replay. See WASM State And Recovery. The deduplicator and window one-node and three-node scenarios re-export a stopped restore with identical descriptors and Arrow groups, stop an installation before its first deduplicator or window checkpoint, which leaves START gated, and release a delayed coordinator’s publication after a resumed restore, which is refused without changing the active keyspaces and windows. The running restore scenario re-exports its deduplicator state after the endpoint accepts a metric probe and before admitting new payments, comparing exact descriptors and Arrow groups to the source archive. That comparison includes each key’s original seen_at; the restored window’s retained rows are checked by their subsequent output for both branches. A focused keyspace test checks that a restored duplicate stays suppressed until its original three-minute deadline and is accepted at that deadline. The public scenario observes eventual delivery after that boundary; its subscription receive time is not an expiry timestamp because a loaded runner may delay the first post or the read beyond the deadline. RESTORE explicitly resets the source ownership fence to zero in the stored checkpoint; revisions and branch generations remain exact. A stopped backup reads this checkpoint’s ordered groups from the same immutable database view as its other state, stages one bounded group at a time and retains the original Arrow bytes. A running or paused backup captures fresh runtime rows under their existing assignment barriers. These representation checks do not establish capture-fence or installation-ordering correctness, which retain their production-owner concurrency and recovery evidence. The registered keyspace and window properties additionally open admitted native checkpoint views and compare every complete emitted Arrow group byte for byte with the resident path. The window oracle decodes the same native checkpoint, since persistence can normalize unused Boolean buffer bits of a live carrier. Restoring the bounded reader’s output compares all significant row values and validity with the live publication. The checks compare revisions, counts, group boundaries, sequences, watermarks and delayed removals. The stored-branch capture outline exercises a stopped domain and a stopped restore with a keyspace and a window above 32 MiB beside 24 small branches, checks exact re-export, and reports sampled per-node bulk and metadata reservations separately from harness allocator statistics. Mappings are included in their admission class; jemalloc’s resident figure excludes mappings outside jemalloc and is not the process’s complete resident set.

Limits

LimitValue
One record section64 MiB
One archive64 GiB, the largest artifact a node stages
Archives and snapshot transfers a node stages at once128 GiB
Download chunk256 KiB
Frames a download queues ahead of its client4
RetentionUntil downloaded, or the retry validity of the execution reference ends
Restore chunk256 KiB
Restore frames the Rust client queues ahead of the transport8
Restore archive retentionUntil the restore finishes, or the retry validity of the execution reference ends
State section staging on an ownerCharged to the same node staging quota until fetched or expired
WASM guest save captureOne selected save at a time: restore_metadata reserves three times its stored checkpoint size for the inline Fjall value and decoded reader; three fixed 64 KiB bulk buffers cover segmented reads, transfer and staging. A save up to the 64 MiB guest limit can exceed the 32 MiB default bulk budget.
Kafka offset or branch lifecycle section captureOne checkpoint at a time per owner: restore_metadata holds twice the stored checkpoint plus 64 KiB for the whole section, beside serializer scratch of one resolver per entry; a 64 KiB bulk buffer streams the record
One Kafka offset or branch lifecycle sectionThe 64 MiB record limit
Native conversion I/O, checkpoint installation and complete generation publication per nodeFixed 2 MiB bulk working-memory reservation; 64 KiB buffers and checkpoint chunks
Retained archive metadata and preparation per node2 GiB default independent admission class; 16 times record bytes plus 64 times NSPL bytes plus 2 MiB
Native conversion serializer scratchFixed-capacity entry resolvers plus the largest entry’s nested resolvers, covered by the retained preparation charge
Materialized identity group / Arrow group1 MiB / 8 MiB
Materialized capture metadata / typed uniqueness metadata / native row views8 MiB each, independently admitted
Deduplicator key group / window input group / window argument group8 MiB each; capture fills a group to at most half that bound, and a single larger row takes a group of its own
Selected stored branch checkpoint captureOne checkpoint per owner at a time. Before opening, restore_metadata admits three times its stored length to cover an inline value’s copies. Four 64 KiB bulk blocks plus 8 KiB cover staging. The complete immutable mapping and its retained indexes remain admitted; selected key parts and decoded columns have separate temporary metadata charges.
Stored window structure conversionrestore_metadata admits a conservative eight times the native length plus 256 KiB for typed removals, descriptor projection and header scratch, independently of the mapping and bounded column buffers. A checkpoint that cannot fit these reservations is refused with its entity named.
One archived deduplicator keyspace or windowNot bounded by the bulk budget. A keyspace’s key parts and values are charged to restore_metadata while it converts, beside the fixed per-key share admitted with the description; a window conversion holds one archived section at a time
Window checkpoint identity record1 MiB of row identities; a larger archived group splits into groups that fit
Restore conversion admission wait30 seconds after the node first refuses one unit for room: a window section, a window while its checkpoint is finished, or a keyspace. The unit is converted again every 50 ms
Restored window rows a branch reopensNo row ceiling of their own; their row views are charged to the relay memory class while the branch reopens the window
Physical checkpoint placement encoding60 KiB, including domain and installation namespace
Unpublished restore checkpoint keys and values per node128 GiB by default; configurable with --restore-staging-max-bytes

A backup larger than the staging quota fails. A backup that fits waits while the leader’s staging area is full, until retained archives are downloaded or expire and snapshot transfers finish.

A restore stages its archive under the same limits, and is refused rather than kept waiting when the archive is larger than one archive may be, or the leader’s staging area cannot hold it now; send it again once retained archives are released and snapshot transfers finish. A restore’s model batch is not bounded by the statement and source-byte limits of a transaction. Each node must admit the bounded installation or publication job within its bulk working-memory budget (32 MiB by default). A state set, and an individual materialized relay, deduplicator keyspace or window, may exceed that budget. An individual job that cannot be admitted fails with the start gate still closed.

A restored deduplicator keyspace or window that exceeds one 2 MiB state-sync response is installed on every assigned owner and replica, but its replicas do not follow the revisions the owner publishes after RESUME, which is the replica limit any keyspace or window of that size has. The owner persists them: a window whose retained rows exceed one 8 MiB snapshot section is sealed in pieces on quota-owned disk and published as a segmented checkpoint, so it survives a restart at any size the node’s snapshot staging quota holds rather than only within the bulk budget.

Failures

A failed backup reports backup failed: and the reason, and its message never includes archive contents. The reasons are:

  • the named domain does not exist, or no domain is selected for BACKUP DOMAIN
  • a domain’s committed models do not form a valid graph, do not render as NSPL, or do not parse back to themselves
  • a domain’s clock mapping cannot be read at the capture time
  • a resource version’s bytes are not installed on the leader, or do not match their catalog entry
  • the archive is larger than one archive may be, or a record does not encode
  • a captured branch lifecycle or Kafka offset checkpoint does not decode, its record exceeds the 64 MiB record limit, or restore_metadata cannot admit its conversion now, naming the entity
  • a selected WASM guest save cannot be read or its restore_metadata conversion cannot be admitted, or its staged write is interrupted; the failure names the processor without guest bytes
  • a quiesced domain cannot pause, drain, capture its owners or resume within its timeout

A capture that fails or is interrupted drops every section its owners staged for it. No archive is assembled from a partial capture, and the backup can be sent again. The same refusal applies to a selected stored keyspace or window whose immutable view, native validation, typed metadata or bounded projection cannot be admitted. Cancellation is checked between stored chunks, identities and keys; refused or interrupted staging drops its private files and returns their quota. No partial branch checkpoint becomes an archive section.

A refused restore reports restore refused: and the reason, and changed nothing. The reasons are:

  • the stream did not carry the archive its start declared: its chunks add up to another size, or its bytes have another digest
  • the leader’s staging area cannot hold the archive now, or the archive is larger than one archive may be
  • the archive does not verify: the section and the check that failed are named
  • a restored domain’s models.nspl does not parse, naming the domain and the line, or holds a statement that creates no model, naming the domain, the statement’s number and its line
  • RESTORE CLUSTER was given a domain archive, or RESTORE DOMAIN names a domain the archive does not hold
  • a restored domain name exists
  • an archived user exists and the user policy is FAIL
  • the archive was taken WITHOUT RESOURCES and holds completed resource versions, or holds a version’s bytes that do not match its root checksum
  • a model binds a resource version the restore does not import as completed
  • a domain’s models do not form a valid configuration, as the transaction planner finds
  • an archived branch key the restore would install is not a key of the branching its restored entity declares, naming the section, the lifecycle entry, descriptor or record identity, the entity, the branch and the field; see Archived Branch Keys
  • an archived materialized relay, deduplicator keyspace or window does not convert under the shape its restored model gives it, or its conversion cannot be admitted, naming the entity; a dry run runs the same conversion. A deduplicator or window conversion the node refuses only for room is converted again first, and is refused only once that refusal has lasted its 30-second wait
  • the archive’s metadata, including the fixed share of every archived deduplicator key, exceeds the node’s available restore preparation budget

A restore that failed at a step reports restore failed at step '<step>':, the reason, and that the steps before it stay applied, together with the report of every step. The reasons are a consensus refusal of the step, a resource version that could not be installed or completed on every live node, a model batch the leader refused, such as lookup data that does not load at its path or TLS material that does not load, and state that does not convert or install. Installation converts each keyspace and window as planning did, with the same wait for room, so a momentarily busy node does not fail the step; a refusal for room that outlasts the wait does. A restore whose archive no client sent to a new leader before the retry validity of its execution reference ended reports restore stopped after the steps it recorded. No message includes password hashes or resource bytes.

Rust Client Library

The workspace includes nervix-client-core, a native Rust client library built on the same session gRPC API used by nervix-cli. It is the reference implementation of the Client Implementation Manual, and Client Session Protocol explains the protocol behavior it relies on.

For a complete graph, data-handle lifetimes, credit and failure decisions, read Client Ingestors And Emitters.

Capabilities:

  • Client::connect(...) and Client::connect_with_options(...)
  • Client::execute(...), and Client::prepare_execution(...) with Client::execute_prepared(...)
  • Client::transaction_status(), Client::attach_transaction(...) and Client::inspect_transaction(...)
  • Client::list_domains(), Client::domain() and Client::set_domain(...)
  • Client::subscribe(...), Client::unsubscribe(...) and Client::next_subscription()
  • Client::attach_domain_clock(...), Client::detach_domain_clock(...), Client::domain_clock(...) and Client::next_domain_clock_event()
  • Client::open_ingestor(...), with Producer::send(...), Producer::submit(...), Producer::rejoin(...), Producer::pending_submissions(), Producer::release(...) and Producer::close()
  • Client::subscribe_emitter(...), with EmitterConsumer::next_batch(), EmitterDelivery::ack(), EmitterDelivery::retry(), EmitterDelivery::reject(...) and EmitterConsumer::close()
  • Client::upload_resource_from_directory(...)
  • Client::download_backup(...)
  • Client::restore(...) and Client::restore_with_reference(...)
  • Client::next_server_event(), Client::next_domain_list() and Client::leadership()
  • Client::suggest(...) behind the autocomplete feature
  • Client::lookup_choices(...)

Suggestion pages report Ready, MissingContext, StaleContext, or LookupFailed. Every candidate carries a UTF-8 byte text edit against the full input and a continuation requests the next bounded page. The shared C binding exposes the same result through nx_session_suggest, nx_suggestions_at, and nx_suggestions_continuation; its header is crates/client-ffi/include/nervix_client.h.

Structured choice pages carry typed enum variants or domain, resource, and model references beside separate presentation metadata. The request includes typed dependencies, search, and a revision-fenced page cursor. Client::lookup_choices(...) retries this read-only request across a session reconnect and returns its Ready, MissingContext, StaleContext, or LookupFailed status without interpreting labels.

Every outcome carries a typed CommandDisposition: Completed, Failed, NotLeader with the leader’s endpoints when discovery knows them, TransactionDetached, TransactionTakenOver, OutcomeUnknown with its cause, ExecutionReferenceConflict, ExecutionReferenceExpired, or PreviewStale. CommandOutcome::succeeded() is true for Completed.

Each execute call creates one stable execution reference and retains it through leader redirects, transaction reattachment, and transport reconnects. A successful outcome means the command’s administrative effect is usable on the current live-node set. If the caller cancels its future, the cluster still owns any already-admitted effect; the client sends no cancellation and discards the reply when it arrives. To submit the same logical request again, prepare it once with prepare_execution, which fixes its execution reference, domain, and upload identity, and pass the same ExecutionHandle to execute_prepared after an uncertain, cancelled, or timed-out attempt.

Every call is bounded by ConnectOptions: connect_timeout for each connection attempt (10 seconds by default), request_timeout for each request (120 seconds), and retry_timeout for the call with all of its retries and redirects (120 seconds). BACKUP uses backup_wait_timeout instead for its command wait and reply (ten minutes), shared across every domain cut, redirect and reconnect. All four durations must be between one millisecond and 24 hours. A call whose deadline passes before a command’s outcome is known fails with ClientError::UncertainCommand, which names the execution reference. An https:// server is verified only against ca_certificate_pem: the client loads no system roots, so a TLS connection needs the certificate authority that signed the server’s certificate.

Every call returns its failure as an error_stack::Report<ClientError>. The report’s current context, report.current_context(), is the failure a caller acts on, and the frames beneath it keep its cause: a call’s transport status, the wire codec’s report beneath EncodeRequest, InvalidUploadReply or InvalidRestoreReply, the name’s report beneath InvalidResourceName, a local archive’s I/O error beneath BuildUploadArchive or ReadRestoreArchive, and the BackupDownloadError beneath BackupDownload. A failure that may hide an admitted command or an installed upload is UncertainCommand or UncertainUpload above the failure that left the outcome unknown, so the execution reference or upload identity is read from the current context and the cause from the frame beneath it. The client decides retries, session recovery and uncertainty from those typed contexts, and a caller needs no text to decide either; {report:#} renders the whole chain for display.

Native connections resolve a hostname through Hickory. By default the client loads the host’s resolver configuration and hosts file once at connection setup. ConnectOptions::dns can name a different DnsConfiguration or an already loaded DnsResolver shared with the caller’s runtime. The same resolver is reused for initial connections, seeds, redirects, and reconnects. Each new connection uses its current cached DNS answer, tries the addresses of the first answer’s family in order, starts the other family’s addresses when 300 milliseconds pass without a connection, and keeps the URL’s hostname for HTTP/2 authority and TLS verification. The connection timeout, ten seconds by default, includes lookup and all connection work; a failed DNS configuration returns LoadDnsConfiguration, and a failed lookup stays in the connection error’s cause chain. DNS does not change execution identities, subscriptions, transactions, or cancellation. Name Resolution describes the resolver.

Minimal example:

use nervix_client_core::{
    Client, ConnectOptions, DomainName, SubscriptionEvent, SubscriptionRequest,
};

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let options = ConnectOptions::default().with_basic_auth("default", "nervix");
    let domain = DomainName::parse("default")?;
    let client =
        Client::connect_with_options("http://127.0.0.1:47391", Some(domain), options).await?;

    let result = client.execute("SHOW CLUSTER STATUS;").await?;
    println!("{}", result.message);

    let request = SubscriptionRequest::new("sampled_orders", "orders")
        .dropping()
        .with_batch_sample_rate("0.1")
        .with_where_clause(
            nervix_nspl::parse_expression("input.tenant = \"acme\"")?
        );
    client.subscribe(&request).await?;

    if let SubscriptionEvent::Rows(rows) = client.next_subscription().await? {
        for line in rows.display_lines()? {
            println!("{line}");
        }
    }
    client.unsubscribe("sampled_orders").await?;
    Ok(())
}

A subscription delivers typed rows against the schema its subscribe reply announced. SubscriptionRowsEvent::display_lines() renders each row as one JSON object, prefixed with its concrete branch key as key=<object> payload=<object> on a branched relay; a sensitive field reads "<masked>". The other subscription events report rows a dropping subscription could not deliver, rows it skipped, and the end of the subscription with its reason: RelayChanged when the relay was redefined, so the announced schema no longer describes its rows, or RelayRemoved when the relay or its domain no longer exists. The end is the last event of that subscription, and the client never opens it again on its own, because a new session would not change why it ended. Client::subscription_lifecycle(&name) reads it as Ended from the moment the end arrives until you subscribe again under the same name, which opens a new generation that announces the relay’s current schema, or unsubscribe, which completes without a request and releases the name. An end whose session ended before you read it is still reported, once, after the events that session left unread are dropped; a subscription whose events overflowed reports the overflow as its last event instead. See Sessions.

A subscription the server acknowledged, and did not end, outlives its session. When the session ends, next_subscription() reports Interrupted, the gap before the subscription opens again, and the client opens it again as a new generation on its next session. When that session refuses it, for example because its relay no longer exists, next_subscription() reports RestorationFailed with the server’s message and the wait before the next attempt; the client keeps trying on that session, after a wait that starts at one second and doubles up to thirty seconds, until the subscription opens, the session ends, or the subscription is deleted. Client::subscription_lifecycle(&name) reads the state a subscription is in. subscribe and unsubscribe reopen a closed session like every other call. Deleting a subscription that no open session holds, because its session ended or the current session refused to open it again, completes without a request and releases the name.

Backing Up

execute runs BACKUP CLUSTER TO '<file>'; and BACKUP DOMAIN [<name>] TO '<file>'; as one command and then downloads the archive the backup assembled into the named file. The download is not bounded by the client’s command wait; each frame must arrive within the request timeout. The client writes a private file beside the destination and moves it over the destination only once the archive’s size and BLAKE3 digest match the backup’s summary, so the file never holds a partial archive, and on Unix only its owner may read it. A download that fails in transport starts again from the first byte while the server retains the archive.

The outcome’s backup field carries the summary: the archive’s size and digest, the capture time, the instant the server stops retaining it, whether resource bytes are included, the number of users, and each domain’s revision, section count and bytes. A download that still fails returns ClientError::BackupDownload with the backup’s execution reference, above the typed BackupDownloadError that says why. Running the same ExecutionHandle again returns the recorded outcome and downloads the archive again, and Client::download_backup(reference, summary, destination) downloads a summary’s archive directly. A download that receives the whole archive releases it on the server, and a later download of it is refused.

For recovery from another process, Client::prepare_backup_with_reference(backup, reference) creates a handle for execute_prepared with that durable reference. Select the original domain and retain the scope, resources and capture options; the destination file may change. The server enforces the semantic binding and retry validity. Each explicit wait attempt is bounded again by backup_wait_timeout, and does not renew archive retention. TIMEOUT in NSPL remains a separate budget for each running domain’s cut, so a cluster capture may validly exceed that duration.

execute refuses DESCRIBE BACKUP, which nervix-cli serves from a local file without a server; the nervix-backup crate’s describe_archive reads and verifies an archive for other Rust programs. The C binding runs BACKUP through nx_session_execute the same way and reports the archive’s size and digest through nx_outcome_backup. See Backup And Restore.

Restoring

execute runs RESTORE CLUSTER FROM '<file>' ...; and RESTORE DOMAIN <name> ... FROM '<file>' ...; by streaming the named local archive to the leader on the session service’s restore stream, beside the session; it refuses both inside a transaction. Client::restore(restore, on_progress) runs a parsed Restore the same way and reports each number of archive bytes it hands to the transport to on_progress, and Client::restore_with_reference(restore, reference, on_progress) runs it under an execution reference the caller chose. The client reads the file once to declare its size and BLAKE3 digest, and sends it in 256 KiB chunks.

A restore is not bounded by the client’s retry deadline: each frame must reach the transport within the request timeout, and so must the reply once the last frame was sent. After a redirect, a lost connection, or an answer that the restore still applies, the client streams the archive again under the same execution reference, which joins the restore or returns its recorded outcome, so a leader change resumes a restore instead of repeating it. An error that may hide an admitted restore is ClientError::UncertainCommand with the restore’s execution reference, and running the restore again under that reference recovers its outcome.

The outcome’s restore field carries the typed RestoreReport: the mode, the archive’s size and digest, the capture time, what the users step did, each restored domain, and each step’s outcome. A stream the leader refused before the restore ran is a failed outcome whose message names the typed failure. The C binding runs RESTORE through nx_session_execute and reports the report’s counts, whether it was a dry run, and whether a step failed through nx_outcome_restore.

Following A Domain Clock

execute routes ATTACH DOMAIN CLOCK; and DETACH DOMAIN CLOCK; the way it routes USE: it sends them as typed attach and detach requests for the selected domain and never as commands. Without a selected domain they fail with ClientError::NoActiveDomain, and while a transaction is active they are refused like every other client-local statement. Client::attach_domain_clock(domain) and Client::detach_domain_clock(domain) send the same requests for a domain named explicitly and return the typed DomainClockAttachOutcome and DomainClockDetachOutcome, whose dispositions tell an attachment from each refusal.

Once attached, Client::domain_clock(&domain) returns an AttachedDomainClock holding the newest clock and accepted tick the session received. It is a copy taken when it is called, which later frames do not change, so a caller reads it again for a newer clock. latest_tick() gives the tick id, logical boundary, authority UTC observation, and serving node’s logical reading; frontier() gives that boundary. It answers with the same vocabulary arithmetic the cluster runs, DomainClockState::logical_time_at and DomainAdmissionWindow::reached, for a UTC instant the caller supplies:

#![allow(unused)]
fn main() {
use nervix_client_core::{DomainClockEvent, DomainName, Timestamp};

let domain = DomainName::parse("simulation")?;
client.set_domain(Some(domain.clone())).await;
client.execute("ATTACH DOMAIN CLOCK;").await?;

let clock = client.domain_clock(&domain).expect("the session follows this clock");
let now = Timestamp::now();
let logical_now = clock.logical_time_at(now)?;
if let Some(frontier) = clock.frontier() {
    println!("latest accepted tick boundary: {frontier}");
}
if let Some(window) = clock.admission_window(now)? {
    // The newest center the ingestor has reached, admitted within SKEW on either side.
    let occurred_at = window.latest_center();
    println!("{logical_now}: send events stamped {occurred_at}");
}

match client.next_domain_clock_event().await? {
    DomainClockEvent::Observed(observed) => println!("{}", observed.clock),
    DomainClockEvent::Ticked(ticked) => println!("tick {}", ticked.tick.tick_id),
    DomainClockEvent::Ended(ended) => println!("ended because {}", ended.reason),
    DomainClockEvent::Interrupted(gap) => {
        println!("{}: session lost; the clock is attached again on the next session", gap.domain)
    }
    DomainClockEvent::RestorationFailed(failure) => {
        println!("{}: {}; retrying in {:?}", failure.domain, failure.message, failure.retry_after)
    }
}
}

logical_time_at reads an unpaced clock as UTC itself and rounds a paced projection down; wall_duration_until(now, target) returns the physical wait until a logical instant, rounded up so waiting it never arrives early by the caller’s own UTC, and zero once the instant is reached; admission_window returns None for an unpaced clock, whose ingestors admit every timestamp. A stopped or uninstalled clock answers every question with a typed DomainClockReadError, and an answer outside the timestamp or duration range is DomainClockReadError::Arithmetic. The answers hold for the caller’s UTC: a host whose UTC is offset from the cluster’s reads logical instants and admission centers shifted by that offset multiplied by the rate, and physical waits that end early or late by the offset itself.

Client::next_domain_clock_event() reports what the session receives after each attach reply: Observed with a changed clock, Ticked with the newest accepted progress, Ended when the server ended the attachment because the domain no longer exists on the serving node, and Interrupted when the session holding an attachment ended. An attach reply produces no event: domain_clock reads the clock it carried, and the first event after attaching to a running paced clock is normally its newest tick. Events are coalesced per domain, so a caller that reads none never holds the session’s reader up. The newest state replaces an unread state and the newest tick an unread tick, and an unread state arrives before a tick of its generation. An interruption discards the unread observations before it, an end discards everything unread about its domain, the newest restoration failure replaces an unread one, and a detach discards every unread event of the domain. Domains are reported in the order of their first unread event. While a clock is interrupted, domain_clock keeps the clock the lost session reported last, without a tick, and another lost session before the clock is restored reports no second interruption. After a reconnect, the client attaches every followed clock again on the new session before any other request, and the clock that attachment reports follows the interruption as an Observed event; changes in between are not reported. When the new session answers that attach while the old session still holds the attachment, as after a redirect to the leader, the attachment moves without an interruption: a clock that stays paced in the same generation keeps its newest tick, so domain_clock is never older than a tick event already taken, and a Ticked event of the new session follows only for a newer tick. A new session that answers the attach with the domain not found ends the attachment with Ended, as the server would. A node that is still starting answers that attach only once it has installed the cluster’s committed domains, so a restart never ends an attachment. When the new session refuses that attach for any other reason, the event is RestorationFailed with the server’s message and the wait before the client sends the attach again on the same session; the wait starts at one second on each new session and doubles up to thirty seconds. An attach the session leaves unanswered past request_timeout ends that session, and the next one attaches the clock again. Waiting for the next event reopens a closed session when a followed clock waits for it, including a call already waiting when the unanswered attach ends its session; a failure to open one is returned while the clock keeps waiting, and the next call tries again. A detach, or an end, stops following the domain, and domain_clock returns None for it afterwards. A detach while a clock waits to be restored reaches the current session: a closed session is opened again first, which attaches the clock before the detach releases it, and an open session that has not restored the clock answers NotAttached, after which the client no longer follows it either. The client never attaches the clock again behind the detach: an attach it already decided to send to restore the clock is sent first, and the detach releases what it attached, while a restoration that would attach the clock during the detach waits for it to end and attaches the clock only if the client still follows it.

Through The Shared C Binding

The shared C binding reads the same events and attached clocks for C, C++, Python, JVM and Ruby hosts; its header is crates/client-ffi/include/nervix_client.h. ATTACH DOMAIN CLOCK; and DETACH DOMAIN CLOCK; run through nx_session_prepare and nx_session_execute like any statement, for the session’s selected domain, and fail with NX_ERROR_INVALID_ARGUMENT when none is selected. nx_session_next_clock_event waits for the next event, bounded by the same nx_cancel tokens and deadlines as every blocking call, and hands out an nx_clock_event reference. While a followed clock waits to be restored it opens a session; when none can open, it fails with NX_ERROR_CLOSED if no server is known and otherwise with the connection, transport or deadline failure of that attempt, and the next call tries again. The events are the Rust client’s, coalesced the same way:

  • nx_clock_event_kind_of tells an NX_CLOCK_EVENT_STATE, NX_CLOCK_EVENT_TICK, NX_CLOCK_EVENT_ENDED, NX_CLOCK_EVENT_INTERRUPTED or NX_CLOCK_EVENT_RESTORATION_FAILED event apart, and nx_clock_event_domain borrows the name of the domain it concerns. A restoration failure reports that the session refused to attach an interrupted clock again, which it tries again after the growing wait the Rust client reports. The event carries only its kind and domain: the binding exposes neither the server’s message nor the wait.
  • nx_clock_event_generation reads the START generation of a state or tick event, nx_clock_event_state the installation state of a state event, nx_clock_event_paced the period, skew, logical origin, UTC anchor and time rate of a paced one, nx_clock_event_tick the id, logical boundary, authority UTC observation and serving node’s logical reading of a tick, and nx_clock_event_end_reason why the server ended an attachment. Each fails with NX_ERROR_TYPE for an event whose kind does not carry what it reads.
  • Instants are signed nanoseconds since the Unix epoch, the period and skew unsigned nanoseconds, and the time rate a double. Every field is written to an out-parameter the host provides, so reading an event allocates nothing on the host’s side.
  • nx_clock_event_retain and nx_clock_event_release count references the way nx_event_retain and nx_event_release do, and a reference may be released on any thread.

nx_session_domain_clock reads domain_clock for a domain the session follows as an nx_domain_clock reference, and writes NULL for a domain whose clock the session does not follow. It never blocks. Right after ATTACH DOMAIN CLOCK; completes it is the clock the attach reply carried, so a host that attaches to a running paced clock reads its START generation and committed mapping before it uses the first tick. Every later observation and accepted tick replaces it in the order the session received them, so a read is never older than an event the host has already taken, including after a redirect moves the attachment to another session without an interruption. Once the session holding the attachment ends, which NX_CLOCK_EVENT_INTERRUPTED reports, it keeps the clock that session reported last, without a tick, until the restored attachment reports the clock again. A read never changes once taken, stays valid after the session is freed, and is counted with nx_domain_clock_retain and nx_domain_clock_release like an event:

  • nx_domain_clock_domain, nx_domain_clock_generation and nx_domain_clock_state read the domain, the generation and the installation state. nx_domain_clock_paced reads the period, skew, logical origin, UTC anchor and time rate of a paced clock and fails with NX_ERROR_TYPE for any other state, and nx_domain_clock_tick says whether the session holds an accepted tick of the clock’s generation and reads it when it does.
  • nx_domain_clock_logical_time_at, nx_domain_clock_wall_duration_until, nx_domain_clock_admission_window and nx_domain_clock_admits answer with the arithmetic of AttachedDomainClock: the logical time at a UTC instant, rounded down; the physical wait until a logical target, rounded up; the oldest and newest tick centers a TIMESTAMP AT ingestor admits events around; and whether it admits a given event time. An unpaced clock reads UTC and admits every timestamp. A stopped or uninstalled clock fails with NX_ERROR_TYPE, and a result outside the timestamp range with NX_ERROR_INVALID_ARGUMENT.

An attach the server refuses completes as failed with the server’s reason, and nx_session_domain_clock still reads the earlier attachment’s clock when the refusal is that the session already follows it. An attach whose session was lost is sent again on the next session the client opens. An attach cancelled or expired after its request was sent can still attach the session: executing the same prepared execution again is answered after the earlier attempt, and nx_session_domain_clock then tells whether the session follows the clock.

Events Across Reconnects

next_subscription(), next_domain_clock_event(), and next_server_event() read streams that belong to the client rather than to one session, so a reader keeps calling them across reconnects:

  • When a session ends, next_subscription() reports Interrupted for every subscription that session held and the server had not ended, reopens a session, and opens each of them again as a new generation. With nothing to restore it waits for the next session the client opens, for example for its next command, and delivers the events of subscriptions opened there.
  • next_domain_clock_event() reopens a session while a followed clock waits to be attached again, as described above.
  • next_server_event() never opens a session itself. Notices end with the session that delivered them, including the ones not read yet, and the stream continues with the notices of the next session.

A failed read leaves its stream open. ClientError::EventOverflow from next_server_event() means notices arrived faster than they were read: the client dropped the ones it held, and the next read returns the notices that arrived after that gap. An error from reopening a session, such as ClientError::RetryDeadline or ClientError::ConnectServer, leaves subscriptions and clocks waiting to be restored, and the next read tries again. Only ClientError::SessionClosed ends a stream: the session ended and the client knows no server to open another on, as happens to a client built with Client::from_channel that has not been redirected.

Producers

Client::open_ingestor(domain, ingestor, expected_fields, limits) attaches a producer to a client ingestor of the domain named explicitly, on the session’s current exchange. expected_fields must be exactly the ingestor’s input schema, including each field’s optionality and sensitivity, and limits asks for the batches and bytes the producer may have outstanding. A refusal is ClientError::ProducerRefused with its typed ClientProducerRefusal, and leaves nothing attached. The returned Producer reports what the open established through description(): the schema, the START generation, the endpoint contract and attachment, the ingestor’s policy, and the granted credit.

#![allow(unused)]
fn main() {
use std::num::{NonZeroU32, NonZeroU64};

use arrow_array::{Int64Array, RecordBatch, StringArray};
use nervix_client_core::{ClientProducerLimits, DomainName, IngestorName, ProducerOutcome};

let producer = client
    .open_ingestor(
        DomainName::parse("shop")?,
        IngestorName::parse("orders_in")?,
        expected_fields,
        ClientProducerLimits {
            batches: NonZeroU32::try_from(16)?,
            bytes: NonZeroU64::try_from(8 * 1024 * 1024)?,
        },
    )
    .await?;
let rows = RecordBatch::try_new(
    std::sync::Arc::new(producer.arrow_schema()),
    vec![
        std::sync::Arc::new(StringArray::from(vec!["o-1"])),
        std::sync::Arc::new(Int64Array::from(vec![1200])),
    ],
)?;
match producer.send(producer.batch(&rows)?).await? {
    ProducerOutcome::Completed => println!("delivered"),
    ProducerOutcome::NotAdmitted { refusal, .. } => println!("not admitted: {refusal:?}"),
    ProducerOutcome::ProcessingFailed { failure, .. } => println!("failed: {failure:?}"),
    ProducerOutcome::OutcomeUnknown { cause, .. } => println!("replay decision: {cause:?}"),
}
producer.close().await?;
}

Producer::batch and ProducerBatch::from_record_batch, behind the arrow feature, write a record batch as the canonical stream the server accepts after checking it against the producer’s schema and row limit; ProducerBatch::from_arrow_ipc takes a stream the application already wrote.

send waits for credit, submits the batch, and returns its terminal outcome. submit returns once the producer holds the batch, and rejoin waits for that submission’s outcome. A submission keeps its share of the credit until the application observes its outcome, so a producer whose application stops reading outcomes stops being granted room for new batches. Cancelling a send or rejoin future never loses the batch or its outcome: pending_submissions() lists every submission the producer holds with its outcome once it has one, rejoin resumes the wait without sending the batch again, and release lets go of a submission and its credit.

The client sends a batch again only when the server refused it temporarily, as Suspended or Busy, after the ingestor’s declared backoff, and it waits while the producer’s admission is suspended. It never replays a batch that failed or whose outcome is unknown. A batch that was sent when the session ended is reported as OutcomeUnknown with SubmissionUncertainty::SessionLost, and one still waiting to be sent as not admitted. Producer::admission() tells whether batches are admitted now and Producer::end() how the producer ended: Closed, Ended with the server’s reason, or ReopenRequired. The same handle restores an attachment after reconnect only when the domain generation, endpoint contract, schema, policy and credit grant still match. connection() reports active, interrupted, restoring, reopen required or closed. A changed or removed endpoint requires the application to open another producer. close takes the producer by reference and releases its attachment from a task of its own, so a caller that stops waiting still releases it; a second close, or a close while the producer waits to be restored, returns at once. Dropping a producer closes it without waiting.

Emitter Consumers

Client::subscribe_emitter(domain, emitter, expected_fields, limits) opens a competing consumer of a native TO CLIENT emitter on the current session. The field list is exact, including order, optionality and sensitivity. The open reserves its requested credit before it succeeds. The returned EmitterConsumer::description() reports the schema, emitter ACK window, timeout and retry pacing, maximum batch rows and bytes, and granted credit. A refusal is ClientError::ConsumerRefused with a typed reason.

#![allow(unused)]
fn main() {
use std::num::{NonZeroU32, NonZeroU64};
use nervix_client_core::{ClientConsumerLimits, DomainName, EmitterName, EmitterSettlement};

let consumer = client.subscribe_emitter(
    DomainName::parse("shop")?,
    EmitterName::parse("app_output")?,
    expected_output_fields,
    ClientConsumerLimits {
        batches: NonZeroU32::try_from(8)?,
        bytes: NonZeroU64::try_from(8 * 1024 * 1024)?,
    },
).await?;
while let Some(delivery) = consumer.next_batch().await? {
    let batch = delivery.record_batch()?; // `arrow` feature
    process(batch).await?;
    assert_eq!(delivery.ack().await?, EmitterSettlement::Confirmed);
}
consumer.close().await?;
}

record_batch accepts a delivery only when its Arrow IPC stream declares exactly the Arrow schema of the fields the emitter declares and holds one record batch of the delivery’s member count; any other stream is a ClientError::UnexpectedReply. EmitterDelivery::decode_record_batch(fields, batch, members) applies the same check to bytes an application kept: it takes the fields of consumer.description(), a delivery’s batch bytes and its members, and needs no live delivery.

delivery.retry().await keeps the same bytes and asks the server to reassign after physical backoff. delivery.reject(reason).await sends one bounded, non-sensitive reason through the emitter’s message error policy for all members. Each reassignment carries a new reference; a stale reference reports StaleReference. Reading, receiving, or decoding the batch is never an ACK. A server attachment ends with its session or endpoint. Applications using the sample loop should handle ClientError::ConsumerInterrupted explicitly: the first read after a session loss or endpoint relocation returns it, and later reads use a fresh attachment if its generation and contract still match. connection() reports its current state. A changed or removed endpoint returns ClientError::ConsumerReopenRequired and needs an explicit new open. An old delivery cannot ACK through the new attachment; its reference expires, and a settlement whose answer was lost returns ClientError::SettlementUnknown. The Rust client’s exchange reader continues to route producer outcomes and command or clock replies while application processing awaits ACK. A next_batch future dropped before its reply leaves the read with the consumer, and the next next_batch of the same attachment receives that reply without asking for another batch, so abandoning a wait never strands an attempt until its ACK timeout. close releases the attachment the way a producer’s does.

The Paced Simulation Drivers are complete programs that combine a followed domain clock, a producer and competing consumers on one session, in Rust and through the shared C binding: they start their consumers before they await an attached outcome, pace their TIMESTAMP AT values by the attached clock’s projections, and make their effects idempotent across redeliveries and deliberate replays. They explicitly reopen changed endpoint contracts within a START generation, validate the same expected fields and credit requests again, and keep unknown submissions with their original handle rather than resending them.

Endpoints Through The Shared C Binding

The shared C binding exposes the same producers and consumers to C, C++, Python, JVM and Ruby hosts, through crates/client-ffi/include/nervix_client.h. Every call forwards to the Rust handles above, so credit, retries of temporary refusals, restoration, outcomes and settlements behave exactly as described; the binding keeps no acknowledgement or reconnect state of its own.

  • Opening. A host describes the fields it expects with nx_fields: nx_fields_add starts a field with its name, outermost type, nullability and sensitivity, and nx_fields_element adds the element of each list around it, so ARRAY<I16, 2, 2> is a FIXED_LIST of 2, a FIXED_LIST of 2, then I16. nx_session_open_ingestor and nx_session_subscribe_emitter open a producer or a consumer on a domain named explicitly, with the credit the host asks for, and copy the fields. A refusal is NX_ERROR_REJECTED, and nx_error_open_refusal reads its nx_open_refusal, such as NX_OPEN_SCHEMA_MISMATCH.
  • Description. nx_producer_schema, nx_consumer_schema, *_generation, *_contract, *_grant and *_policy read what the open established: the schema with every field’s nullability, sensitivity and type levels, the START generation, the contract digest, the granted credit and batch limits, and the acknowledgement window, ACK timeout and backoff. nx_producer_state and nx_consumer_state read the connection, nx_producer_admission the admission, and *_reopen_reason why a handle needs a new open.
  • Submitting. A host builds a batch from its own buffers with nx_batch_builder_new for the producer’s schema: one nx_batch_builder_states call per nullable column, one nx_batch_builder_offsets call per variable-length list level, and one nx_batch_builder_fixed or nx_batch_builder_varlen call for the innermost values. Every call copies its buffers before it returns, and nothing is converted. nx_producer_submit waits for credit, checks the batch against the producer’s schema and row limit, and returns the submission’s identity; nx_producer_rejoin waits for its outcome and takes it, and nx_submission_outcome_* read its class, typed cause and message. nx_producer_pending lists what the producer holds and nx_producer_release lets a submission go. A host that writes canonical Arrow IPC itself submits it with nx_producer_submit_ipc, which copies it and leaves its checking to the server.
  • Consuming. nx_consumer_next returns a reference-counted nx_delivery: its identity, reference, source relay, branch fingerprint, member count and execution time, its stream, borrowed with nx_delivery_ipc, and its batch, which nx_delivery_batch decodes the first time it is asked for and then shares. A batch is read one level of a column at a time: nx_batch_states for the rows, nx_batch_offsets for each variable-length list level, and nx_batch_fixed or nx_batch_varlen for the values, each in one call. nx_delivery_ack, nx_delivery_retry and nx_delivery_reject settle the attempt and return the server’s nx_settlement. Releasing a delivery settles nothing.
  • Interruption. After a session ends, the next nx_consumer_next returns NX_ERROR_INTERRUPTED once, a delivery read before the gap fails to settle with NX_ERROR_REJECTED, a submission sent before it is NX_UNCERTAINTY_SESSION_LOST, and a settlement whose answer the session lost is NX_ERROR_UNCERTAIN. The next calls restore the handles on a new session while their contract holds. A handle closed while it waits to be restored returns at once and is never restored.
  • Ownership. Producers, consumers, deliveries and batches may be used from several threads at once, and deliveries and batches are released on any thread; a field list and a builder are used from one thread at a time. A producer, a consumer and a delivery keep their session running until they are released, and a borrowed stream or value stays valid until the reference it was read from is released.

Transaction Handles And Attach

CommandOutcome::transaction describes the session’s transaction binding. Its TransactionStatus holds the transaction id, the domain the transaction is bound to, its TransactionLifecycle (Open, Committing, Committed, Failed with the failing operation and its error, Reverted, or Expired), and its accepted, applied and pending operation counts. Treat the id as the durable transaction handle:

#![allow(unused)]
fn main() {
client.execute("CREATE DOMAIN production;").await?;
client.set_domain(Some(DomainName::parse("production")?)).await;

let begun = client.execute("BEGIN;").await?;
let transaction_id = begun
    .transaction
    .as_ref()
    .expect("BEGIN returns transaction status")
    .transaction_id()
    .to_string();

client.execute("CREATE SCHEMA notification (user_id I64);").await?;

// A different Client authenticated as the same user can take over the transaction.
let attached = recovered_client.attach_transaction(transaction_id).await?;
if !attached.succeeded() {
    eprintln!("attach outcome: {}", attached.message);
}
}

BEGIN requires an already-existing selected domain and binds the transaction to it. Attaching adopts that domain, so recovered_client follows the transaction’s domain without an explicit set_domain.

While a transaction is open, execute first preflights a queueable statement against the replicated prefix. An unsuccessful preflight leaves the transaction Open with the same pending count, so callers may correct the command and continue using the same handle. A queued model mutation’s message reports the effective quiesce level of its consecutive atomic model run at the current prefix. A later mutation in that run may escalate the level or cancel the net change. An exact append retry returns the originally admitted result without refreshing preflight or the transaction’s activity timestamp.

The client automatically attaches its active transaction after a leader redirect or transport reconnect before retrying a command. It retains each append’s execution reference and expected position and only treats the exact recorded append as completion. A pending COMMIT waits for the outcome recorded under its own reference, repeating the commit after an interruption; it completes from that exact terminal outcome rather than from a coincidental progress count, and the call fails as uncertain if retry_timeout passes first. A recovered committed operation returns a successful CommandOutcome containing the retained aggregate quiesce output. A direct COMMIT outcome has no per-statement statements; its message contains only the maximum quiesce level actually executed. If a peer briefly has no leader address while an election converges, the client waits and retries within retry_timeout instead of returning the transient NotLeader outcome. An OutcomeUnknown outcome means the command may have been admitted and its result is not known yet, for example because leadership moved while it applied; the client retries it with the same execution reference within the same deadline, which recovers the recorded outcome.

An explicit attach by another session takes over the binding. Attach to a retained tombstone returns an unsuccessful command outcome whose transaction status names Committed, Failed, Reverted, or Expired; when commit produced aggregate output, diagnostics carries that one aggregate rather than the individual statement results. Attach to an id removed after retention returns an unknown-id outcome. transaction_status() keeps the latest structured status so an interactive caller can render Open and Committing differently.

Inspecting A Transaction

Transaction Quiescence And Impact Inspection defines the report’s operation contributions, effective step scopes, actual engagement, and retained topology. This section describes how the Rust client receives it and fences a later commit.

DESCRIBE TRANSACTION answers twice: rendered in the outcome’s message, as TEXT by default or as one JSON document with FORMAT JSON, and typed in CommandOutcome::inspection. The typed TransactionInspection holds the inspected transaction’s status, the selected operation if the statement named one, and the whole TransactionImpactReport, whichever format rendered the message:

#![allow(unused)]
fn main() {
let described = client.execute("DESCRIBE TRANSACTION OPERATION 2;").await?;
if let Some(inspection) = &described.inspection {
    println!(
        "{} operation(s) in {} execution step(s)",
        inspection.report.operations().len(),
        inspection.report.execution_steps().len()
    );
}
}

The rendered message and CommandOutcome::inspection describe the same complete value. The server does not paginate or truncate a large report, so callers that inspect transactions with many operations or large affected graphs should budget for one response proportional to the expanded report. A successful result always includes all operations, execution steps, topology, and recorded outcomes.

CommandOutcome::transaction keeps describing this session’s own binding, so inspecting another transaction by id changes neither transaction_status() nor the selected domain. An inspection consumes no queue position: the next queued statement receives the operation number it would have had. An inspection of the attached transaction refreshes the identified preview used by COMMIT only when its report covers the attached transaction’s current accepted-operation position. Inspecting another transaction, or receiving an older position, cannot replace that preview. A stale-preview refusal leaves the previously reviewed basis in place; inspect the attached transaction again before retrying COMMIT. A refused inspection is an unsuccessful outcome whose message names why nothing was read.

DESCRIBE RESOURCE <name> is typed the same way: CommandOutcome::resource holds the ResourceDescription beside the printed text, with the highest completed version as an Option, every published version with its entries under their exact paths, and the models bound to each version.

DESCRIBE WASM PROCESSOR <name> is typed too: CommandOutcome::wasm_state holds the WasmStateInspection beside the text or JSON rendering, with the pinned module binding, the default guest-state generation, the latest reset with its request reference, scope, phase, and reason, the recorded rejected-state recoveries, and each current branch’s checkpoint stage, revisions, and replica counts under its opaque fingerprint. RESET WASM PROCESSOR ... STATE is an ordinary command: its success arrives only once the new lifetime is usable, and a retry the client makes after an uncertain outcome reuses the same execution_reference, so it recovers the original reset’s outcome rather than starting another. See WASM State And Recovery.

Client::inspect_transaction(target, operation) also returns the typed InspectionOutcome from the API. It follows leader redirects and reconnects with the session client’s normal request-ID dispatch. A successful read of the attached transaction refreshes the same commit preview; a rejected read changes no binding or preview.

Client Implementation Manual

This manual is the normative contract for a program that speaks the Nervix client session protocol. It states what an implementation must do to frame and verify messages, correlate requests, recover commands exactly, follow the leader, hold a transaction, read subscriptions without misreading a value, publish batches through producers, upload resources, and download backups. Client Session Protocol explains why the system behaves this way. This manual does not repeat those reasons, and the chapter does not repeat these rules.

The Rust client, nervix-client-core, is the reference implementation. The shared binding, nervix-client-ffi, exposes it to C, C++, Python, JVM, and Ruby hosts, and a host that loads the binding inherits every rule below from it except those in Using The Shared Binding. An implementation that speaks the protocol itself, as the Go and TypeScript conformance clients do, follows all of it.

The Rust reference implementation’s execution modes and native/browser capability boundaries are specified in Execution Primitives. Those repository build and verification contracts apply to the Rust implementation; independent clients continue to implement the protocol rules in this manual using their own runtime.

Conformance Language

The key words MUST, MUST NOT, SHOULD, and MAY are used as RFC 2119 defines them. Each rule has an identifier, such as F-3, which Conformance Evidence ties to the executable tests that exercise it. An implementation conforms when it follows every MUST and MUST NOT rule for the parts of the protocol it uses. It MAY implement a subset, for example commands without subscriptions, and MUST NOT send a request whose replies it does not handle.

The protocol has one current form: no version negotiation, no compatibility mode, and no fallback encoding. An implementation MUST be generated from, or checked against, the crates/client-wire/schema/session.fbs of the server release it talks to.

Messages At A Glance

Every request is a ClientMessage carrying a request identity and one ClientRequest. The server answers each request with one terminal Reply, possibly delivered as transfer parts:

RequestServed onServed byTerminal reply body
CommandRequestOrdered laneThe leader, except for the reads the chapter listsCommandOutcome
AttachTransactionRequestOrdered laneThe leaderAttachOutcome
SubscribeRequestOrdered laneAny nodeSubscribeOutcome
UnsubscribeRequestOrdered laneThe session’s nodeUnsubscribeOutcome
AttachDomainClockRequestOrdered laneAny nodeDomainClockAttachOutcome
DetachDomainClockRequestOrdered laneThe session’s nodeDomainClockDetachOutcome
OpenIngestorRequestOrdered laneAny nodeOpenIngestorOutcome
SubmitBatchRequestThe producer’s taskThe session’s nodeSubmissionOutcome
CloseIngestorRequestThe producer’s taskThe session’s nodeCloseIngestorOutcome
OpenEmitterRequestOrdered laneAny nodeOpenEmitterOutcome
ReadEmitterBatchRequestConcurrentlyThe session’s nodeReadEmitterBatchOutcome
SettleEmitterBatchRequestConcurrentlyThe session’s nodeSettleEmitterBatchOutcome
CloseEmitterRequestConcurrentlyThe session’s nodeCloseEmitterOutcome
SuggestRequestConcurrentlyAny nodeSuggestOutcome
ChoiceLookupRequestConcurrentlyAny nodeChoiceOutcome
ListDomainsRequestConcurrentlyAny nodeDomainList
SelectDomainRequestConcurrentlyAny nodeDomainSelectionOutcome
InspectTransactionRequestConcurrentlyThe leaderInspectionOutcome
CancelRequestOn arrivalAny nodeCancelOutcome

Any request may instead end with RequestRejected, or with RequestCancelled when a cancellation targeted it. A command sent to a follower is served there only when it is one of the reads the chapter lists in Which Requests Need The Leader; every other command is answered with a LeaderRedirect disposition.

Unsolicited ServerMessage bodies carry no request identity:

BodyMeaning
LeadershipObservedWhich node leads, first on every session and again on every change
DomainsObservedThe domain list, second on every session and again on every change
DomainSnapshotObserved, ClusterObservedThe selected domain’s live graph and entities, and the cluster summary, after SelectDomainRequest
ServerNoticeText for display at Info, Warning, or Error
SubscriptionRows, SubscriptionDeliveryLost, SubscriptionRowsSkipped, SubscriptionEndedFrames of one subscription generation
DomainClockObserved, DomainClockTicked, DomainClockAttachmentEndedState, accepted tick progress, and end frames of one domain clock attachment
ProducerAdmissionChanged, ProducerEndedThe admission state and the end of one producer
SessionEndingThe last frame of a session the server ends

An upload is a separate call with its own frames; see Resource Uploads. So is the download of a backup’s archive; see Backup Downloads, and a restore with its archive; see Restore Streams.

Frames

  • F-1. An implementation MUST read and write every frame with code generated from session.fbs by flatc, or with code checked against it. It MUST NOT renumber, reorder, or omit schema members.
  • F-2. It MUST send exactly one finished frame per transport message, that is one gRPC message or one WebSocket binary message, finished with its root’s file identifier, NXCM for a ClientMessage and NXUM for an UploadMessage, and without a size prefix. It MUST NOT split a frame across messages, join frames in one message, or send a WebSocket text message.
  • F-3. Before it reads any field of a received frame, it MUST check that the frame is at most the frame limit of 4 MiB, at least 8 bytes long, and carries the expected identifier, NXSM on a session and NXUR for an upload reply. Where its FlatBuffers runtime has a verifier, it MUST verify the frame with a nesting limit of 64, a table limit of the frame length divided by 4, and an apparent-size limit of 8 times the frame length. Where its runtime has none, as for Go and TypeScript, it MUST check every union discriminant, required field, and required value it reads, and MUST treat a read that its runtime fails, such as an offset outside the frame, as a protocol violation.
  • F-4. It MUST treat as a protocol violation a NONE or undeclared union discriminant, an undeclared enum value, a missing required field, an absent optional scalar that a schema comment requires, a zero that a schema comment forbids, and an empty collection that a schema comment forbids. It MUST NOT skip such a value, default it, or map it to another one.
  • F-5. It MUST keep an absent optional scalar, declared = null, distinct from a present zero, both when it reads one and when it writes one. For example, it omits expected_transaction_position when it expects no position and writes 0 when it expects position zero.
  • F-6. It MUST NOT send a frame above 4 MiB, a string above 64 MiB, a vector above 262,144 entries, or tables nested deeper than 64.
  • F-7. It MUST reassemble TransferPart replies into one reply per request identity. It MUST refuse a part that names another request identity or another total, a first total above the transfer limit of 64 MiB, a part whose offset is not the number of bytes received so far, and an empty chunk. It MUST verify the joined bytes as a ServerMessage frame under the transfer limit that holds a Reply to the same request whose body is not itself a transfer part. It MUST accept other frames between the parts of one reply, and MUST NOT deliver a partial reply.
  • F-8. On a protocol violation it MUST stop reading the session, end every request still waiting on it as interrupted, and treat every command among them as uncertain under Commands And Execution Identity. It MAY open a new session.
  • F-9. A value read in place borrows the frame it was read from. An implementation MUST keep the frame alive and unchanged while any such value is in use, and MUST copy what must outlive it. It SHOULD copy a small frame out of a large receive buffer before retaining it, as the Rust gRPC codec does below 64 KiB, so a retained frame does not pin the whole buffer.

Transports And Authentication

  • T-1. A native client MUST open a session as the bidirectional gRPC stream /nervix.session.Session/Exchange over HTTP/2, and an upload as the client stream /nervix.session.Session/UploadResource. Each message MUST be one raw frame, not a protocol buffer, and uncompressed. It SHOULD set its gRPC message size limits to the frame limit.
  • T-2. A server address MUST be an http or https origin whose path is / and that has no query, fragment, or user information, as an advertised grpc_uri is. A client that started over https MUST NOT follow a redirect or a seed to http. A native client that resolves a hostname to one or more addresses MUST retain the original URI authority and verify the TLS certificate against that hostname on every connection, including redirects and reconnects. DNS and address attempts MUST fit within the client’s connection deadline.
  • T-3. A WebSocket client MUST connect to /console/ws on the leader’s console endpoint, using ws for an http endpoint and wss for an https one, and MUST send binary messages only. It MUST treat a close with code 1003, 1007, 1008, or 1009 as a defect in what it sent.
  • T-3a. A WebSocket client MUST carry each backup download on its own connection to /console/backups/download, and each restore attempt on its own connection to /console/backups/restore, with exactly the frames of the DownloadBackup and RestoreBackup calls. A download connection carries one request. A restore connection MUST stay open after its last chunk, whose bytes complete the size its start declares, until the reply arrives: closing it would end the attempt in transport. A client MUST treat a close with code 1001 on either connection as a transport failure, after which A-6 and P-5 apply.
  • T-4. A native client MUST send authorization: Basic <base64(user:password)> metadata on every call, including every upload. A WebSocket client MUST present the same credentials in an Authorization: Basic header or in the auth query parameter, percent-encoded, because the server decodes the query as a form and would read a raw + as a space.
  • T-5. On UNAUTHENTICATED, or 401 for the WebSocket, a client MUST NOT retry with the same credentials automatically. The server paces a user’s attempts after a failure. UNAVAILABLE, or 503 for the WebSocket, means the node could not verify the credentials now and judged nothing, so a client MAY retry with the same credentials after backing off, as it does after any other transport failure.
  • T-6. A client MUST NOT resend a frame that ended its call with OUT_OF_RANGE or INTERNAL, because the server found the frame oversized or malformed.
  • T-7. A WebSocket client MUST expect its session to end with a SessionEnding whose reason is a LeaderRedirect whenever its node does not lead. It then reconnects to the named leader’s web_console_uri with the path /console/ws, or backs off and retries when the redirect names no endpoint.
  • T-8. A client MUST NOT rely on keepalives or an idle timeout, since neither side sends keepalives, and MUST bound every wait with a deadline of its own.

Requests, Replies, And Cancellation

  • C-1. A request identity MUST be non-zero and unique within its session, and MUST NOT be reused after its request’s terminal reply. A counter that starts at 1 on every new session satisfies this. A client that exhausts the 64-bit range MUST open a new session.
  • C-2. A client MUST register the waiter for a request before it sends the request’s frame.
  • C-3. A client MUST route every Reply by its request identity alone. An unsolicited body never completes a request. A reply whose identity no request is waiting for, because its caller stopped waiting, MUST be discarded and MUST NOT complete any other request.
  • C-4. A client MUST expect exactly one terminal reply per request. After a SessionEnding or the end of the transport, no reply follows for any request still in flight.
  • C-5. A client MUST NOT send a request identity that is in flight. The server answers such a duplicate with DuplicateRequestId under that same identity, and the original request’s own reply still follows, so the two cannot be told apart.
  • C-6. A client SHOULD NOT have more than 64 requests in flight on one session. A request refused with TooManyRequestsInFlight was not admitted and MAY be sent again once an earlier request has its terminal reply.
  • C-7. A client MUST read and route replies whatever the application does with unsolicited frames. Every queue of unsolicited frames MUST be bounded, and a full queue MUST drop events and report the loss rather than stop the reader.
  • C-8. A client MUST act on RequestRejected by its reason:
    • InvalidRequest, UnsupportedRequest, UnsupportedValue, and DuplicateRequestId report a defect in the request. The client MUST NOT send it again unchanged.
    • TooManyRequestsInFlight is covered by C-6.
    • ReplyTooLarge says the complete reply cannot be carried. Repeating an unchanged read returns the same result; a command’s outcome is recovered by its execution reference like any lost reply.
    • ServerBusy answers a read, which a client MAY repeat later.
  • C-9. A client MAY cancel a request with a CancelRequest that has an identity of its own. It MUST expect CancelOutcome under the cancellation’s identity and, when that outcome is Requested, the target’s own terminal reply as well, in either order. That reply is RequestCancelled or the target’s ordinary reply. BeforeAdmission means the target has no effect. AfterAdmission means its effect continues and is recovered by its execution reference, except that a domain clock attach answered so attaches nothing (K-1). A client MUST NOT present a cancellation as undoing anything.
  • C-10. A client that stops waiting without sending CancelRequest MUST treat the request exactly as a cancellation after admission: the effect may happen, and a command’s outcome is recovered by its execution reference.

Structured Choice Lookups

  • Q-1. A client that sends ChoiceLookupRequest MUST use the target’s typed dependencies: domain for internal schema, branch, relay, codec, VHOST, signaling protocol, JSON/CBOR/AVRO wire schema, or resource choices; domain followed by a relay Model reference for relay fields; domain followed by a codec Model reference for the fields of its output schema; and domain followed by a Resource reference for completed resource versions. It MUST use the distinct wire-schema targets when a form requires an exact format. An ingestor source-reference target MUST carry a domain and MUST match the selected connector and entity; the HTTP ENDPOINT and WEBSOCKETS ENDPOINT targets return endpoints of that TYPE, and the others return clients of the pair’s connector. The ingestor codec target carries a domain and returns only decoding codecs. Its unbranched relay target carries a domain, while its branched relay target carries a domain followed by a branch Model reference. Ingestor error relays use the unbranched target. A branch-field lookup carries a domain followed by a branch Model reference. Decoded and relay output fields use the codec-field and relay-field targets above. Processor compatible-input, input-branch output, and materialized-relay targets each carry a domain followed by the first input relay’s Model reference. The first requires the same named schema and branch, the second the same branch, and the third a relay with materialized state in that branch. A junction’s first input uses the ingestor relay target for its selected branch; a reingestor’s first input uses the ordinary relay target. Reingestor routes that construct a different branch use the ingestor relay and branch-field targets.
  • Q-2. A client MUST use the returned ChoiceValue, rather than its presentation label, for selection. For completed resource versions it MUST handle ResourceVersionNumber and LatestResourceVersion as distinct values. It MUST NOT offer a version absent from the result as a completed upload. It MUST treat MissingContext, StaleContext, and LookupFailed as distinct outcomes, and MUST restart a paged lookup after StaleContext rather than reuse its cursor.

Routing Statements

A client that accepts NSPL text sends some statements as requests of their own rather than as commands. Sent as a command, each of these is refused with RequestFailed naming the request to use.

StatementSent as
USE <domain>Nothing: it sets the domain the client puts in later requests. A client MAY send SelectDomainRequest to receive that domain’s observations and to learn whether it exists.
LIST DOMAINSListDomainsRequest
CREATE SUBSCRIPTION ...SubscribeRequest carrying exactly that statement
DELETE SUBSCRIPTION <name>UnsubscribeRequest
ATTACH DOMAIN CLOCKAttachDomainClockRequest for the selected domain
DETACH DOMAIN CLOCKDetachDomainClockRequest for the selected domain
UPLOAD RESOURCE ...An UploadResource call
BACKUP ...CommandRequest, then a DownloadBackup call for the archive its outcome summarizes; a browser makes the call over a console WebSocket of its own
RESTORE ...A RestoreBackup call that carries the statement and the archive it names; a browser makes the call over a console WebSocket of its own. Sent as a command, it is refused.
DESCRIBE BACKUP ...Nothing: the client reads the archive file itself. Sent as a command, it is refused.
Every other statementCommandRequest
  • L-1. A client MUST put the selected domain in every CommandRequest, or leave domain absent when none is selected. The server keeps no selected domain for commands.
  • L-2. A client MUST send a statement from the table above on its own. It MUST NOT send several statements in one CommandRequest outside a transaction, and MUST NOT combine DESCRIBE TRANSACTION or SHOW TRANSACTIONS with any other statement.
  • L-3. While it holds a transaction, a client MUST NOT send SubscribeRequest, UnsubscribeRequest, AttachDomainClockRequest, or DetachDomainClockRequest, which the server refuses then, and MUST NOT change its selected domain, which every request of the transaction must name.

Commands And Execution Identity

  • E-1. A client MUST create one execution reference per logical command before it first sends that command. The reference MUST be 1 to 128 bytes of ASCII letters, digits, -, _, and .. For every command that can be persistent or part of a transaction, which is every command except the reads Exact Recovery lists, it MUST be a UUIDv7 whose timestamp is the client’s current time; a reference that is not one is refused before any effect. A client SHOULD use a UUIDv7 for every command.
  • E-2. A client MUST keep the reference together with the exact query text, the domain, the expected transaction position, and the expected preview until the command’s outcome is known. Every repetition MUST send all of them unchanged, under a new request identity. Explicit backup recovery MAY change the client-local archive destination as A-8 specifies; its server capture inputs and selected domain MUST remain unchanged.
  • E-3. A client MUST NOT create a new reference for a command whose outcome is uncertain. It MUST repeat the command under the same reference until it receives a terminal disposition, or give up and report the outcome as unknown, naming the reference.
  • E-4. A client SHOULD stop repeating a reference well before the retry validity, 15 minutes by default, has passed since the reference was created, and its clock SHOULD stay within five minutes of the cluster’s. A repetition after the record was reclaimed is refused with ExecutionReferenceExpired. A client MUST NOT present that refusal as the outcome of the original command, and MUST decide from the disposition rather than the message text.
  • E-5. A client MUST check that the execution_reference of a CommandOutcome equals its request’s reference, and MUST treat a mismatch as a protocol violation.
  • E-6. A client MUST act on each disposition as this table requires:
DispositionRequired action
CommandCompletedReport success. already_existed means nothing changed.
RequestFailedReport the failure with its message and diagnostics.
LeaderRedirectNothing was admitted. Follow Leader Redirect And Reconnect, then repeat under the same reference.
TransactionDetachedAttach the named transaction again, then repeat under the same reference.
TransactionTakenOverReport it. The client MUST NOT attach the transaction back automatically.
OutcomeUnknownBack off and repeat under the same reference until another disposition arrives. The client MUST NOT report success or failure from it.
ExecutionReferenceConflictReport a defect in the client’s use of references. The client MUST NOT repeat the request.
ExecutionReferenceExpiredReport the original command’s outcome as unknown.
PreviewStaleNothing was applied, and the transaction stays open. The client MUST NOT commit again against the current preview without the user’s decision.
  • E-7. A client MUST report a request of several statements from its statements, one outcome per statement in written order. The command’s own disposition is that of the statement that ended it.
  • E-8. A client MUST treat origin as information only. A Recovered outcome is the outcome of the command, with the message and diagnostics recorded when it happened.

An unknown outcome from an interrupted finalization is subject to E-3 and E-6. Its message can describe effects that already applied; the typed disposition still requires recovery under the retained reference, query and transaction expectations.

Leader Redirect And Reconnect

  • D-1. On a LeaderRedirect that names the leader with an endpoint, a client MUST open a session at grpc_uri for a native client, or at web_console_uri for a WebSocket client. Before it repeats a request that belongs to a transaction, it MUST attach that transaction on the new session.
  • D-2. On a LeaderRedirect that names no leader, or a leader without the endpoint the client needs, a client MUST back off and try again. It MUST NOT construct an address the redirect did not name.
  • D-3. A client SHOULD remember a bounded set of endpoints it learned from redirects and LeadershipObserved, and try them, and its configured seeds, when its session is lost.
  • D-4. A client MUST bound every call with a deadline of its own. When a deadline passes before a command’s outcome is known, the client MUST report the command as uncertain, naming its reference, and never as failed or completed.
  • D-5. After it loses a session, a client that restores session state MUST, in this order: open a new session; attach the domain clocks it follows; open the subscriptions it keeps again, each as a new generation, reporting the gap; attach its transaction; and only then repeat outstanding commands under their original references. It MUST have sent every clock attach and subscription of the new session before it sends the transaction attach, because a session that holds a transaction refuses both.
  • D-6. A client MUST treat SessionEnding, the end of the transport, and a protocol violation alike: every request in flight ends without a reply, and every command among them is uncertain.

Transactions

  • X-1. A client MUST send BEGIN with the selected domain, which must already exist, and MUST keep the transaction identity from the outcome’s transaction status as the transaction’s handle.
  • X-2. Every request that appends to the transaction the session holds MUST carry expected_transaction_position set to the accepted_operations of the newest status the client received for that transaction; that is 0 right after an empty BEGIN. A request of several statements carries the position of its first append. The client MUST advance the position only from an outcome that reports it.
  • X-3. A client MUST report an append as accepted only from its own outcome: CommandCompleted under its own reference, carrying a transaction_admission. It MUST report a commit only from the outcome recorded under the commit’s own reference. It MUST NOT infer either from the transaction’s state or counts, including COMMITTED.
  • X-4. A client SHOULD send COMMIT with expected_preview set to the preview identity the user reviewed: the one from the last append’s admission, or from an inspection of the attached transaction at its current position. It MUST NOT refresh that preview from a PreviewStale reply, from an inspection of another transaction, or from an inspection at an older position.
  • X-5. While a commit is COMMITTING, a client MUST keep waiting for the commit’s own outcome, repeating the commit under its reference after any interruption.
  • X-6. On TransactionDetached, a client MUST send AttachTransactionRequest for the transaction, adopt the domain of the attached transaction, and repeat the command under the same reference and position. TransactionAlreadyFinished reports the final status and aggregate outcome of a transaction that ended; the client MUST NOT treat it as the outcome of an outstanding append or commit. After a finished or failed attach, the client MUST repeat each outstanding command under its own reference and original position to obtain its recorded outcome or a definitive failure for a command that was never admitted.
  • X-7. Ending a session cleanly, by half-closing the gRPC request stream or sending a WebSocket close with no request in flight, reverts the open transaction the session holds. A client MUST end a session that way only when it intends that revert. Any other ending leaves the transaction open for a later attach.
  • X-8. A client MUST expect an open transaction to expire after its idle timeout, 15 minutes by default, whether or not a session is bound to it. Only attaching, appending, and commit admission renew it.
  • X-9. A client MUST NOT assume a read is linearizable. A read served by any node reflects that node’s applied state. Once a command completes, its effect is visible through each node in the leader’s completion participant set. A node the leader has retired can serve an older state until it catches up.

Subscriptions

  • S-1. A client MUST open a subscription with SubscribeRequest naming the domain, carrying exactly one CREATE SUBSCRIPTION statement, and setting subscription_type to Row explicitly.
  • S-2. A client MUST record the handle and schema of SubscriptionOpened in the frame reader itself, before it routes any later frame, because the subscription’s rows can follow the reply immediately.
  • S-3. A client MUST route every subscription frame by its complete handle, the name together with the generation. It MUST ignore a frame for a handle it does not hold, MUST NOT apply a frame of one generation to another, and MUST expect the SubscriptionEnded of an earlier generation to arrive after the opening reply of a later generation with the same name.
  • S-4. A client MUST treat SubscriptionEnded as the last frame of its generation. After RelayChanged, it MUST NOT read later rows against the ended generation’s schema, and subscribes again to read the relay under its current definition. A client that keeps subscriptions across sessions MUST NOT open an ended generation again on a later session; only a new subscribe under the same name opens it, as a new generation. It MAY complete the deletion of an ended subscription without sending UnsubscribeRequest, because the server keeps an ended name only until it is reused or its session ends.
  • S-5. A client MUST surface SubscriptionDeliveryLost and SubscriptionRowsSkipped as gaps with their counts, and MUST NOT present the rows around a gap as continuous.
  • S-6. After UnsubscribeOutcome reports SubscriptionDeleted, nothing about that generation follows, and the client MAY reuse the name at once.
  • S-7. A client that keeps a subscription across sessions MUST open it again as a new generation on the next session, unless the server ended it (S-4), and report the time between as a gap. If its user deletes a subscription while an opening or reopening is in flight, a late successful reply MUST be followed by an unsubscribe before the name is used again. When the new session refuses the reopening, or leaves it unanswered, the client MUST report the refusal and SHOULD send it again on that session after a wait that grows with each refusal, rather than at a fixed rate. A client MUST complete the deletion of a subscription no open session holds, because the session that held it ended or the current session refused to open it again, without sending UnsubscribeRequest, and MUST release its name; a deletion whose session ends before it is answered is complete too.
  • S-8. A client MUST bound what it retains for subscriptions, per subscription and in total, and SHOULD let one subscription retain at least one frame of the frame limit.
  • S-9. A client that stops reading a BLOCKING subscription holds back the relay it reads. A client that cannot keep up SHOULD use DROPPING delivery.

Reading Rows

The reply that opens a subscription carries a RowSchema: the relay’s fields in declared order, each with its name, type, nullability, and sensitivity, and, exactly when the relay is branched, the declared branch with its key fields. Every later batch is positional against that schema.

  • R-1. A client MUST read a RowBatch only against the schema of the generation its frame names.
  • R-2. Before it exposes any value of a batch, a client MUST check the batch against that schema, and MUST treat a batch that does not conform as a protocol violation:
    • a batch of a branched relay carries a branch_key, and a batch of an unbranched relay carries none;
    • a row holds exactly one cell per schema field, and a branch key exactly one cell per key field, in declared order;
    • a sensitive field holds RedactedCell in every row, and no other field does;
    • only a nullable field holds NullCell;
    • every other cell holds exactly the cell type of its field’s declared type, and a fixed-length list holds exactly its declared number of elements;
    • a list element is never NullCell or RedactedCell.
  • R-3. A client MUST NOT convert a value to another type while reading it. An integer keeps its width and signedness, a DATETIME is signed nanoseconds since the Unix epoch in UTC, a STRING is UTF-8 that may contain NUL, and a BYTES value is raw octets that may be empty or not UTF-8.
  • R-4. A client MUST keep a null value, a withheld value, and a present value distinct in its data model. A presentation MAY render a withheld value as a placeholder such as "<masked>".
  • R-5. A client MUST keep the exact bits of F32 and F64 values, including the sign of a negative zero and the payload of a NaN, wherever its data model can hold them.
Declared typeCellValue
U8, U16, U32, U64U8Cell, U16Cell, U32Cell, U64CellUnsigned integer of that width
I8, I16, I32, I64I8Cell, I16Cell, I32Cell, I64CellSigned integer of that width
F32, F64F32Cell, F64CellAn optional scalar that is always present, so a negative zero keeps its sign
BOOLBoolCellBoolean
STRINGStringCellUTF-8 text
BYTESBytesCellRaw octets
DATETIMEDatetimeCellSigned Unix nanoseconds in UTC
Fixed-length listListCellExactly the declared number of elements, each a cell of the element type
Variable-length listListCellAny number of elements, each a cell of the element type

Language Pitfalls

The conformance report carries values chosen to break careless readers: every integer width at both extremes, 64-bit values on both sides of JavaScript’s safe-integer boundary, an absent and a present-zero optional value, text with multi-byte characters and an embedded NUL, bytes that are not UTF-8, empty text and bytes, -0.0, the smallest subnormal and the largest finite floats by their bits, the extreme DATETIME nanoseconds, and a redacted field. The corpus adds lists, a NaN with a payload, infinity, and nullable and sensitive branch key fields.

RuntimePitfallRequired handling
JavaScript and TypeScriptA Number holds 53 bits of integer precision.Read U64, I64, DATETIME, request identities, generations, and counts as BigInt.
JavaScript and TypeScriptJavaScriptCore, which Bun runs on, canonicalizes a NaN it materializes as a Number; V8 keeps the payload.Read a float’s bits from the buffer as an unsigned integer when the bits matter, instead of calling the generated value() accessor.
JavaScript and TypeScriptDate has millisecond precision and a narrower range than the protocol’s nanoseconds.Keep a DATETIME as a BigInt of nanoseconds.
Java and other JVM languagesThere are no unsigned primitive types.A U64 arrives in a long; compare and print it with the unsigned Long operations. Values read from the binding’s column copies are raw bytes of the declared width, so widen a U8, U16, or U32 without sign extension.
C and C++Strings may contain NUL, and the binding never terminates them.Use the length every accessor returns; never treat a value as a C string.
PythonA memoryview borrows memory it does not own.Keep the object that owns the buffer alive for as long as the view is used.
Every runtimeA borrowed value reads the frame it was verified in.Keep the frame alive, and unchanged, while any borrowed value is used, and copy what must outlive it.

Domain Clock Attachment

  • K-1. A client MUST attach with AttachDomainClockRequest and detach with DetachDomainClockRequest, each naming the domain. It follows each domain at most once. It MUST handle every disposition: DomainClockAttached with the clock, DomainClockAlreadyAttached, DomainNotFound, and RequestFailed for an attach; DomainClockDetached, DomainClockNotAttached, and RequestFailed for a detach. DomainClockNotAttached means the session does not follow that clock, and the client MUST stop following it as it does after DomainClockDetached. An attach answered RequestCancelled attaches nothing, so the client MUST NOT follow the clock on its account; a detach answered RequestCancelled with AfterAdmission has detached. A client that attaches clocks again on its own, as K-5 requires, MUST NOT decide to send such an attach while a detach of the same domain is unanswered, and MUST send an attach it decided on before that detach ahead of it, so that the detach reaches the attach’s session behind it or finds that session ended. Once the detach is answered, no session then holds the clock on the client’s behalf.
  • K-2. A client MUST apply the clock in the attach reply before any state or tick frame for that domain. The attach reply’s state, or a later DomainClockObserved for a changed generation, MUST be applied before a DomainClockTicked of that generation. It MUST ignore frames about a domain it does not follow and MUST treat DomainClockAttachmentEnded as the last frame about that attachment.
  • K-3. A client MUST treat each DomainClockObserved as the newest installed clock and clear an older generation’s tick or any tick when the clock is not paced. It MUST retain only the newest accepted DomainClockTicked per domain and generation, ignoring a tick id that does not advance. State changes and tick ids can be coalesced, so it MUST NOT expect every intermediate state or tick. It MUST discard unread ticks when it detaches or loses the session.
  • K-4. A client MUST read the tick generation and id as nonzero unsigned 64-bit integers; its logical boundary, authority UTC observation, and serving-node logical reading are signed 64-bit Unix nanoseconds. A clock’s generation is an unsigned 64-bit integer that is zero before the domain’s first START. Period and skew are unsigned 64-bit nanoseconds, the period is nonzero, and the time rate is a required positive finite double. DomainClockAttachmentEnded always carries its reason. The serving-node reading anchors the tick when the client’s UTC differs from the cluster’s. Domains And Time defines the projection a client computes from a paced clock.
  • K-5. After it loses a session, a client that follows clocks MUST attach them again, and MUST NOT assume it saw the changes or ticks made in between. It MUST clear the old tick frontier until the new attachment reports accepted progress. It MUST treat DomainClockAlreadyAttached as the session holding the attachment, keep the clock it last received without a tick, and read the clock from that session’s later frames. A node that is still starting answers such an attach only once it has installed the committed domains, so DomainNotFound in reply means the domain no longer exists, and the client MUST end the attachment as DomainClockAttachmentEnded would. When the session refuses the attach for any other reason, the client MUST keep the attachment interrupted rather than drop the domain, and SHOULD send the attach again on that session after a growing wait. An attach the session leaves unanswered past the client’s request timeout ends that session, as any request that outlives the timeout does, and the client attaches the clock again on its next session; a client waiting for clock events MUST then open that session rather than wait for the ended session’s transport to fail. When a new session answers the attach while the previous session still holds the attachment, as after a leader redirect, the client MUST move the attachment without a gap: it keeps the newest tick of a clock that stays paced in the same generation, and ignores a tick of the new session that does not advance it.

Producers

  • P-1. A client MUST open a producer with OpenIngestorRequest, naming the domain, the ingestor, the exact fields it expects, including each field’s optionality and sensitivity, and positive credit no larger than 1,024 batches and 32 MiB. It MUST handle both dispositions: Opened with the producer’s description, and Refused with a ClientProducerRefusal. It MUST NOT open a producer while its session holds a transaction.
  • P-2. The producer’s identity is the request identity of its open. A client MUST start following that producer before the open’s waiter completes, so the admission changes and end that the server sends after the reply find it, and it MUST ignore frames about a producer it does not follow.
  • P-3. A batch MUST be one canonical Arrow IPC stream: the schema message, exactly one record batch, and the end-of-stream marker, uncompressed, without dictionary or extension encodings, and with exactly the fields of the producer’s description in Nervix’s Arrow representation and without field metadata. It MUST carry at most max_batch_rows rows and max_batch_bytes bytes of the grant.
  • P-4. A client MUST track the credit its batches hold: each takes one of the granted batches and its size of the granted bytes from the moment it is sent until its outcome is read. It MUST NOT send a batch that does not fit; the server refuses it as CreditExceeded and ends the producer as ProtocolViolated.
  • P-5. A client MUST expect exactly one SubmissionOutcome per batch and MUST NOT report completion before Completed arrives; transport receipt is not an outcome. It MAY send a batch again only when its outcome is NotAdmitted with Suspended or Busy, only while admission is open, and only after the ingestor’s declared backoff, which starts at retry_backoff and doubles up to retry_max_backoff. It MUST NOT send again, by itself, a batch whose outcome is ProcessingFailed or OutcomeUnknown, or one refused for any other reason. A batch sent again follows every batch sent since, so a client that needs submission order MUST NOT have more than one batch outstanding.
  • P-6. A client MUST stop sending new batches while the newest ProducerAdmissionChanged says Suspended. Admission changes are coalesced, so it MUST NOT expect every intermediate state. It MUST treat ProducerEnded as the last frame about the producer, every batch of which has already received its outcome. It MUST continue reading outcomes for admitted batches during an alteration’s suspension, including buffered partial batches. It MUST retain their credit until their outcomes arrive; suspension and a failed alteration do not authorize replay or a replacement producer.
  • P-7. A client MUST NOT send CancelRequest for a submitted batch; the server refuses it with InvalidRequest, and the batch’s outcome still follows. A caller that stops waiting MUST leave the outcome retrievable, so it is never lost.
  • P-8. A client MUST close a producer with CloseIngestorRequest, and MUST expect every batch’s outcome before the close’s reply. When its session is lost, it MUST report every batch sent without an outcome as of unknown outcome and MUST NOT resend any such batch. A client retaining a desired producer MAY open a new attachment on a new exchange only after its START generation, endpoint contract, fields, policy and grant match the original description. The new open uses a fresh request identity. A changed contract or generation, a removed or stopped endpoint, or a schema mismatch requires an explicit new producer. A temporary capacity or owner refusal MAY be retried with bounded physical backoff. EndpointUnavailable while the serving node establishes its process-start catch-up proof is such a temporary refusal. Close and drop MUST win over an in-flight restoration.

Emitter Consumers

  • E-1. A client MUST open a TO CLIENT emitter with OpenEmitterRequest, naming its domain, emitter and exact ordered output fields, including optionality and sensitivity. It MUST supply nonzero batch and byte limits; the requested byte limit MUST hold one maximum-sized IPC batch. An open inside a transaction is refused. The open’s request_id becomes its ConsumerId.
  • E-2. A client MUST continue reading transport frames while an application waits for consumer credit or processes an output batch. A pending ReadEmitterBatchRequest does not acknowledge delivery and MUST NOT block command replies, producer outcomes, domain clock frames, or SettleEmitterBatchRequest replies. A client whose caller stops waiting for a read SHOULD keep the read and hand its reply to the next read of the same attachment: the server may already have assigned an attempt to it, and a reply nobody reads leaves that attempt unsettled until the emitter’s ACK timeout.
  • E-3. A Batch outcome is one Arrow IPC stream with exactly the opened schema and one batch. The client MUST check its row count against members, and treat the source relay and optional 32-byte branch fingerprint as metadata, never as extra fields. The fingerprint does not carry raw branch key values. A read may instead return Ended when its attachment is gone. Since an end does not say whether the endpoint moved or changed, a client retaining the desired consumer MUST report the interruption and check a fresh open against its pinned contract.
  • E-4. A client MUST settle only the current attempt reference with Ack, Retry, or Reject. The rejection reason is nonempty, at most 1024 UTF-8 bytes, and MUST NOT contain sensitive values. It MUST await Confirmed before treating an ACK as confirmed. A stale reference cannot settle a replacement; repeating a confirmed ACK is idempotent only while the server retains that bounded result.
  • E-5. A retry or timeout can deliver the same stable identity and IPC bytes with a new reference, possibly to a different worker. The client MUST design its application side effects for this duplicate window. There is no durable consumer cursor. An owner or session loss ends the attachment. A client retaining a desired consumer MAY reopen it on the current or a new exchange only after the START generation, endpoint contract, fields, window, timeouts, grant and batch limits match its original open. It MUST surface the interruption before delivering a batch from the new attachment. It MAY retry EndpointUnavailable while the serving node catches up, with bounded physical backoff. Removed or changed endpoints require a new consumer open by the application.
  • E-6. CloseEmitterRequest releases the attachment. Closing, losing the session, or losing the forwarding stream revokes its unresolved references. A delivery reference MUST be settled only through the exchange and consumer identity that delivered it; a lost settlement answer MUST be reported as uncertain, and a revoked reference MUST NOT be sent to a replacement attachment. Closing or dropping a desired consumer MUST fence a late restoration. Producers and consumers may share one session; an application may read and ACK output while a submitted producer batch waits for its graph outcome.

Resource Uploads

  • U-1. A client MUST upload one archive per UploadResource call. The first frame is an UploadStart with a non-zero request_id, the domain, the declared resource, the upload identity, and a non-zero total_bytes. The archive follows as UploadChunk frames in order, each non-empty and within one frame, adding up to exactly total_bytes, and then the client half-closes the stream. The Rust client sends 64 KiB chunks. Resources defines the archive format. A client that prepares a local archive MUST finish and flush it before using it as an upload body. A preparation failure or cancellation MUST abandon the partial archive while preserving the original failure or cancellation outcome. Local preparation has not admitted an upload.
  • U-2. A client MUST create one upload identity per logical upload, 1 to 128 bytes of ASCII letters, digits, -, _, and ., and SHOULD make it a UUIDv7. It MUST keep the identity and the exact archive bytes until the upload’s outcome is known, and every attempt MUST send both unchanged.
  • U-3. A client MUST check that the reply’s request_id equals the start’s, and that a ResourceInstalled names the upload identity it sent.
  • U-4. A client MUST act on the reply:
    • ResourceInstalled reports the installed version.
    • UploadFailed with InvalidStream, ResourceNotDeclared, SizeMismatch, or QuotaExceeded admitted nothing, and the client MUST NOT send the same stream again unchanged.
    • UploadFailed with InstallationFailed reports that the upload failed, that its identity is bound to another archive, or that its completion could not be confirmed, and carries the assigned version when there is one. Repeating the same identity with the same archive returns the outcome recorded for it.
    • LeaderRedirect means the client streams the archive again, with the same identity, to the leader, or backs off when no leader endpoint is named.
  • U-5. When a call fails in transport, or its deadline passes, before a reply arrives, the upload’s outcome is uncertain. A client MUST send the whole archive again under the same identity to learn it, and MUST NOT create a new identity for it.

Backup Downloads

  • A-1. A client MUST send BACKUP as a CommandRequest on its own, under an execution reference that follows E-1 to E-4, and MUST download the archive only after a CommandCompleted outcome that carries a backup summary. It MUST keep the reference and the summary until the archive is downloaded or its retention ends. Each domain summary includes the capture cut kind and, for a quiesced cut, its engagement and release times and quiesce counters.
  • A-2. A client MUST download one archive per DownloadBackup call, sending exactly one BackupDownloadRequest that names the backup’s execution reference. The answer is one BackupDownloadFailed or LeaderRedirect frame, or a BackupArchiveStart, the archive as BackupArchiveChunk frames in order, and a BackupArchiveComplete. Any other order is a protocol violation.
  • A-3. A client MUST check that the start’s size and digest equal the summary’s, MUST refuse an archive that grows past that size, and MUST accept the archive only after BackupArchiveComplete arrived and the bytes it received have exactly the summary’s size and BLAKE3 digest.
  • A-4. A client MUST NOT expose a partial or unverified archive under the name the user asked for, and SHOULD write the archive so that only its owner can read it: an archive holds password hashes and secrets.
  • A-5. A client MUST act on the answer:
    • LeaderRedirect means the client downloads again from the leader, or backs off when no leader endpoint is named.
    • BackupDownloadFailed with Expired, NotOwner, NotRetained, or InvalidRequest is final for this archive, and the client MUST NOT present it as a failure of the backup itself, which completed.
    • BackupDownloadFailed with ReadFailed leaves the archive retained, and the client MAY download it again.
  • A-6. When a call fails in transport, stalls, or ends before BackupArchiveComplete, a client MAY download the archive again, always from its first byte. It MUST NOT bound a download by the command’s request deadline, and SHOULD bound the wait for each frame instead. A client that lost the outcome of BACKUP itself repeats the command under the same reference, as E-3 requires, to recover the summary.
  • A-7. A client MUST distinguish each domain’s quiesce timeout from the overall backup command wait. It SHOULD provide a bounded configurable wait without requiring the operator to predict the number of domains. All redirects and reconnects in one wait MUST share one deadline and execution reference. If that wait ends without a terminal outcome, the client MUST report the reference as uncertain and MUST NOT imply that admitted work was cancelled. The native client defaults to a ten-minute command wait; its duration is between one millisecond and 24 hours.
  • A-8. Recovery MUST retain the original selected domain, scope, resource inclusion and capture options. The local archive destination MAY change; it is excluded from the semantic request binding. A client MUST respect an execution-reference conflict or expiry and MUST NOT silently issue a fresh reference. An explicit renewed wait does not renew archive retention or server retry validity, and the archive may already be unavailable when its outcome is recovered.

Restore Streams

  • P-1. A client MUST send RESTORE on its own, never in a CommandRequest and never while it holds a transaction. Each attempt is one RestoreBackup call: a RestoreStart with a non-zero request_id, the restore’s execution reference, the statement as canonical NSPL, and the exact size and BLAKE3 digest of the archive; then the archive as RestoreChunk frames in order, each non-empty and within one frame, adding up to exactly the declared size; then the client half-closes the stream, or on a console WebSocket keeps the connection open for the reply (T-3a). The Rust client and the web console send 256 KiB chunks.
  • P-2. A client MUST create one execution reference per logical restore, following E-1 to E-4, and MUST keep the reference, the statement, and the archive’s bytes until the restore’s outcome is known. Every attempt MUST send all three unchanged, and the digest MUST be computed over exactly the bytes the client streams.
  • P-3. A client MUST check that the reply’s request_id, when present, equals the start’s, and that a CommandOutcome names the execution reference it sent.
  • P-4. A client MUST act on the reply:
    • RestoreUploadFailed with InvalidStream, InvalidStatement, SizeMismatch, or DigestMismatch changed nothing, and the client MUST NOT send the same stream again unchanged.
    • RestoreUploadFailed with QuotaExceeded or StagingFailed changed nothing, and the client MAY send the same stream again later.
    • A CommandOutcome is the restore’s outcome, decided from its disposition as E-6 to E-8 decide a command’s: LeaderRedirect means the client streams the restore again, under the same reference, to the leader; OutcomeUnknown means it streams it again after a backoff; and CommandCompleted and RequestFailed are terminal. A RequestFailed whose restore report names a failed step leaves the steps before it applied. Clients MUST decode each restored domain’s required status and start_version, including in a dry run. With RESUME, a completed outcome reports Running at the archived generation after all state is published; the default reports Stopped. Clients MUST also decode the required start_version in each DomainInfo from domain lists and domain-change events, and MUST reject missing required generation or status fields rather than supply a default.
  • P-5. When a call fails in transport, or a frame or the reply does not arrive in time, the restore’s outcome is uncertain. A client MUST stream the whole archive again, from its first byte, under the same reference to learn it, and MUST NOT create a new reference for it. It MUST NOT bound a call by the command’s request deadline, and SHOULD bound each frame, and the wait for the reply once the last frame was sent, instead.

Using The Shared Binding

A host of nervix-client-ffi follows the contract in crates/client-ffi/include/nervix_client.h and these rules:

  • B-1. A host MUST prepare each logical command once with nx_session_prepare, and MUST execute the same nx_execution again after NX_ERROR_UNCERTAIN, NX_ERROR_CANCELLED, or NX_ERROR_DEADLINE to recover its outcome. It MUST NOT prepare a new execution for a command whose outcome is uncertain.
  • B-2. A host MUST release every object it receives exactly once, with that object’s release function, and MUST NOT use a borrowed pointer after the object it was read from is released.
  • B-3. A host MUST hold a reference to an event, taken with nx_event_retain, for as long as it uses any frame or value borrowed from that event, and MUST release every reference exactly once. It MAY release a reference on any thread.
  • B-4. A host MUST read every string as a pointer and a length. Strings are never terminated and may contain NUL.
  • B-5. A host MUST NOT call the binding from a thread that is driving a Tokio runtime.
  • B-6. A host SHOULD read a batch one column at a time with the column accessors. For a string or bytes column it first passes a null data buffer to learn the size it needs.
  • B-7. A host MUST treat NX_EVENT_INTERRUPTED as a gap in every subscription it names, NX_EVENT_RESTORATION_FAILED as a refused attempt to open that subscription again, which the session repeats, NX_EVENT_CONSUMER_OVERFLOW as the end of that subscription’s delivery on this session, and NX_EVENT_ENDED as the last event of that subscription’s generation, which the session never opens again.
  • B-8. A host that runs BACKUP through nx_session_execute receives the archive in the file the statement names, and reads its size and digest with nx_outcome_backup. After an error that names the backup’s execution reference, the host MAY execute the same nx_execution again, which recovers the backup’s outcome and downloads its archive while the server retains it.
  • B-9. A host MUST release every nx_clock_event that nx_session_next_clock_event or nx_clock_event_retain returns exactly once, and MAY release it on any thread. It MUST treat NX_CLOCK_EVENT_INTERRUPTED as a gap in that domain’s clock: the changes and ticks in between are not reported, and the clock the restored attachment found arrives as the next state event, or, when the new session already held the attachment, with that session’s later events. It MUST treat NX_CLOCK_EVENT_RESTORATION_FAILED as a refused attempt to attach that domain’s clock again, which the session repeats; that event carries only its kind and domain. Rust Client Library lists the accessors of each event kind.
  • B-10. A host that runs RESTORE through nx_session_execute streams the archive the statement names, and reads what the restore’s report says with nx_outcome_restore. After an error that names the restore’s execution reference, the host MUST execute the same nx_execution again, which streams the archive again and joins the restore or recovers its outcome.
  • B-11. A host that paces on a domain clock MUST read it with nx_session_domain_clock once ATTACH DOMAIN CLOCK; completes, before it uses a tick, and MUST hold every later tick to the clock of the tick’s generation as the latest NX_CLOCK_EVENT_STATE event, or a read taken after it, reports that clock. After NX_CLOCK_EVENT_INTERRUPTED it MUST NOT assume that the clock it read before the gap still holds. After NX_ERROR_CANCELLED or NX_ERROR_DEADLINE from an attach, it MUST execute the same nx_execution again, as B-1 requires, and read nx_session_domain_clock to learn whether the session follows the clock, since that attempt is refused as already attached when the first one took effect. It MUST release every nx_domain_clock exactly once, and MAY release it on any thread. It SHOULD project logical time, waits, and admission with the nx_domain_clock projections rather than reimplement the mapping’s rounding.
  • B-12. A host MUST describe an endpoint’s fields exactly with nx_fields: every field in order, every level of a list type, and each field’s nullability and sensitivity. It MUST read why an open was refused with nx_error_open_refusal, and MUST NOT open a producer or a consumer while its session holds a transaction.
  • B-13. A host building a batch MUST give every column the levels its type has: states for a nullable column when a row is null, offsets for every LIST level, and the innermost values in their type’s exact width or as UTF-8 text. It MAY reuse or free its buffers as soon as each builder call returns. A host submitting its own Arrow IPC MUST write one canonical stream of the producer’s schema, P-3’s, and MAY reuse the buffer once nx_producer_submit_ipc returns.
  • B-14. A host MUST take every submission’s outcome with nx_producer_rejoin, or let it go with nx_producer_release, since a submission holds its credit until then. A nx_producer_submit that ends with NX_ERROR_CANCELLED or NX_ERROR_DEADLINE submitted nothing, and the host MAY submit the batch again. A nx_producer_rejoin that ends either way leaves the submission with the producer, and the host MUST NOT submit its batch again. It MUST NOT treat NX_SUBMISSION_PROCESSING_FAILED or NX_SUBMISSION_OUTCOME_UNKNOWN as proof that the batch had no effect, and replays such a batch only when its effects are idempotent.
  • B-15. A host MUST settle a delivery with nx_delivery_ack, nx_delivery_retry, or nx_delivery_reject; releasing a delivery never acknowledges it. It MUST key its effects by the delivery’s identity, since a retried, timed-out, or revoked attempt comes again with a new reference, and after NX_ERROR_UNCERTAIN from a settlement it MUST treat the delivery as possibly settled. It MUST release every nx_delivery and nx_batch reference exactly once, MAY release one on any thread, and MUST NOT use a borrowed stream or value after the reference it was read from is released.
  • B-16. A host MUST treat NX_ERROR_INTERRUPTED from nx_consumer_next as a gap: no delivery read before it can be settled, and what they carried is delivered again. It MUST treat NX_ERROR_REOPEN_REQUIRED as the end of the handle, read why with the handle’s reopen reason, and open a new one only after checking the endpoint it now expects.

Required State Machines

An implementation that supports a feature MUST behave as the corresponding state machine describes. The state names are not normative.

A request. It is registered before it is sent and ends exactly once:

stateDiagram-v2
    [*] --> Registered: identity taken, waiter registered
    Registered --> Sent: frame written
    Sent --> Assembling: first transfer part
    Assembling --> Assembling: next part in order
    Assembling --> Answered: joined reply verified
    Sent --> Answered: terminal reply
    Sent --> Interrupted: session ended, transport lost, or protocol violation
    Assembling --> Interrupted: session ended, or a malformed part
    Answered --> [*]
    Interrupted --> [*]: a command among them is uncertain

A session and a command. The chapter’s Reconnecting A Session shows both: a session moves between connecting, open, redirecting, awaiting a leader, lost, and restoring, and a command keeps one execution reference from preparation to its terminal disposition.

A transaction binding. The client tracks which transaction its session holds:

stateDiagram-v2
    [*] --> Unbound
    Unbound --> Bound: BEGIN completed, or TransactionAttached
    Bound --> Bound: append accepted; position advanced from its outcome
    Bound --> Detached: TransactionDetached, session lost, or leader changed
    Detached --> Bound: TransactionAttached
    Detached --> Finished: TransactionAlreadyFinished
    Bound --> TakenOver: TransactionTakenOver
    Bound --> Finished: COMMIT or REVERT outcome recorded, or the transaction expired
    TakenOver --> [*]
    Finished --> [*]

A subscription. The chapter’s Restoration And Bounded Consumers shows the lifecycle a client keeps for each subscription across sessions.

A producer and its batches. A producer handle retains its desired endpoint across sessions; each wire attachment lives on one session, and each batch holds its credit until its outcome is read:

stateDiagram-v2
    [*] --> Opening: OpenIngestorRequest sent
    Opening --> Refused: Refused
    Opening --> Open: Opened, followed before the waiter completes
    Open --> Suspended: ProducerAdmissionChanged Suspended
    Suspended --> Open: ProducerAdmissionChanged Open
    Open --> Closing: CloseIngestorRequest sent
    Suspended --> Closing: CloseIngestorRequest sent
    Closing --> Closed: every outcome, then the close reply
    Open --> Ended: every outcome, then ProducerEnded
    Suspended --> Ended: every outcome, then ProducerEnded
    Open --> Interrupted: session lost; sent batches unknown
    Suspended --> Interrupted: session lost; sent batches unknown
    Interrupted --> Restoring: new exchange, fresh open
    Restoring --> Open: generation and contract match
    Restoring --> Interrupted: exchange lost again
    Restoring --> ReopenRequired: generation or contract changed
    Refused --> [*]
    Closed --> [*]
    Ended --> [*]
    ReopenRequired --> [*]
stateDiagram-v2
    [*] --> AwaitingCredit
    AwaitingCredit --> Sent: credit taken, frame written
    Sent --> Retrying: NotAdmitted Suspended or Busy
    Retrying --> Sent: backoff elapsed, admission open
    Sent --> Resolved: any other outcome
    Sent --> Unknown: session lost
    Resolved --> [*]: outcome read, credit returned
    Unknown --> [*]: reported unknown, credit returned

Conformance Evidence

The rules above are exercised by these tests. The Cucumber scenarios run through just test-scenarios --input <feature>, the unit tests through just test-package-lib <package>, the wire and corpus tests through just test-client-wire, and the cross-language probes through just test-client-conformance.

RulesExecutable evidence
F-1 to F-7the_checked_in_corpus_is_what_the_encoder_writes_and_reads, the frame and transfer tests of nervix-client-wire such as a_union_discriminant_without_its_member_is_refused, parts_must_belong_to_the_transfer_and_arrive_in_order, and corrupting_a_row_batch_never_panics; the scenario A <runtime> client reads every frame of the conformance corpus the Rust encoder wrote for Go, Node.js, and Bun
F-8, D-6a_frame_the_contract_does_not_describe_ends_the_exchange and a_session_ending_frame_closes_the_waiters_of_its_exchange in nervix-client-core
T-1 to T-6Authentication, framing and protocol failures end a native session with their status and The console WebSocket closes a connection that sends something other than a session frame in session_protocol.feature; non_frame_messages_end_the_connection_with_a_close_code
T-3aEvery scenario of backup_restore.feature in the web console suite; a_console_download_answers_its_request_and_closes_normally, a_console_restore_is_answered_once_its_declared_bytes_arrived, a_console_restore_whose_client_leaves_early_is_not_answered, a_second_request_on_a_console_download_breaks_the_call, a_console_download_stops_at_its_client_close_while_frames_remain, and the shutdown cases in nervix-server; each_console_websocket_refuses_the_frames_of_another_call in nervix-client-wire
T-7Web console opened on a follower connects to the leader and Web console reconnects after leader switchover in connection_status.feature
C-1 to C-7request_identities_start_at_one_and_are_never_reused, response_reordering_cannot_take_another_requests_waiter, a_reply_no_request_waits_for_is_dropped, saturated_event_consumer_cannot_block_a_command_reply, and replies_reach_their_requests_in_whatever_order_they_arrive in nervix-client-core; untracked_domain_push_cannot_discard_a_pending_websocket_request and ordered_requests_beyond_the_in_flight_limit_wait_in_order_for_earlier_replies in the console; Web console refuses a command past its outstanding requests and recovers once they complete in nspl_repl.feature
C-8A malformed request is refused with a typed rejection and the session keeps serving in session_protocol.feature; a_rejected_request_surfaces_as_a_typed_error
C-9, C-10A long command leaves the session responsive and a waiter cancelled before admission admits nothing and Cancelling a durably admitted command ends only the wait for it in session_protocol.feature; cancelling_a_command_releases_its_pending_reply
Q-1, Q-2configured_choices unit tests for exact wire-schema, resource, version, VHOST, signaling, and dependency choices; typed_choices_and_lookup_states_round_trip in nervix-client-wire; A resource-backed codec selects a completed version and file explicitly and A codec can choose a wire schema staged earlier in its transaction in visual_create_codec.feature; WebSocket client and endpoint select an existing signaling protocol in visual_create_client_endpoint.feature; Go and Node.js corpus probes in client_conformance.feature
L-1 to L-3use_domain_is_served_by_the_client, list_domains_is_served_from_a_domain_list_request, execute_rejects_mixed_client_local_multi_statement_request, execute_rejects_client_local_command_during_transaction, and a_create_subscription_statement_is_sent_as_a_subscribe_request; Implicit multi-command requests are rejected in nspl_transactions.feature
E-1 to E-5In client_wire_failures.feature: A command lost after durable admission is recovered by its request identity, A reclaimed command identity stays expired after a durable restart, A race across leaders recovers a typed execution reference conflict, A command identity outside its retry window starts no effect, A full command history refuses new identities and keeps every retained result, Concurrent exact BEGIN retries join one durable execution, and Reusing a durable transaction identity with different content fails semantically; a_command_reply_for_another_execution_cannot_claim_success
E-6 to E-8Leadership lost after durable admission leaves an unknown outcome that a retry recovers in session_protocol.feature; an_unknown_outcome_is_recovered_with_the_same_execution_reference and replies_ask_for_the_routing_their_disposition_needs
D-1 to D-5A redirect names no endpoint for a leader that discovery cannot reach in session_protocol.feature; The Rust client reconnects through its original seed after the leader stops in client_wire_failures.feature; a_command_redirect_keeps_its_execution_reference, a_command_waits_for_an_election_and_is_sent_again_with_its_reference, a_closed_session_recovers_through_a_configured_seed, and a_reconnected_session_restores_subscriptions_before_it_attaches_its_transaction
X-1 to X-3In client_wire_failures.feature: A command missing from a committed transaction cannot report aggregate success, Replaying a BEGIN whose response was lost returns the original transaction, Replaying an accepted <append_kind> append does not preflight or append it again, and the two exact transaction batch retries; lost_begin_append_and_commit_replies_retry_the_exact_request and concurrent_commands_capture_transaction_position_in_send_order
X-4, X-5A commit fenced to a preview the transaction outgrew is refused and stays open in nspl_transactions.feature; a_commit_fences_against_the_basis_its_own_transaction_reported, a_refused_commit_does_not_adopt_an_unreviewed_basis, and an_older_inspection_cannot_replace_a_newer_queue_preview
X-6A session whose leader lost its binding re-attaches instead of failing and Attaching from a second session takes over an open transaction in nspl_transactions.feature; Web console recovers a reverted command's own outcome after its reply is lost and Web console resolves a Create command held when its transaction finishes during reconnect in nspl_repl.feature; a_detached_transaction_is_attached_again_before_the_command_is_retried
X-7, X-8A clean session close reverts its open transaction and An orphaned transaction expires and retains its outcome in nspl_transactions.feature; A stalled commit cannot block expiry, another domain, or tombstone cleanup in client_wire_failures.feature; Physical inactivity while every node is stopped expires an open transaction in client_wire_process_restart.feature
S-1 to S-6Every scenario of session_subscription_lifecycle.feature and session_subscription_options.feature; Published interest starts, reopens, and stops remote subscription fan-out in subscription_interest.feature; A native client does not restore a subscription the server ended and opens its name again on request in client_wire_failures.feature; Web console ends a relay tab the server ended and resubscribes it on request in nspl_repl.feature; a_subscription_type_must_be_selected_and_supported, a_subscription_the_server_ended_is_not_opened_again_on_a_new_session, an_end_its_session_lost_before_it_was_read_is_still_reported, and, in the console, a_generation_the_server_ended_ends_its_tab_which_keeps_its_rows_and_is_not_restored
S-7, S-8In client_wire_failures.feature: A reconnected native client restores acknowledged subscriptions, A native client deletes a subscription its lost session held and opens the name again, A native client reports a refused subscription restoration and deletes the subscription without the server, and A native client keeps a subscription active while it receives a row that fills most of a frame; Subscription restoration and typed transaction inspection survive the same leader loss in client_wire_qualification.feature; Web console restores a relay tab after its transaction finished while it reconnected, Web console restores a relay tab before it attaches its open transaction again, and Web console bounds a busy relay tab and keeps its REPL responsive in nspl_repl.feature; deleting_while_creation_is_in_flight_drains_its_late_success_before_name_reuse, cancelling_an_in_flight_restore_cleans_up_its_late_success, a_refused_restoration_is_repeated_after_a_growing_delay, deleting_a_subscription_whose_restoration_was_refused_needs_no_server, a_subscription_requested_on_a_closed_session_opens_on_a_new_session, one_subscription_overflow_preserves_other_subscription_events, each_subscription_retains_a_frame_of_the_frame_limit_and_overflows_alone_past_it, a_subscription_past_the_exchange_allowance_overflows_without_evicting_full_subscriptions, and a_row_frame_filled_to_the_frame_limit_reaches_an_active_subscription
R-1 to R-5A <runtime> client round-trips an operation, typed rows, an error and a closure in client_conformance.feature for every runtime; a_batch_round_trips_every_cell_kind_at_its_bounds, cells_must_follow_their_fields, branch_identity_must_match_the_schema, and lists_must_follow_their_element_type_and_length; a_batch_that_does_not_conform_to_its_schema_is_a_protocol_failure in the binding
K-1 to K-5A domain clock attachment reply precedes its frames, a detach reply follows them, and a transaction refuses both in session_protocol.feature; the state, tick, and client pacing outlines in domain_clock_attachment.feature; the owner-loss case in domain_clock_contract.feature; The CLI follows a domain clock across a cluster restart in cli_session.feature; A <runtime> client reads the running domain clock it attached to before its ticks and keeps its generations apart in client_conformance.feature for every binding host; an_attach_answers_once_its_node_has_installed_the_committed_domains, an_ended_exchange_interrupts_its_attachments_until_a_new_exchange_attaches_them, an_attached_clock_is_attached_again_on_a_new_session_and_reports_its_clock, a_refused_clock_restoration_is_repeated_on_the_same_session, a_clock_restoration_answered_already_attached_follows_the_new_session, a_clock_restoration_that_reaches_no_server_is_tried_again_by_the_next_read, an_attachment_moved_before_its_old_session_ends_keeps_the_tick_it_reported, a_detach_sent_while_a_refused_restoration_waits_releases_the_clock_for_good, a_detach_the_old_session_answers_during_a_reconnect_stays_released_on_the_new_session, a_restoration_left_unanswered_past_the_request_timeout_wakes_the_waiting_reader, a_detach_refused_during_a_reconnect_leaves_the_clock_restored_on_the_new_session, a_clock_attached_while_a_reconnect_opens_its_exchange_is_attached_again_on_it, an_attachment_moved_while_its_exchange_holds_it_keeps_the_tick_of_an_unchanged_clock, a_detach_waits_for_the_attaches_decided_before_it_and_holds_off_later_ones, ticks_coalesce_per_domain_and_follow_their_generations_state, the Shuttle check shuttle_a_detach_racing_a_restoration_leaves_no_attachment_of_its_clock, and the server_domain_clock_ticked.nxsm conformance frame
P-1 to P-8Every scenario of client_ingestors.feature and of client_ingestor_process_faults.feature, including the socket loss, full restart, relocation, and forwarding node death cases retaining one producer; a_lost_exchange_leaves_sent_batches_unknown_and_restores_the_producer, a_batch_waiting_for_admission_when_the_session_ends_is_definitely_unsent, a_new_domain_generation_requires_a_new_producer_open, and closing_a_producer_during_restoration_releases_its_late_open in nervix-client-core; every_open_refusal_round_trips, every_submission_outcome_round_trips, producer_events_round_trip_and_name_no_request, and the_largest_submitted_batch_fits_a_frame_and_one_byte_more_does_not in nervix-client-wire; the client_open_ingestor.nxcm, client_submit_batch.nxcm, server_ingestor_opened.nxsm, server_submission_*.nxsm, and server_producer_*.nxsm conformance frames
E-1 to E-6 (emitter consumers)The saturated producer and concurrent consumer and clock restart, generation change, and emitter relocation cases in client_emitters.feature; a_consumer_reports_a_gap_then_reads_through_a_fresh_attachment, an_ended_consumer_attachment_reopens_when_its_contract_is_unchanged, a_changed_consumer_contract_or_generation_requires_a_new_open, temporary_consumer_capacity_refusal_retries_the_same_desired_contract, a_delivery_from_a_lost_exchange_cannot_ack_a_replacement, a_settlement_sent_before_the_session_lost_its_answer_is_unknown, a_read_whose_caller_stopped_waiting_is_taken_over_by_the_next_read, a_close_whose_caller_stopped_waiting_still_releases_the_attachment, and closing_during_restoration_releases_the_late_attachment in nervix-client-core; consumer_replies_round_trip_with_retained_arrow_body and the server_emitter_opened.nxsm conformance frame in nervix-client-wire
U-1 to U-5An upload stream the protocol does not allow is refused with a typed failure and admits nothing in session_protocol.feature; in resource_describe.feature, An incomplete upload does not admit content or consume its identity, Upload retry reports one assigned version, Upload retry after leader change reports the assigned version, and An uncertain upload completes once across installation and leader change; a_lost_upload_reply_retries_with_the_same_identity_and_archive and malformed_upload_replies_are_rejected_by_their_correlations
A-1 to A-6Every scenario of backup.feature, including A client that loses its download fetches the archive again until a download collects it, An archive is refused once its execution reference's retry validity ends, and Downloads of another user's backup, or under a reference without an archive, are refused; the console’s A console backup downloads a verified archive whose manifest lists the backed-up domains and A console backup download interrupted by a page reload resumes after the reload in backup_restore.feature; every_download_frame_round_trips and a_download_request_with_an_invalid_reference_is_refused in nervix-client-wire; the backup_download_* conformance frames
B-1 to B-7The binding tests of nervix-client-ffi, such as retained_references_keep_the_frame_until_the_last_one_is_released, string_and_bytes_columns_are_copied_with_offsets_and_borrowed_per_cell, every_event_kind_reports_its_subscription_and_count, and a_token_bounds_a_call_by_cancellation_and_by_deadline; the C, C++, Python, Java, and Ruby cases of client_conformance.feature
B-8a_backup_outcome_reports_its_archive and client_errors_are_classified_and_keep_their_causes in nervix-client-ffi
B-9a_retained_clock_event_outlives_a_reference_released_on_another_thread, a_session_reads_every_clock_event_kind_and_bounds_its_wait, and a_restoration_failure_names_its_domain_and_carries_no_clock in nervix-client-ffi; the clock cases of client_conformance.feature for C, C++, Python, Java, and Ruby
B-11a_clock_attached_after_start_is_read_before_its_first_tick, every_state_reports_only_the_fields_and_projections_it_carries, a_refused_attach_leaves_the_session_following_what_it_followed, an_attach_cancelled_after_it_was_sent_is_resolved_by_executing_it_again, several_domains_are_read_apart_until_a_detach_or_an_end_withdraws_one, an_interrupted_attachment_reads_its_last_clock_without_a_tick_until_it_is_restored, and a_domain_clock_outlives_references_released_on_another_thread_and_its_session in nervix-client-ffi; the clock cases of client_conformance.feature for the C ABI in process, C, C++, Python, Java, and Ruby
B-12 to B-16A <runtime> client publishes typed batches through a client ingestor and acknowledges their output through a client emitter in client_conformance.feature for the C ABI in process, C, C++, Python, Java, and Ruby; a_producer_reports_its_description_and_takes_every_outcome_class, a_refused_open_names_its_refusal_and_a_host_argument_is_checked_first, a_consumer_reads_and_settles_a_delivery_and_a_lost_confirmation_is_uncertain, a_reconnect_interrupts_a_consumer_expires_its_deliveries_and_restores_only_open_handles, every_kind_of_column_reads_back_from_the_batch_and_from_its_stream, a_builder_refuses_what_a_column_cannot_hold, and bolero_host_columns_round_trip_through_builder_and_stream in nervix-client-ffi
P-1 to P-5, B-10Every scenario of restore.feature, including An interrupted restore upload is sent again under its execution reference, A restore repeated under its execution reference joins it or returns its recorded outcome, and A leader change while a restore applies resumes it on the new leader; the console’s restore scenarios in backup_restore.feature; a_restore_start_round_trips, every_restore_chunk_round_trips_and_an_empty_one_is_refused, and every_restore_reply_round_trips in nervix-client-wire; the restore_* conformance frames; a_restore_outcome_reports_its_steps in nervix-client-ffi

The client representation properties add complete generated-value evidence for F-1 to F-7, R-1 to R-5 and B-1 to B-7. They preserve integer widths, float bits, nanosecond timestamps, schema metadata, cell validity, nested lists, branch and subscription identities, and retained bytes. Opaque JSON and connector payloads are compared as exact text or bytes. The malformed-request scenario also checks both completion page bounds and then sends a valid completion and command on the same session.

Executable Examples

The conformance probes are complete, runnable clients, and each prints the same report as every other probe. They are qualification clients rather than supported SDKs. The Go and TypeScript probes each keep one request in flight and run no persistent command, so they use execution references that are not UUIDv7, which E-1 allows only for reads.

ProbeShows
tests/client_conformance/go/probe.goA native gRPC client: a pass-through frame codec, Basic authentication metadata, correlation by request identity, following a redirect’s grpc_uri with the same execution reference, and failing on OutcomeUnknown
tests/client_conformance/node/probe.tsA WebSocket client for Node.js and Bun: one binary message per frame, BigInt for every 64-bit value, float bits read from the buffer, and following a redirect’s web_console_uri
tests/client_conformance/go/corpus.go, probe.ts corpus <dir>Reading every corpus frame without a FlatBuffers verifier, checking identifiers, discriminants, and required values
tests/client_conformance/c/probe.c, cpp/probe.cppThe binding from C and from C++ with std::unique_ptr ownership
tests/client_conformance/python/probe.pyThe binding through ctypes, with a memoryview over a retained frame
tests/client_conformance/java/Probe.javaThe binding through the Foreign Function and Memory API, with arena-owned events
tests/client_conformance/ruby/probe.rbThe binding through Fiddle, with collector-driven release

Run with the clock argument, each binding probe follows a paced domain clock instead: it attaches to a running clock, reads the clock the attach reported and its projections before it uses a tick, follows a STOP and START into the next generation and the attachment the binding restores after the scenario cuts its session, and detaches. Run with the io argument, it opens a producer and a consumer, builds typed batches column by column, reads their output one level at a time, and retries, rejects, and acknowledges it across a session the scenario cuts.

The Paced Simulation Drivers are runnable applications rather than qualification clients. examples/paced-simulation/python/paced_simulation.py follows B-11 through the binding: it reads the attached clock before it submits anything, waits with nx_domain_clock_wall_duration_until, stamps its readings within nx_domain_clock_admission_window, pauses from NX_CLOCK_EVENT_INTERRUPTED until the next NX_CLOCK_EVENT_STATE, and runs its consumers beside its producer on one session, as emitter consumer rule E-6 allows. The Rust driver beside it does the same with the Rust client. Both drivers explicitly reopen changed contracts within the current START generation, retaining the original handle for outstanding producer outcomes. Only domain_stopped and generation_changed wait for a later generation. Removed endpoints, incompatible fields, and unusable consumer credit produce configuration errors; no unknown submission is resent.

just test-client-conformance builds every probe and runs it against one- and three-node clusters; tests/client-conformance-ledger.md records the runtimes, the build commands, and what each probe checks. The core of the TypeScript client’s exchange shows C-1 to C-3 in a few lines:

async request(kind: wire.ClientRequest, build: Build): Promise<wire.Reply> {
  this.nextId += 1n;
  const id = this.nextId;
  const builder = new flatbuffers.Builder(256);
  const body = build(builder);
  wire.ClientMessage.startClientMessage(builder);
  wire.ClientMessage.addRequestId(builder, id);
  wire.ClientMessage.addRequestType(builder, kind);
  wire.ClientMessage.addRequest(builder, body);
  builder.finish(wire.ClientMessage.endClientMessage(builder), 'NXCM');
  this.socket.send(builder.asUint8Array());
  for (;;) {
    const message = await this.receive();
    if (message.bodyType() !== wire.ServerBody.Reply) {
      this.pending.push(message);
      continue;
    }
    const reply = member(message.body(new wire.Reply()) as wire.Reply | null);
    if (reply.requestId() !== id) {
      throw new Error(`a reply names request ${reply.requestId()} while ${id} is in flight`);
    }
    return reply;
  }
}

Because the probe keeps one request in flight and its replies are small, it treats a reply for any other identity, and any transfer part, as an error. A client with several requests in flight routes such a reply to the waiter its identity names, as C-3 requires, and reassembles transfer parts as F-7 requires.

What A Client Must Not Promise

A client built on this protocol MUST NOT tell its users that:

  • a subscription delivers every row, delivers a row exactly once, or can be resumed from a position;
  • a failed transaction, a cancelled command, or a lost session rolled anything back;
  • a cancelled or timed-out command did nothing, unless the server reported it cancelled before admission;
  • an uncertain command failed, or succeeded, before its own outcome was recovered;
  • a subscription’s rows arrive in Arrow, or in any encoding other than typed Row frames, or that a columnar form of them exists;
  • releasing, reading, or decoding a delivery acknowledged it, or that an acknowledgement whose answer was lost was not applied;
  • a session, its subscriptions, its clock attachments, or its producers survive the loss of its connection;
  • a submitted batch was delivered exactly once, or that a batch whose outcome is unknown or failed had no effect;
  • a backup’s archive can be downloaded more than once, or survives a restart of the node that assembled it;
  • a restore whose outcome is unknown changed nothing, or that a restore which failed at a step undid the steps before it.

Subscription Clock Lifetime

A filtered subscription reads the currently installed domain clock through its retained lifecycle, so opening before START does not prevent later filtering. Clients continue to handle domain-time-unavailable skipped-row notices and generation termination using the existing subscription protocol. Internal clock and metric handle retention introduces no additional client request, capability negotiation or recovery step.

Examples

Runnable end-to-end graphs live under examples/:

  • examples/iot/iot.nspl implements MQTT smart-factory telemetry with branch-local quality gates, repartitioning, ordering, and alert emission.
  • examples/nats-factory-windows/nats_factory_windows.nspl demonstrates NATS ingestion and route-local window aggregate construction.
  • examples/datalake/datalake.nspl combines MQTT CBOR, NATS Protobuf, Kafka JSON, correlation, materialized lookup state, Rust WASM enrichment, Kafka/Redis output, and Iceberg tables.
  • examples/wasm-processors/wasm-dual.nspl wires Rust and Go guests into set-only multi-route WASM processors.
  • examples/paced-simulation/paced_simulation.nspl is the graph of two runnable applications, a Rust driver and a Python driver over the shared C binding, that pace a sensor simulation by the domain clock through client ingestors and attached client emitters. Paced Simulation Drivers describes the graph and how to run both programs.

The Docker Compose setup explicitly provisions the external bucket, catalog namespace, and tables used by the datalake graph. Nervix never creates those external entities during node startup.

Kafka ingestion with route-local branch construction

CREATE SCHEMA user_id_branch (
  user_id U32
);

CREATE BRANCH by_user SCHEMA user_id_branch TTL 5m;

CREATE SCHEMA notification (
  user_id U32,
  created_at DATETIME,
  payload STRING OPTIONAL
);

CREATE WIRE JSON SCHEMA notification_wire MODE STRICT (
  user_id integer,
  created_at string,
  payload string OPTIONAL
);

CREATE CODEC notification_codec
  FROM WIRE JSON SCHEMA notification_wire
  TO SCHEMA notification
  ENCODE created_at AS RFC3339;

CREATE RELAY notifications
  SCHEMA notification
  BRANCHED BY by_user;

CREATE CLIENT kafka_main
  TYPE KAFKA
  CONFIG {
    'bootstrap.servers' = 'localhost:9092'
  };

CREATE KAFKA TOPIC INGESTOR kafka_notifications
  FROM kafka_main
  TOPIC 'notifications'
  OFFSET BY CONSUMER GROUP 'nervix_consumer'
  MODE ACK SEQUENTIAL ACK TIMEOUT 30s RETRY POLICY BACKOFF 200ms MAX 5s
  ON QUIESCE SUSPEND DECODE USING notification_codec
  TO notifications
    INHERIT ALL
    BRANCHED BY by_user SET user_id = message.user_id
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The route output is explicitly inherited from the decoded input. The branch key is constructed only after output finalization and route filtering.

Transforming projection

CREATE DEDUPLICATOR project_notifications
  FROM notifications WHERE input.active
  DEDUPLICATE ON input.tenant, input.event_id
  MAX TIME 10m
  BRANCHED BY by_tenant
  TO projected_notifications
    INHERIT ALL EXCEPT raw, active
    SET amount = amount + 1,
        normalized = lower(trim(input.raw))
    WHERE output.amount > 0
    FLUSH IMMEDIATE
    ON MESSAGE ERROR SEND TO processing_errors
      SET error_reference = error.reference,
          error_code = error.code,
          source_id = input.event_id,
          attempted_amount = partial_output.amount;

INHERIT and SET belong to this route only. Assignments run left to right. input.raw is the original record, while output.amount is the finalized route value.

ONNX inference

Inferencers keep explicit tensor declarations and name the resource version they load. Their routes are set-only and do not inherit the source row:

CREATE INFERENCER score_message
  FROM input_features
  USING RESOURCE inference VERSION 1
  FILE 'score.onnx'
  INPUTS {
    "features" DENSE TENSOR<F32>[128] = input.features
  }
  OUTPUT SCHEMA {
    "scores" DENSE TENSOR<F32>[10]
  }
  BRANCHED BY by_tenant
  TO scored_output
    SET tenant = branch.tenant,
        scores = scores
    FLUSH IMMEDIATE
    ON MESSAGE ERROR LOG;

The INPUTS mapping may read the original row. Route construction sees immutable generated model output, the branch, and declared materialized state, but not input or message.

Window construction

CREATE WINDOW PROCESSOR device_summary
  FROM device_measurements
  FILTER WHERE input.value >= 0.0
  WIDTH 5m DURATION
  STEP 1m DURATION
  BRANCHED BY by_device
  TO device_windows
    SET device_id = branch.device_id,
        samples = COUNT(input.value),
        minimum = MIN(input.value),
        maximum = MAX(input.value),
        average = AVG(input.value)
    WHERE output.samples > 0
    ON MESSAGE ERROR LOG;

Windows have no AGGREGATE or FLUSH clause. input is legal only inside aggregate arguments, though aggregate calls may participate in larger scalar expressions.

Generate from materialized state

CREATE GENERATOR synth_notifications
  USING MATERIALIZED STATE notification_templates
  EACH 100ms
  BRANCHED BY by_tenant
  TO generated_notifications
    SET tenant = branch.tenant,
        user_id = relay_state.notification_templates.user_id,
        amount = relay_state.notification_templates.amount
    FLUSH EACH 1s MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG;

A generator declares exactly one materialized relay. Every concrete materialized branch starts an independent task, and every route sees the same immutable state snapshot for a tick.

Codec emission with staged headers

CREATE EMITTER kafka_notifications
  FROM notifications
  TO KAFKA kafka_main TOPIC notifications_out
    MODE ACK PARALLEL MAX 1000 ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING notification_codec
  INHERIT ALL EXCEPT secret
  SET secret = leak_sensitive(input.secret)
  WHERE output.active
  INVOKE write_header('tenant', input.tenant),
         write_header('trace-id', input.trace_id)
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

Header calls execute in order only after payload construction and filtering. Payload and header publication are all-or-nothing for a route attempt.

Paced Simulation Drivers

examples/paced-simulation holds two runnable applications that drive a paced simulation through a Nervix graph. Each one follows the domain clock, submits sensor readings stamped with the tick centers that clock reaches through a client ingestor, and applies and acknowledges the readings the graph constructs through client emitters, all on one session:

  • nervix-paced-simulation, in examples/paced-simulation/rust, is built on the Rust Client Library.
  • examples/paced-simulation/python/paced_simulation.py is built on the shared C binding, through ctypes and the Python standard library alone.

Both take the same options, print the same report and write the same ledger and effect store, so either one can continue a run the other began. They are complete programs meant to be copied: the public scenarios in tests/features/runtime/paced_simulation.feature run them as published against the published graph, on one node and on three.

The Client Ingestors And Emitters architecture chapter explains the endpoint ownership, ACK, credit, ALTER and recovery contracts these programs exercise.

The Graph

examples/paced-simulation/paced_simulation.nspl creates the paced domain paced_simulation with a 100 ms tick period and an equal admission skew, and this graph in it:

EntityRole
simulated_readingsClient ingestor of reading with TIMESTAMP AT occurred_at
live_readingsClient ingestor of the same schema with TIMESTAMP NOW, for comparison
readingsRelay of observed_reading, branched by sensor
rejected_readingsUnbranched relay of the rejection notices the ingestors’ error routes publish
observed_readingsAttached client emitter of the constructed readings
rejection_noticesAttached client emitter of the rejection notices

Each ingestor route keeps the reading, adds the domain time it was admitted at, and constructs one concrete branch per sensor. A reading the ingestor cannot admit becomes a rejection notice instead of an error in a log:

CREATE CLIENT PRODUCER INGESTOR simulated_readings
  FROM SCHEMA reading MODE ACK PARALLEL MAX 8 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 2s ON QUIESCE SUSPEND
  TIMESTAMP AT occurred_at
  TO readings
    INHERIT ALL
    SET admitted_at = now(),
        timestamp_source = 'at'
    BRANCHED BY by_sensor SET sensor = message.sensor
    FLUSH EACH 100ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR SEND TO rejected_readings SET reading_id = input.reading_id, occurred_at = input.occurred_at, error_code = error.code, error_message = error.message
  ON GENERAL ERROR LOG;

The output emitter is attached, so a reading’s batch completes for its producer only once a consumer acknowledged the reading’s output. Its batches never mix sensors, because a client batch never combines concrete branches:

CREATE ATTACHED EMITTER observed_readings
  FROM readings
  TO CLIENT SCHEMA observed_reading
    MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s RETRY POLICY BACKOFF 100ms MAX 2s
  INHERIT ALL
  BATCH MAX MESSAGES 256 MAX SIZE 256KiB
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The exported schema carries the reading’s own event time, occurred_at, and the domain time the ingestor admitted it at, admitted_at. Clock observations and deliveries are separate streams with no order between them, so an application that needs an output’s event time reads it from the output.

The graph needs nothing outside Nervix. Its file includes separate domain bootstrap and USE phases. Create the domain, load the transaction beginning at BEGIN with the CLI’s domain selection, then start the clock at the pace the simulation should run at:

nervix-cli --command "CREATE PACED DOMAIN paced_simulation WITH PERIOD 100ms SKEW 100ms;"
nervix-cli --domain paced_simulation --command \
  "$(sed -n '/^BEGIN;/,$p' examples/paced-simulation/paced_simulation.nspl)"
nervix-cli --domain paced_simulation --command "START AT NOW TIME RATE 4.0;"

Check the printed command dispositions: ordinary --command currently prints a refusal without a nonzero exit status. USE must run on its own when entered as an NSPL command.

Running The Drivers

The Rust driver:

just paced-simulation --server http://127.0.0.1:47391 --username default \
  --password "$NERVIX_PASSWORD" --ticks 100 --sensors 3

just paced-simulation builds and runs the release binary, which is the same as cargo run --release --package nervix-paced-simulation -- <options>.

The Python driver loads the shared binding named by --library or NERVIX_CLIENT_LIBRARY, and needs Python 3.12 or later. just paced-simulation-python builds the binding and runs the driver with it:

just paced-simulation-python --server http://127.0.0.1:47391 --username default \
  --password "$NERVIX_PASSWORD" --ticks 100 --sensors 3

Any live node serves either driver; the node routes producers and consumers to the nodes that execute the endpoints, so the session’s node need not execute any of them. The credentials can come from NERVIX_USERNAME and NERVIX_PASSWORD instead.

OptionDefaultMeaning
--serverhttp://127.0.0.1:47391The gRPC session endpoint of any live node
--username, --passwordNERVIX_USERNAME, NERVIX_PASSWORDThe session’s credentials
--domainpaced_simulationThe paced domain the graph runs in
--timestampsatat submits to simulated_readings, now to live_readings
--ingestor, --emitter, --rejectionsthe example’sThe endpoints, when a graph names them differently
--ticks50How many tick centers to submit readings for; 0 only replays
--sensors3Sensors reporting at each tick center; each is a concrete branch
--burst1Readings each sensor reports at each tick center
--invalid-every0Stamp the first reading of every Nth tick before the admission window
--consumers1Competing consumers of the output emitter
--consumer-delay0sHow long the output consumers wait before they attach
--consumer-leave-afternoneThe last output consumer leaves after this many deliveries
--processing-time0sApplication work per delivery, before its effect and its ACK
--credit-batches, --credit-bytes8, 1MiBThe producer credit to ask for
--inspect-everynoneInspect the ingestor and the output emitter at this interval
--ledgerpaced-simulation-ledger.jsonlThe application-owned input ledger
--effectspaced-simulation-effects.jsonlThe idempotent effect store
--replayoffFirst resubmit the ledger’s readings that did not complete
--follow-generationsoffAfter STOP and START, continue in the new generation
--deadline10mHow long to wait for outstanding outcomes once submitting stops

Durations are written like NSPL durations, such as 250ms, 1s or 5m, and byte sizes as a count of bytes, KiB, MiB or GiB.

Choosing The Pace

Three settings decide how fast a simulation runs and how much it produces:

  • START AT NOW TIME RATE <rate> sets how fast domain time runs against wall time: 0.5 runs the simulation at half real time and 4.0 at four times real time. A driver submits each tick’s readings once the clock reaches that tick center, so the rate paces the drivers too.
  • The domain’s PERIOD spaces the tick centers, and so the readings of one sensor.
  • --sensors and --burst set how many readings each tick center produces: sensors × burst readings per period of logical time, in one batch.

Slow application processing does not slow domain time. A driver that falls behind submits the centers it missed as soon as it can, stamped with their own times, and the ingestor admits them as long as its window still retains them: the newest 256 reached centers. A center the window no longer retains is skipped with a SKIPPED line instead of being submitted only to be rejected.

What A Run Does

  1. It opens one session and attaches it to the domain clock: the Rust driver through Client::attach_domain_clock, the Python driver by executing ATTACH DOMAIN CLOCK. The clock the attach reports decides whether the run can start: a stopped clock, an unpaced domain or a clock the node has not installed within 30 seconds ends the run with exit status 2.
  2. It opens the rejection consumer and the output consumers before the producer, because a reading completes only once a consumer acknowledged its output: a run that waited for its outcomes before consuming would wait on itself. An endpoint that is not running on its node yet is asked again for up to 30 seconds; any other refusal ends the run with exit status 2.
  3. For each tick it reads the latest clock the session observed, waits until domain time reaches the next tick center, and stamps every reading of the tick with that center. It waits until the batch fits in the producer’s credit, then reads the window the ingestor admits event times in again, so a batch held back for credit is never checked against a window that has moved on. It writes the readings to the ledger, submits them as one batch, and awaits the batch’s outcome without holding up the next tick.
  4. Each consumer decodes a delivery, records its readings in the effect store, and only then acknowledges it.
  5. Once it submitted its last tick it waits for the outcomes it still awaits, and for the rejection notices of its completed batches, up to --deadline; closes its producer and consumers; detaches the clock; and prints its summary.

The drivers take the tick centers from the committed logical origin and PERIOD, one center per period. Every wait, current logical instant and admission check comes from the attached clock’s projections: wall_duration_until, logical_time_at and admission_window in Rust, and nx_domain_clock_wall_duration_until, nx_domain_clock_logical_time_at, nx_domain_clock_admission_window and nx_domain_clock_admits through the binding. FLUSH EACH and the admission window follow domain time, while ACK TIMEOUT, retry backoff, session liveness and the drivers’ own deadlines are physical.

The Report

Both drivers print one line per observation on standard output, each line whole, and the same lines for the same run. Errors go to standard error, prefixed with error:.

LinePrinted when
CONNECTED server=… domain=…The session opened
CLOCK generation=… state=paced period=… skew=… origin=… anchor=… rate=…The attach, or a later observation, reported a paced clock
CLOCK generation=… state=stopped|uninstalled|unpacedThe clock is in another state
CLOCK restoration_failed, CLOCK unavailable, CLOCK endedRestoring the attachment failed for now, the session could not reopen, or the domain was removed
INTERRUPTED clock domain=…The session holding the clock attachment ended
CONSUMER opened consumer=… emitter=… generation=… batches=… bytes=…A consumer opened, with its granted credit
PRODUCER opened ingestor=… generation=… batches=… bytes=… max_batch_rows=… max_batch_bytes=…The producer opened, with its granted credit
READY generation=…The run starts submitting
SUBMIT tick=… occurred_at=… window=…..…The clock reached a tick center, inside that admission window
WAITING credit outstanding_batches=… outstanding_bytes=…The next batch waits for the producer’s credit
SKIPPED tick=… reason=behind_admission_windowThe window no longer retains a tick center the run fell behind
SUBMITTED tick=… readings=… bytes=…The producer holds the batch
OUTCOME tick=… readings=… completed|not_admitted|processing_failed|outcome_unknown [cause]The batch’s terminal outcome
PROCESSING, DELIVERY … applied=… duplicates=…, ACK … confirmedA consumer handled a delivery
REJECTED reading_id=… occurred_at=… error_code=… error_message=…A rejection notice arrived
INTERRUPTED consumer=…, CONSUMER reopen_required|reopened|left|unavailable …A consumer’s attachment changed
CONSUMER delayed, PRODUCER unavailable, … close_failed, CLOCK detach_failedA consumer waits to join, the session could not be restored for a submission yet, or closing failed
REOPENED generation=… ingestor=… reason=…The producer accepted a changed contract within the current START generation
REOPENED generation=… ingestor=… after=…The run moved on to a new START generation
REPLAY tick=… readings=…, REPLAY expired|skipped …A replay resubmitted, or could not resubmit, ledger readings
INSPECT …An inspection of the ingestor and the output emitter
WAITING notices outstanding=…, NOTICES missing=…The run waits for rejection notices of completed batches
STOPPING reason=…The run stops submitting
SUMMARY …The run ended

The summary counts readings submitted, the readings of each outcome, the effects and rejection_notices the consumers recorded for the first time, duplicates of either that the store already held, the START generations the run planned in, ticks_observed, inspections, credit_waits, the peak_outstanding_bytes the producer held, and the output consumers_joined and consumers_left. The exit status says how the run ended:

StatusMeaning
0Every reading the run submitted completed
3A reading did not complete: it was not admitted, its processing failed, or its outcome is unknown
2The command line, the graph or the domain is not one the simulation can run against
1The run failed on its own side, or outcomes were still outstanding at --deadline

Observing A Run

--inspect-every runs SHOW INGESTORS and DESCRIBE EMITTER on the driver’s own session while it produces and consumes, and prints the ingestor’s admission, producers, outstanding batches and bytes, and the emitter’s consumers and retained batches:

INSPECT simulated_readings from=CLIENT PRODUCER schema=reading owner=node-2 status=running admission=open producers=1 outstanding_batches=1 outstanding_bytes=110592 admitted_batches=1
INSPECT emitter=observed_readings consumers=1 retained_batches=2

The same commands, DESCRIBE INGESTOR simulated_readings and nervix-cli domain-clock, work from any other session; inspecting an emitter claims and acknowledges nothing. The ledger and the effect store are ordinary JSON-lines files the application owns:

{"record":"reading","reading_id":"g1-t12-s0-0","generation":1,"tick":12,"sensor":"sensor-0","occurred_at":"2026-10-02T18:00:01.200Z","value":372,"ingestor":"simulated_readings","timestamps":"at","stamp":"center"}
{"record":"outcome","reading_ids":["g1-t12-s0-0","g1-t12-s1-0"],"outcome":"completed","cause":""}
{"kind":"reading","reading_id":"g1-t12-s0-0","sensor":"sensor-0","tick":12,"occurred_at":"2026-10-02T18:00:01.200Z","admitted_at":"2026-10-02T18:00:01.230Z","timestamp_source":"at","value":372,"generation":1}

A reading’s identity names the START generation, tick, sensor and burst position it was planned for, so it stays the same when the reading is replayed.

Event Time: TIMESTAMP AT And TIMESTAMP NOW

--invalid-every 3 stamps the first reading of every third tick one nanosecond further than the skew before the oldest center the window retains. Submitted to simulated_readings, which reads TIMESTAMP AT occurred_at, that reading is rejected: its batch still completes, because the error route acknowledges the reading once its notice is published, and the rejection consumer records the notice with code validation. Submitted to live_readings with --timestamps now, the same reading is admitted, stamped with the domain time it arrived at, and its occurred_at travels through unchanged.

TIMESTAMP NOW is still tested against the window: the arrival time lies less than one period after the newest reached center, and it is admitted within the skew of that center. The example’s skew equals its period, so every arrival is admitted. With a skew smaller than the period, arrivals that fall between two windows are rejected like any other event time outside them.

Interruptions, Outcomes And Replay

The clock and the data endpoints recover independently, and neither waits for the other:

  • When the session ends, the clock attachment reports INTERRUPTED clock and the driver stops planning: the clock it read before the gap no longer says which event times the ingestor admits. It continues once the restored attachment reports the clock again. The producer and the consumers restore their own attachments when the domain’s START generation and their endpoint contracts are unchanged, and a consumer reports INTERRUPTED consumer once.
  • A batch whose session ended before its outcome arrived ends outcome_unknown session_lost. The drivers never resend it on their own: it may have been admitted and processed. The run exits with status 3.
  • --replay resubmits, before the run simulates anything, every ledger reading whose latest outcome is not completed, with its identity and content unchanged, when it belongs to the START generation the domain runs now and the window still admits its event time. A reading of another generation is never submitted against this one.
  • A replayed reading may already have been applied: its output may have been delivered before the outcome was lost, or redelivered after the session came back. The effect store is keyed by the reading’s identity, so the consumer acknowledges such a delivery as a duplicate without applying it again. Processing is at least once; each effect happens once because the application makes it idempotent, not because delivery is exactly once.
  • A delivery whose acknowledgement was lost comes again with the same identity and a fresh reference, and is recorded as a duplicate.
  • When an endpoint requires a new open because its contract or schema changed, it was removed, it violated the protocol, or restoration was refused, the driver opens a fresh producer or consumer immediately in the current START generation. Consumers print CONSUMER reopen_required and CONSUMER reopened; the producer prints REOPENED with the unchanged generation and the reason. An endpoint unavailable refusal is retried within the existing 30-second physical open budget. A flush-only change preserves the contract and requires no application reopen. Clock and credit waits recheck the endpoint every 200ms, so a slow domain rate does not defer reopening until the next tick. Suspended admission pauses planning before it creates readings. An alteration drains admitted work before it publishes the changed contract. The example keeps its buffered FLUSH EACH 100ms branched input route: an intake hold force-flushes partial batches through the open shared relay and waits for their ACK roots before the full alteration hold closes the relay gates. Admission stays suspended across both holds. An unsuccessful alteration keeps the committed contract and requires no reopen.
  • Each replacement validates the example’s exact fields again and requests the same credit limits. A schema mismatch, removed endpoint, or unusable credit limit ends the run with status 2 and the endpoint’s refusal text. A fresh producer has fresh credit; outstanding submissions retain their original producer and credit until their outcomes resolve. Nothing with an unknown outcome is resent. A tick already planned when its producer ended is recorded as not_admitted not_sent if it never left the client, or with the node’s refusal if it reached the node. These readings can be resubmitted only by an explicit --replay.
  • STOP ends the producer and the consumers with the generation; their batches that had not completed end not admitted or of unknown outcome. With --follow-generations the driver waits for the next START, opens a new producer and new consumers under the new generation, and continues from that generation’s reached tick center. Without it the run finishes. The domain_stopped and generation_changed reopen reasons wait for this next generation. Once the clock reports a later paced generation, the explicit new producer also retries domain stopped within the same 30-second open budget: the serving node can report that clock before its endpoint-open view has applied the corresponding START. Both drivers still require the opened producer’s own generation to have a paced clock before planning any readings. Opens in the current generation retain their immediate domain stopped refusal, and schema, missing-endpoint and other terminal refusals remain terminal while following a generation.
  • With --consumer-delay the output emitter has no consumer at first: it retains the batches it could not deliver, which DESCRIBE EMITTER shows, and the producer stops being granted credit once its outstanding batches fill it. The backlog is bounded by that credit. Consumers that join drain it with explicit acknowledgements, and one that leaves with --consumer-leave-after hands the rest to the others.

Stopping

Ctrl-C stops the planning. A batch still waiting for credit has sent nothing and ends not admitted with the cause not_sent; the driver then waits for the outcomes it awaits, closes its endpoints, detaches the clock and prints its summary, as at the end of every run.

Limits

The ledger and the effect store are files the example flushes but does not make durable; an application that needs its input to survive a crash keeps a durable outbox. Nervix persists neither payloads nor delivery state: an owner lost while it held a batch loses it, and only a replay from the application’s own ledger recovers it. A rejection notice the run did not receive before --deadline is delivered to the next consumer of the rejection emitter, which records it then.

Rust WASM Guest SDK

The nervix-wasm-sdk crate is the supported way to write Nervix WASM processor guests in Rust. A guest implements the Processor trait for one branch-local type and registers it with export_processor!; the SDK owns the complete guest side of the WASM processor ABI: the reusable linear-memory buffer, FlatBuffers envelope encoding and decoding, the emit queue, the global-error channel, panic conversion, error-state latching, and guest snapshot plumbing. Processor code only sees typed inputs, typed outputs, and the branch configuration.

Use this chapter to write a Rust guest. The WASM Processor Guests chapter remains the authoritative wire contract the SDK implements and the reference for guests written in other languages.

Installing The SDK

The SDK is not published to crates.io yet. Install it as a git dependency from the Nervix repository and pin a revision so builds stay reproducible:

[package]
edition = "2024"
name = "my-wasm-guest"
version = "0.1.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
arrow-array = "58"
error-stack = "0.5"
nervix-wasm-sdk = { git = "https://github.com/nervix-io/nervix", rev = "<commit>" }

[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true

Guests are native wasm32-unknown-unknown modules without WASI:

rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release

The finished module is target/wasm32-unknown-unknown/release/my_wasm_guest.wasm.

A Minimal Guest

The SDK’s representation properties exercise all current scalar and nested Arrow types, null validity and float bits, retained input buffers, shared output columns and ordered emissions. Snapshot properties preserve raw application bytes, accept a domain rename with the same branch contract, and reject another branch configuration before calling application restore. They run through the production SDK APIs in both ordinary Bolero tests and registered sanitizer targets; the WASM representation register states the projections and verification boundaries.

This guest passes every input field through unchanged and appends one generated bucket column. It assumes the destination schema lists the input fields first and a required bucket STRING field last:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use arrow_array::{Array, ArrayRef, Int32Array, StringArray};
use error_stack::{Report, Result};
use nervix_wasm_sdk::{
    BranchContext, GuestContext, GuestError, InputBatch, OutputColumnRef, OutputEnvelope,
    Processor,
};

struct SeverityBucketer;

impl Processor for SeverityBucketer {
    fn create(branch: &BranchContext) -> Result<Self, GuestError> {
        if branch.output_schemas().len() != 1 {
            return Err(GuestError::failed(
                "severity_bucketer expects exactly one TO relay",
            ));
        }
        Ok(Self)
    }

    fn process_batch(
        &mut self,
        ctx: &mut GuestContext<'_>,
        input: InputBatch,
    ) -> Result<(), GuestError> {
        let mut buckets = Vec::new();
        for batch in input.batches() {
            let values = batch
                .column(0)
                .as_any()
                .downcast_ref::<Int32Array>()
                .ok_or_else(|| GuestError::failed("input column 0 must be a required I32"))?;
            for row in 0..values.len() {
                buckets.push(if values.value(row) >= 100 { "HIGH" } else { "LOW" });
            }
        }

        let mut output = OutputEnvelope::new();
        let bucket = output.add_generated_column(
            Arc::new(StringArray::from(buckets)) as ArrayRef,
            false,
        );
        let input_fields = u32::try_from(ctx.branch().input_schema().fields.len())
            .map_err(|_| Report::new(GuestError::InvalidSize))?;
        let mut columns = (0..input_fields)
            .map(|column_index| OutputColumnRef::Input { column_index })
            .collect::<Vec<_>>();
        columns.push(OutputColumnRef::Generated {
            column_index: bucket,
        });
        let relay = ctx.branch().output_schemas()[0].name.clone();
        output.add_route(relay, columns, input.acks().clone());
        ctx.emit(output)
    }
}

nervix_wasm_sdk::export_processor!(SeverityBucketer);
}

Reusing input.acks().clone() preserves the complete host row sidecar: every output row keeps its tokens and source_token, so input-column references and route expressions such as input.field keep working, and pre-existing terminal decisions pass through unchanged.

Lifecycle

The host creates one guest instance per concrete branch. The SDK maps the ABI callbacks onto the trait:

Trait methodWhen the host calls it
createBranch initialization, with the decoded BranchInit payload as a BranchContext: domain, the concrete branch key, which is absent for the unbranched instance of an unbranched processor, and the exact input and output schemas.
process_batchOne input envelope, decoded into an InputBatch: Arrow record batches and the ACK sidecar.
on_timeoutA previously requested domain-clock timeout fired.
flushThe runtime is quiescing this branch: for an entity or domain pause, an ownership move, a coordinated reset, or a shutdown. Emit everything the processor still buffers; whatever it keeps stays unacknowledged until the branch resumes.
save_stateAfter every process_batch and on_timeout call, whether it succeeded or failed, unless it exhausted a limit or requested a new state lifetime; after a flush that emitted output; and when the host checkpoints the branch for an ownership handoff. Return the processor’s durable computation state, or an error when it cannot be serialized.
restoreThe host recreates the branch instance, for example after a restart or on the node a branch moved to, and hands it the application state of the checkpoint the branch continues from.

Keep all state branch-local. Never aggregate across branch keys inside one guest, and reject init payloads whose schemas the guest does not implement instead of accepting them silently.

Building Output

OutputEnvelope builds one output group:

  • add_generated_column(array, optional) places one immutable Arrow array in the common generated pool and returns the index used by OutputColumnRef::Generated references. The SDK encodes the pool with the required empty field names; optional must match the nullability of every destination field that references the column. Several routes, or several fields in one route, may reference the same index.
  • add_route(relay, columns, acks) adds one routed output. columns align positionally with the destination relay schema: OutputColumnRef::Input { column_index } references an unchanged input column without re-serializing its values, Generated references the pool, and Uninitialized leaves an optional destination field to materialize as typed NULLs.
  • GuestContext::emit encodes the envelope and queues it; the host collects every queued envelope after the callback returns. Routes that need different generated row counts or ordering belong in separate emitted envelopes.

Row lineage follows the ABI contract: preserve the complete row sidecar for rows you keep, put dropped rows’ tokens in acked, failed rows in nacked with a reason, and rows for the processor’s ON MESSAGE ERROR policy in message_errors. The full validation rules are in WASM Processor Guests.

ProcessorTypeArrow::arrow_data_type maps ABI schema types to the exact Arrow types Nervix compares, including Datetime as Timestamp(Nanosecond, "+00:00") and Bytes as Arrow Binary. Use it when constructing generated arrays for non-trivial destination fields.

Error Handling

Three error levels map onto the processor’s declared policies:

The Processor callbacks and fallible SDK methods return error_stack::Result<T, GuestError>. Create a report where a failure occurs; keep a source error in that report and add context when the guest gives it a processor-specific meaning. The SDK renders the report only when it crosses the guest ABI, so its Rust callers can still inspect typed causes.

  • Message errors are lineage: route the affected tokens through the message_errors sidecar of an emitted envelope. The host applies ON MESSAGE ERROR.
  • GuestContext::report_global_error(reason) reports a global processor error while the current callback still succeeds. The host applies ON GLOBAL ERROR and the guest latches into error state.
  • Returning Err(GuestError::failed(reason)) from a callback reports the reason through the global-error channel, fails the callback, and latches the guest into error state. GuestError::failed creates the report for that failure. Other GuestError variants return their negative ABI code without latching.

Panics are converted into latched global errors; with panic = "abort" they surface as Wasmtime traps, which the host also treats as global errors.

Guest State

The runtime persists and replicates guest state so it can recreate a branch instance; WASM State And Recovery describes how. save_state returns the processor’s durable computation state: whatever a recreated instance needs to continue the computation, such as counters, aggregates, or open windows. The SDK stores those application bytes in its snapshot together with the branch configuration the instance was initialized with, and restore receives the application bytes back.

Save computation state, never execution state. The input a processor still buffers, its ACK tokens and row sidecars, output envelopes it has not emitted, and timeout handles belong to the live instance and mean nothing to one created later. Release buffered input from flush; ACK tokens are host-local runtime capabilities that are never persisted or replicated, and when ACK state is lost with a processor owner, the upstream ingestor reacts according to its delivery mode and retry policy. The SDK keeps its own execution state out of the snapshot as well: a recreated instance starts without error state, with an empty emit queue, and without pending timeouts.

save_state is fallible. Return an error when the state cannot be serialized: Nervix reports a state snapshot failure and keeps the state saved last, so a recreated instance restores that state rather than a save that never completed. The error follows the same rules as a callback error: GuestError::failed puts its reason on the global-error channel and latches the instance into error state.

restore must stay strict: reject bytes the current build cannot interpret instead of silently resetting. Empty application bytes are the valid state of a stateless processor, not a request to discard state: the default save_state returns them, the SDK still wraps them in a snapshot, and the default restore accepts them and rejects anything else.

The SDK reports every restore verdict with the reserved nervix_load_state codes: a snapshot it cannot decode, including its branch configuration, or a snapshot taken under a different branch configuration than the one the instance was just initialized with, is a rejected snapshot envelope, and any error restore returns is a rejected application state with that error as the reason. Nervix keeps the saved state after a rejection and reports it under the snapshot envelope decoding or application state restoration failure stage, so return an error from restore only when the saved state itself is unusable.

Requesting A New State Lifetime

GuestContext::request_state_reset() asks Nervix to replace the complete guest-state lifetime of the branch the callback is running in. Nervix performs the replacement after the callback returns and never calls back into the guest to do it, so the SDK is never re-entered. The request selects nothing: it is scoped to the calling branch and to the generation it runs in, and it is routed through the same coordinated reset an operator triggers, with the same durability and replica guarantees.

The request is terminal for the callback that made it. Output queued with GuestContext::emit is discarded, the input the branch holds is left unacknowledged for its source to redeliver, no checkpoint is taken, and the instance is dropped with its pending timeouts. Effects earlier callbacks published stand, because their checkpoints completed. The processor is then created by Processor::create in the new lifetime, without a Processor::restore call, and it is that initialization — not the returned Ok(()) — that tells the guest the new lifetime is durable.

Asking repeatedly, in one callback or across the callbacks of one lifetime, replaces that lifetime exactly once. The call returns the host’s own answer, StateResetRequestAnswer. A GuestContext exists only inside process_batch, on_timeout, and flush, which are exactly the callbacks Nervix accepts a request from, so a processor sees Accepted unless the host it runs on disagrees about which operation is in progress.

Use it when the processor’s own state is unusable and must be abandoned, not to clear fields. Clearing fields is an ordinary application-state mutation that the next checkpoint saves.

fn process_batch(
    &mut self,
    ctx: &mut GuestContext<'_>,
    input: InputBatch,
) -> Result<(), GuestError> {
    if self.counters_are_unusable(&input)? {
        if ctx.request_state_reset() == StateResetRequestAnswer::Refused {
            return Err(GuestError::failed("host refused the state reset request"));
        }
        return Ok(());
    }
    // ... ordinary processing
    Ok(())
}

Timeouts

GuestContext::request_timeout(delay) asks the host for a domain-clock timeout and returns its handle; when the domain clock reaches the requested time the host calls on_timeout with that handle. Output emitted from the timeout callback is routed exactly like batch output. WASM processors have no FLUSH clause — guest-requested timeouts and emitted envelopes own the output cadence.

Timeouts belong to the branch instance that requested them and are never part of its saved state. A recreated instance starts without pending timeouts, so a processor whose restored state needs a timer requests it again from its next process_batch or on_timeout call.

Deploying A Guest

Upload the built module as a resource version and reference it from the processor declaration:

CREATE RESOURCE normalizer;
UPLOAD RESOURCE normalizer VERSION 'path/to/resource-directory';

CREATE WASM PROCESSOR normalize_events
  FROM events
  USING RESOURCE normalizer VERSION 1
  FILE "processor.wasm"
  MAX FUEL 1000000000
  MAX MEMORY 64MiB
  BRANCHED BY by_tenant
  TO normalized_events
    SET tenant = tenant,
        normalized = normalized
    ON MESSAGE ERROR LOG
  ON GLOBAL ERROR LOG;

VERSION is mandatory. VERSION LATEST binds the highest completed version when the statement is applied; either form stores one concrete version, so a later upload does not replace the module a processor runs.

See Resources for uploads and Runtime Nodes for the complete CREATE WASM PROCESSOR grammar.

Reference Guest

The complete reference guest — global-row filtering across buffered batches, a generated column shared by multiple routes, message errors, global errors, a failing state save, and a row ordinal as its only saved state — is examples/wasm-processors/rust-guest in the Nervix repository. Build it with:

cargo build \
  --manifest-path examples/wasm-processors/rust-guest/Cargo.toml \
  --target wasm32-unknown-unknown \
  --release

The module is produced at examples/wasm-processors/rust-guest/target/wasm32-unknown-unknown/release/nervix_wasm_processor_rust_guest.wasm.

Architecture And Internals

This section explains how Nervix is built and how it behaves internally.

Use it for:

If you are looking for NSPL usage, setup, or feature-level guidance, start in the Manual.

Architecture Overview

At a high level, Nervix has two major halves:

  • a control plane that owns definitions, scheduling, lifecycle, and coordination
  • a data plane that moves records through the runtime graph

The graph itself is built out of persisted NSPL models:

  • SCHEMA
  • WIRE JSON SCHEMA
  • WIRE CBOR SCHEMA
  • WIRE AVRO SCHEMA
  • CODEC
  • RESOURCE
  • RELAY
  • CLIENT
  • VHOST
  • ENDPOINT
  • INGESTOR
  • JUNCTION
  • DEDUPLICATOR
  • REINGESTOR
  • EMITTER

Those models are stored in the registry and scheduled into a DomainSchedule. The schedule says which nodes exist in a domain, which server is primary for each node, and which servers hold replicas. A relay is one of those runtime nodes: it has one owner, and only materialized relay state has scheduler-selected replicas.

The decision layer converts each committed domain schedule into one in-memory typed execution revision, including node identity, placement, branch and schema contracts, resources, source and sink plans, processor programs, and message-error routes. The runtime binds local resources and executes that revision without reading a Model. Execution Plans follows construction, publication, incremental application, rebuilds, and recovery end to end.

Each declared layer module carries a gated nervix::layer source contract alongside its ownership documentation. The compiler uses that semantic owner through aliases, re-exports and source moves. Configuration Models cannot enter data-plane interfaces or bodies; connector engines explicitly declare that they consume plans. General infrastructure, including consensus persistence, may retain Models when its owning contract requires them. Parser definitions belong to the language, edges and harnesses; import and manifest provenance is checked separately before cfg selection. The session command, mutation, transaction and subscription adapters are edges, as are archive section encoding and restore intake. Their consensus and decision dependencies retain those layers’ ownership. Data-Plane Concurrency states the finite compiler rules and their supported effects.

This graph configuration is persisted with strong control-plane consistency. It is separate from runtime execution state and from the hot-path records moving through the graph.

Before a transaction changes that graph, the control plane plans its ordered execution steps and their before/after affected topology. Its typed report separates required pause scopes from actual gate engagement and remains inspectable through commit and retention. Transaction Quiescence And Impact Inspection defines those guarantees.

Each boundary carries its decisions forward as types: absence stays explicit, distinct states carry their own data, and required identities are validated before execution or recovery. The Typed States And Validation Boundaries chapter covers these rules across the language, registry, runtime, connectors, and clients.

The Errors And Diagnostics chapter explains which layer owns each failure, how typed reports cross boundaries, and how runtime and public edges report them.

Consensus access is restricted by operation. Observers can read locally applied state and watch changes. Proposers can also attempt replicated mutations, administrators manage membership and leadership transfers, and protocol receivers apply Raft messages independently of those capabilities. Consumers receive only the capabilities their responsibilities require. Observation does not imply a linearizable read, and proposal authority does not imply that the node is currently leader.

Raft still checks leadership when accepting a proposal. If leadership changes after a command is admitted, the server preserves the leadership-loss result so the client can redirect to the new leader or wait for an election. Long-running transaction commits validate leadership between steps; their replicated progress survives leader loss so the new leader can continue execution. Raft protocol listeners remain available on every live node through elections and membership changes.

The runtime then instantiates that schedule:

  • ingestors attach to external systems or local endpoints
  • relay owners buffer and route records, including fan-out to remote consumer nodes and sessions
  • junctions, deduplicators, and reingestors transform or route records between relays
  • emitters encode records and publish them externally

Ingestors and emitters meet external systems through connector crates. The server composes a validated typed plan with an integration-specific source or sink, and its data-plane host owns intake, routing, buffering, acknowledgements, and lifecycle. The Connector Crates And The Connector Contract chapter defines that boundary, its source and sink families, delivery and commit points, special integrations, and failure semantics. The Ingestors and Emitters manuals define the public NSPL forms.

HTTP Emitter Architecture traces one HTTP request from its validated configuration and Arrow source record through preparation, response classification, retry, acknowledgement, and recovery, with the one- and three-node qualification evidence.

Every expression follows the same one-way conversion. Parsing produces expression Models; registry validation compiles them to check exact types and sensitivity. The decision layer lowers the committed schedule into typed programs, and node installation compiles them against its bound schemas and UDFs. The data plane executes those programs over Arrow batches with a caller-supplied domain timestamp. The VM Functions chapter defines that pipeline, columnar execution and its selected-row conditionals, row and batch errors, kernel and SIMD choices, every function family, branch-local window aggregates and sketches, and how to add a function. Expression Functions owns the public function contracts.

Clock ownership follows the same one-way conversion. NSPL parsing turns PERIOD, SKEW, start timestamps, and rates into validated vocabulary values. The control plane commits one mapping and fenced authority for a paced START. Each data-plane execution binds a capability for the exact domain and generation and obtains one timestamp snapshot before calling an expression engine or a WASM guest. Engines accept that timestamp as input and cannot read actual UTC. Logical deadlines carry their domain and generation; operational deadlines are a separate process-monotonic type whose construction is limited to timeout, retry, and external-I/O owners. Actual UTC enters the data plane through one physical-time owner in the connector contract and is projected into logical time or used by an explicit external observation contract.

The Domain Clock chapter defines the mapping, lifecycle generation, authority fence, progress delivery, local installation, execution snapshots, admission arithmetic, and logical-deadline boundary in detail.

Runtime execution has its own persistence boundary. Selected execution-node state is persisted through periodic snapshots and replication, but in-flight message batches and ACK state are hot-path memory only. Relay buffers, concrete presence, fan-out, and metrics are owner-local and unreplicated; optional materialized records use the relay’s state replicas.

RESOURCE sits between the control plane and the runtime. The control plane numbers each uploaded version and completes it only after every live node has verified and installed the same archive. Every model that uses a resource pins one completed version, and its runtime consumers load exactly that version from their node’s local copy. An upload adds a version and moves nothing; only a model mutation, such as REBIND RESOURCE, changes the version a model binds. A TLS VHOST is one such binding: the HTTPS listener of every node presents the certificate of the version it pins.

The Resource Versions And Bindings chapter defines catalog and store ownership, the version lifecycle, what each binding loads and when, LATEST resolution, rebinding, the DYNAMIC TLS refresh, and failure and recovery.

All node-to-node traffic uses one mutually authenticated TLS 1.3 and HTTP/2 listener. Independent management, command, replication, relay, and bulk pools isolate traffic, while typed operations, bounded payloads, admission quotas, memory budgets, and deadlines keep one workload from exhausting the node. Application health is observed separately from transport connectivity.

The Cluster Interconnect chapter defines peer identity, connection topology, wire contracts, resource isolation, exchange forms, relay delivery and reconciliation, consensus and bulk traffic, domain-clock progress, application health, lifecycle behavior, and observability.

Clients reach the cluster through a separate public boundary. Every node serves the client session protocol: FlatBuffers frames, verified against one public schema and bounded by fixed session limits, over native gRPC and the console’s binary WebSocket. A request identity pairs each reply with its request, while a durable execution reference names a command’s effects across retries, redirects, reconnects, and restarts, so a client recovers an uncertain outcome exactly instead of repeating it. Row subscriptions deliver typed cells encoded directly from Arrow columns.

The Client Session Protocol chapter defines that boundary, its framing, correlation, dispositions, exact recovery, redirect and reconnection, subscriptions, uploads, bindings, and guarantees, and the Client Implementation Manual states what a client implementation must do.

Stopping a node is its own ordered lifecycle. One owner advertises that the process incarnation is terminating, closes public admission, keeps the services admitted work depends on alive while the local graph drains, and only then tears down the node’s tasks, connections, and storage. One physical deadline bounds all of it, and a repeated signal or an expired deadline ends the process the way a crash would.

The Shutdown And Recovery chapter defines stop requests, phases and outcomes, the deadline and exit statuses, cordon versus terminating placement eligibility, intake stop, graph drain and force flush, ownership handoff during shutdown, connector acknowledgement and commit boundaries, terminal teardown, what survives each ending, and restart recovery.

The rest of this section splits control-plane semantics from data-plane semantics because that distinction is fundamental to how Nervix behaves.

Architecture Rules And The Debt Ratchet

Nervix separates facts about Rust source from facts about the APIs a compilation resolves. The source checks inspect inactive code too. The compiler checks identify definitions, normalized types and source-owned contracts in each declared legal configuration. A method name alone is never evidence of its owner or effect.

just ratchet runs both owners. just validate and just validate-ci require the same gate; formatting is applied by the former and checked by the latter. The compiler emits ordinary nervix:: diagnostics. The four structural counts compare with debt-baseline.json; a count may decrease and cannot increase. just ratchet --update records a decrease only after the complete gate passes. No numeric allowance controls a semantic rule.

Rule Contracts

The fifteen rule families below have one implementation each. The family name remains useful for describing its purpose; a compiler family can emit more than one diagnostic where a missing contract and a violated contract require different corrections.

FamilyPurpose and owning factAllowed exampleRejected exampleEnforcement and qualification
data_plane_lock_acquisitionsResolve synchronization APIs and the execution contract of their callers. Concurrency owns contexts, effects and bounds.A task-local HashMap::entry; a registry acquisition with a lifecycle contract.A recurring callback reaching a DashMap shard or a lifecycle-only helper.Compiler sync_acquisition, lifecycle_call, unknown_effect; resolved receiver, helper, callback, trait, macro and expectation fixtures.
bare_error_signaturesA Nervix failure contract carries its complete contextual report. Errors owns outcome, library and foreign-interface classifications.Result<T, Report<DomainError>>; an exact reason-bearing outcome contract.An owned failure in an alias, inferred future or stream without its report.Compiler bare_error_signature; associated-output, alias, inference, metadata and report-carrier fixtures.
discarded_resultsPreserve a failure or classify recovery at its consuming operation.A propagated failure or the recovery owner’s explicit Discarded operation.A failed outcome discarded through a binding, projection or drop.Compiler discarded_outcome; complete and partial-discard, borrowed, tuple and collection fixtures.
bare_unwrap_and_expectA canonical Option/Result panic needs a construction or checked guarantee, expressed through meticulous.verified after checking the condition; an unrelated object’s method named expect.Canonical unwrap or expect, including aliases and UFCS.Compiler bare_panic; canonical/custom methods, dereference and call-form fixtures.
model_matches_in_data_planeConfiguration Models stay inside their owning boundary; data plane and connector engines consume plans.A vocabulary Model or a typed connector plan.A forbidden Model in a body, normalized interface or nested aggregate.Compiler model_in_data_plane; source layer, value and input contracts, cross-crate and projection fixtures.
parser_imports_outside_edgesLanguage dependencies belong to the language and its declared edges.The session adapter consuming public parsing; a runtime consuming Models.A language reference outside its authorized layer, including aliases.Compiler parser_dependency plus disjoint inactive import/manifest provenance; qualified names, reexports and dependency fixtures.
data_plane_cluster_awaitsRecurring processing retains observations instead of awaiting cluster control access.A lifecycle observation; reading an installed immutable publication.Recurring work awaiting a declared cluster-access capability.Compiler cluster_await; direct, bound-future, helper and trait fixtures.
string_node_idsDeclared node identities use the shared typed vocabulary. Typed States owns the identity contract.ClusterNodeId; an ordinary string unrelated to node identity.A declared identity interface or field represented by a string.Compiler node_identity; alias, interface, aggregate, field and metadata fixtures.
write_once_rwlock_fieldsInstallation-only state is an installed value rather than replaceable locked absence.An installed value; a genuinely mutable field with its lifetime reason.An installed field containing an RwLock, or an unclassified candidate requiring review.Compiler installed_state and state_contract; normalized representation, lifetime, review and exception fixtures.
clamped_arithmeticThe resolved primitive integer or Duration API chooses saturation or wrapping instead of checked overflow.Checked arithmetic; pointer wrapping; an Instant elapsed-time query; a specifically justified retry ceiling or modular mixer.An integer/Duration saturating or wrapping operation without its actual arithmetic meaning.Compiler clamped_arithmetic; integral and Duration owners, aliases, UFCS, callbacks, authored macros and narrow-expectation fixtures.
combinator_control_flowCanonical Option/Result adapters must not hide a branch or a sequence.One transformation, an iterator pipeline, or an unrelated custom method.map_or/map_or_else, successive governed adapters, or a governed adapter with a closure containing statements.Compiler combinator_control_flow; canonical/custom receiver pairs, UFCS, closure structure, diagnostic cardinality and source spans.
as_castsCount actual Rust cast expressions, not words in imports, attributes or qualified paths.use Value as Alias; r#as; <Type as Trait>::item; ordinary literals.An expression using as, including a const expression nested in a qualified type.Stable source parser; inactive cfg, raw identifiers, nested syntax, macro fragments and bounded source properties. Clippy independently denies conversions in its broader build scope.
testing_feature_struct_fieldsA product struct has one field shape rather than a shape selected by testing.A test-only item, an API gate or a conditional documentation attribute.A named or tuple struct field whose presence depends on testing, including a conditional cfg attribute.Stable source parser; field ownership, tuple, inactive, compound-predicate and cfg_attr controls. Enum fields are outside this specific struct rule.
files_over_3000_linesKeep product and in-crate test files within a source-size bound.At most 3,000 lines, counting a final line without a newline.An eligible file with more than 3,000 lines.Source discovery and physical line count; exact boundary and newly authored source tests.
functions_over_complexity_30Keep each function’s decisions within a cyclomatic complexity of 30. At full coverage a function’s CRAP score equals its complexity, so a function above 30 exceeds a CRAP threshold of 30 at any coverage. Function complexity owns the analyzer and its limits.A function the pinned analyzer scores at most 30; an existing function above 30 that grows, which keeps the count.A function newly scored above 30, including one in an inactive configuration or an in-crate test file.Pinned cargo-crap analysis over source discovery; exact 30/31 boundary, inactive configuration, macro, trait and nesting limits, eligibility, and missing, failing, other-version, skipped-source, empty, configured, undiscovered and unreadable-file fixtures.

The Option/Result adapter catalog covers and_then, filter, map, map_err, map_or, map_or_else, ok_or, ok_or_else, or_else, unwrap_or, unwrap_or_default and unwrap_or_else. It resolves their inherent owner. A custom map_or is not a canonical branch, and an Iterator’s map is not an Option/Result adapter. A chain is diagnosed once at its outer governed call; a later binding separates steps. A closure’s statement structure is a source fact, not a claim that the compiler understands the business meaning of its transformation.

Arithmetic recognition uses the defining crate and inherent primitive integer/Duration owner. Type aliases and UFCS preserve that identity. A function reference retains the arithmetic diagnostic before a callback or function-pointer type can erase its definition. A wrapper’s authored body is checked when its configuration compiles; matching words on the wrapper do not grant it permission or make it a canonical API. Instant’s saturating elapsed-time query is a query with a defined zero result, and pointer wrapping is pointer arithmetic. Neither is the integer/Duration overflow choice this family enforces.

Scope And Configuration

Compiler coverage is precisely tools/nervix-lint/configurations.toml: the ordinary workspace’s libraries and binaries, browser root, testing library and separate legal Shuttle, Loom, Turmoil and Deloxide configurations. The diagnostic variants have their own library and binary invocations. They are never combined with --all-features. Every expected target and report must be present, current and complete. Authored macro expansion and captured caller tokens retain provenance; generated build-directory code and external definitions are explicit exclusions. The compiler does not infer the APIs of code that no declared configuration compiles.

Source discovery includes tracked Rust and newly authored Rust not ignored by Git. The four counts retain their product-debt scope: directory components named tests, benches or examples are excluded. In-crate tests.rs, *_tests.rs and test_fixtures.rs count toward file size and function complexity but are excluded from cast/field debt. Test-only items are excluded from those two syntax counts. Other inactive configuration branches remain visible. These exclusions belong to these counts; they do not exempt a file from the primitive, manifest, clock or language provenance validators, which own their broader authored-source contracts.

The stable source parser reads original Rust text and walks cast expressions and attributes attached to actual struct fields, retaining token locations even beside Unicode comments. It also parses Rust fragments in authored macro groups after replacing metavariable markers with their identifiers, preserving token locations. Repetition bodies are visited independently. Opaque macro DSL words and invalid fragments are not reinterpreted as Rust expressions. An invalid top-level Rust file fails with its owning path and location. This does not expand a macro or prove which tokens an external procedural macro would generate.

The parser requires no rustc internals or analysis driver. just typed-ratchet-source-build builds it on the stable toolchain. Native compiler coverage builds it on that producer’s pinned toolchain so LLVM profiles and exporters match. The tooling workspace stays outside product dependencies, including browser and ordinary release graphs.

Function Complexity

functions_over_complexity_30 counts the functions whose cyclomatic complexity exceeds 30, as one pinned analyzer measures it: cargo-crap at the version scripts/cargo_crap.py declares, the same build the advisory CRAP report of just check-coverage runs. just install-cargo-crap builds that version with cargo install --locked into target/tools/cargo-crap-<version>/, through the compiler wrapper the host configures, and keeps a build that is already there. just ratchet, just test-ratchet-units, just check-coverage, just test-crap-report and just coverage-crap-report-runner depend on it, and the CI checks, coverage and extra-tests jobs build it through kache. A binary that reports another version is refused with the command that installs the pinned one; a different version in PATH is never used.

The complexity is classical McCabe: one, plus one for every if, for, while, loop, match arm, &&, || and ?. A function at exactly 30 is not counted and one at 31 is. Growing a function that already exceeds 30 leaves the count unchanged; a new function above 30, or one that crosses it, raises the count.

The analyzer walks the repository root with the eligibility above. It reads syntax and expands nothing, so these are outside the count:

  • a #[test] function and everything inside a #[cfg(test)] module;
  • a function inside a macro, whether an authored macro_rules! body or an invoked item macro such as cfg_if!;
  • the body of a trait’s provided method;
  • a function nested in another function’s body, and the decisions of a closure, which belong to no reported function.

Fixtures pin each limit, so a new analyzer version that changes one fails a test. Inactive configuration branches stay visible.

Missing analysis never reads as zero debt. The count fails, naming the cause, when the pinned binary is missing or reports another version; when the analyzer exits with an error or writes a warning, which it does only for a source it could not read or parse; when it measures no function; when its report has another version or a ? weight other than one; when a .cargo-crap.toml in the repository root or above it could change the analysis; when it measures a file source discovery does not govern, such as one in a nested repository; and when a discovered file is hidden, a symbolic link or tracked but ignored by Git, which the analyzer’s walk would skip.

just ratchet --show functions_over_complexity_30 lists each function’s file, line, name and complexity. The baseline holds counts rather than sites, so when a count rises the gate lists every site of that count and the function a change added is named among them. Decomposing a function is the repair. Extracting a forwarding wrapper only to move the number is the workaround just lint-audit attributes to a lowering of this count.

Exceptions And Ownership Review

A semantic exception is a reason-bearing expect at one operation or binding, gated through cfg_attr(nervix_lint, ...) or nervix_primitives::expect_lint!. A module or function allowance, blanket warning suppression, missing reason, unused expectation or expectation covering another operation fails. Arithmetic reasons state the actual ceiling, capacity or modular computation; “cannot overflow” does not justify wrapping. A retained defect names its repair task.

Source counts have no semantic exception channel. A current structural occurrence remains counted until its owner changes it; a compiler expectation cannot hide its inactive source. Likewise, source provenance cannot be waived by declaring a lifecycle or error contract.

Review still owns the truth of reasons, lifetime classifications, architectural roles and bounds. The compiler checks their placement, shape and supported effects, not arbitrary whole-program ownership. Finite API recognition does not establish business semantics for an unrelated custom method, an erased indirect transformation or an opaque dependency. A paired rejection fixture proves the specified route; it does not prove every possible alternative absent.

Authoring And Qualification

Add a semantic rule in Rust beside its owner. Qualify an allowed/rejected pair that differs in the semantic fact being enforced, not just a name. Include aliases, call forms, macro origin, caller or metadata effects when applicable, exact diagnostics and source locations, and invalid, unfulfilled and widened expectations. Change the receiver or definition to an unrelated object as a false-positive control; restore the canonical API through an alias or UFCS as a false-negative control. Source rules also qualify inactive branches and lexical distractions.

Use just test-ratchet-units, just test-typed-ratchet-reports, just test-typed-ratchet-compiler, just test-typed-ratchet-contracts and just test-typed-ratchet for the source, report, ordinary and separate mode qualification. just typed-ratchet --inventory --show displays a calibration without claiming the diagnostic gate passed. just ratchet requires the gate. Cache qualification remains just qualify-typed-ratchet-cache; changed source, annotations, rules, dependencies and configuration invalidate evidence.

The registered typed-source-syntax Bolero property uses bounded current Rust syntax, complete source input/site equality and deliberate cast/field counts. Ordinary corpus/randomized checks are required; just fuzz typed-source-syntax is actual sanitizer-backed fuzzing, and CI runs that mode only for a PR labeled fuzz. A sanitizer skip supplies no fuzz or coverage evidence. Compiler mode qualification checks analysis, not execution of the runtime’s concurrency models. The sanitizer compiler adapter preserves the source parser’s span contract as described in Property Testing And Fuzzing. The concurrency, error and state chapters retain those operational owners.

Auditing Lint Workarounds

A rule that misjudges code invites code shaped around the rule. The textual counters this gate replaced matched method spellings inside named files, so lowering a count sometimes meant respelling an operation or moving it rather than changing what the rule was about. just lint-audit finds code shaped like such a workaround and attributes it to the history that produced it.

The compiler pass records review candidates beside its acquisition findings in every complete analysis, so the audit reads the evidence just ratchet uses and runs no second analysis. just lint-audit --inventory audits with warning levels while the gate still has open diagnostics, and --configuration selects declared configurations as an inventory. Each candidate carries its owning body, that body’s execution contract (declared, or recurring by reachability from a recurring caller), its spans and macro origins, and the configurations that compiled it.

KindResolved patternControls that are not candidates
repeated_lookupKeyed operations (get, get_mut, contains_key, insert, remove, entry, indexing and their IndexMap forms) of a standard, hashbrown, IndexMap, DashMap or JSON map collection, on one receiver place with one key value, where a later operation can run after an earlier one. guarded marks a later operation in a branch the earlier result selected.Exclusive branches, a receiver or key assigned between the operations, different keys or collections, and another type with map-like method names.
key_allocationA borrowed lookup handed an owned copy of its key, or an entry handed a copy that a hit discards.Borrowed keys, owned insertion keys, Copy values, shared-handle clones and formatted keys.
borrowed_copyAn owned copy whose only use is a borrow, such as &value.clone() or text.to_string().len().Copies that are moved, Copy values, shared-handle clones and text formatted from another value.
duplicated_branchA call or assignment that every branch of one exhaustive if chain or match repeats, with the same resolved callees, generic arguments and type.An if without else, exits, constructed values, and arms that dispatch one spelling over differently typed bindings.
forwarding_wrapperA synchronous function whose whole body passes its parameters unchanged and in order to one local function, with its caller count and whether it carries a source contract, implements a trait method or calls into another file.Transformed, reordered or partial arguments, library callees and constructors.
discarded_errorResult::ok, unwrap_or, unwrap_or_default, is_ok, is_err, an unwrap_or_else, map_err or or_else closure that ignores its failure, an Err(_) arm, if let Ok without else and let Ok(..) = .. else, when the failure carries information.Unit, uninhabited and zero-sized failures, and failures handed on.
disguised_clampChecked primitive-integer or Duration arithmetic, or a checked integer conversion, whose absence falls back through unwrap_or, unwrap_or_default or unwrap_or_else.Overflow decided by its own statement.
relocated_acquisitionA synchronization acquisition outside the files the textual lock counter read, written by a commit that lowered that count.Acquisitions in the counter’s files, and ones no lowering commit wrote.

Attribution blames each candidate’s lines in the working tree, reads the diff hunk of the commit that completed them, and replays the history of debt-baseline.json. For every count the candidate’s kind could have worked around, it records whether that commit introduced or lowered the count, whether its hunk, the rest of the candidate’s file or the commit’s other files removed the counter’s governed spelling (such as .entry( for the lock counter, counted only in the files that counter read at the time), and whether the code predates the count. The report keeps the hunk’s removed and added lines around the candidate’s line. When the completing commit only carried the pattern forward, the earlier commits of the same lines are read for that evidence and the one found is recorded as the origin. --pull-requests also reads, through gh, the commits a squash merge folded away, finding its pull request by the number the subject names or, for a subject without one, by the merged commit, and records a commit that removed the spelling while writing one of the candidate’s own lines. A read that ends with a server error or a dropped connection is attempted up to four times with a doubling backoff; a failure that persists, or any other failure, ends the audit without a report.

The suggestion is suspected when a count event coincides with a spelling removed from the candidate’s own hunk or file, or a folded commit rewrote the candidate’s lines away from it. A removal in another file explains the candidate only for the lock counter, which read named files, so moving an operation out of them lowered it. The suggestion is possible when only an event or a removal is present, or when the code was written into a file the lock counter read before the compiler resolved that count, since such code could avoid the spelling without changing the count; unattributed when the code was written while a count existed without any of these; pre-existing when it predates every plausible count; and uncommitted for lines not yet committed. A suggestion is evidence for review, never a classification: chronology alone proves nothing.

Candidate identities hash the kind, file, owner, primary source and pattern facts without byte offsets, so an unrelated edit keeps them, and a repeat within one owner takes an ordinal. audit.json and audit.md land in target/lint-audit/<source identity>/. They are generated evidence: review dispositions, inventories and measurements belong on the owning task, and nothing reads them back as policy.

Review classifies each candidate as confirmed lint-caused, suspected, unrelated, pre-existing or intentional, recording the source revision, owning rule, before and after code, reason and disposition. A repair keeps lazy and fallible initialization, side-effect counts, borrowed-key hit behavior, exact equality, lifetimes, reentrancy, error and acknowledgement semantics and ordering. An entry is not a repair where a hit would discard an allocated key, where a callback runs between the lookup and the insertion, or where the miss path needs the whole collection. Fewer searches do not make an entry faster either: on a small ordered map whose hits dominate, a lookup can cost less than building the entry, so the measurement covers hit-heavy and miss-heavy key distributions. A performance claim needs measured lookup, comparison and allocation counts and same-host Criterion or just benchmark-ab evidence; a shorter function or a lower count is not a speedup.

Errors And Diagnostics

The architecture rule matrix records the compiler contracts for failure signatures, discarded outcomes, canonical panic operations and Option/Result control flow. Expanding an error projection into an explicit Result branch preserves its complete report, context and recovery disposition. The source parser reports malformed authored Rust through a typed path/location context over the original parse cause; rendering includes that cause once.

Nervix gives a failure its meaning at the boundary that can decide what went wrong. That meaning travels through the graph as a semantic error and an error-stack report. A public edge renders a diagnostic only after it has made the decision the error permits. Ordinary control outcomes, such as waiting for materialized state or following a new leader, remain distinct from failures. The web console applies the same distinction to structured choice lookups: an absent form prerequisite shows a neutral hint, and stale context offers a fresh request. A failed lookup, closed session channel, or unreadable reply appears as an alert.

This chapter owns the error and diagnostic model across layers: who gives a failure its meaning, how a report is created, enriched, inspected and rendered, which outcomes are not failures, where a report ends at a fixed wire, ABI or stored shape, what a diagnostic may say, and how the rules are enforced. It names the error types of every layer. The contract a failure belongs to stays with the chapter that owns its subsystem:

SubjectOwning chapter
Missing values, distinct semantic states and the boundary that validates themTyped States And Validation Boundaries
Row and batch errors of the expression VM, and each function’s error contractVM Functions and Expression Functions
Guest result codes, rejected saved state and failed checkpointsWASM State And Recovery and Rust WASM Guest SDK
Source and sink failures, per-record outcomes, retry and acknowledgementConnector Crates And The Connector Contract
Lookup outcomes and which caller owns a failed lookupName Resolution
Exchange forms, remote failure classes on the wire and relay acknowledgementsCluster Interconnect
Durable appends, uncertain writes and a failed consensus storeConsensus Storage And Replication
Planning, binding and message-error delivery while a schedule is appliedExecution Plans
Primitive mode, target and permission rejection; distinctions between verification outcomesExecution Primitives
Branch-local execution and acknowledgement trackingData Plane and Data-Plane Concurrency
Command dispositions, typed rejections and exact recoveryClient Session Protocol
Planned and actual transaction impact diagnosticsTransaction Quiescence And Impact Inspection
Stop and drain phases, and the outcomes a stopping node dropsShutdown And Recovery

The NSPL forms for error routes are in Message Errors and Error Routes.

The Report Model

A Failure Has One Owner

A fallible operation returns error_stack::Result<T, E>: a semantic thiserror context inside an error-stack report. E belongs to the module that decides what the failure means, and the report around it carries every cause and context the failure gathered on its way to the caller. The owner extends its existing error type when an operation gains another failure case. A second type for the same failure would force callers to reconcile two meanings. Values a caller acts on belong in typed fields, such as a domain, a node, a relay, a revision, or a limit and the size measured against it; display formatting happens when the result is reported.

Creating A Report And Adding Context

The owner creates the report where it detects the failure, with Report::new over its own context. An error a library returned enters the same way: change_context on the library’s result makes that error the first context of a new report and puts the owner’s context above it. Each outer layer that changes what the failure means then adds its own context with change_context, naming its operation, node, route, branch, placement, or target, and keeps the report it received. It does not format a cause into a string and then classify that text as a new failure, and it does not replace the report by a clone of its current context.

A context describes its own failure once:

  • It names its own operation and identity and leaves its cause to the frame beneath. error-stack records the #[source] chain of the error a report is created from as frames of their own, so a context that also printed its source would name the cause twice in the rendered chain. A context with a #[source] therefore leaves the source’s wording to the next frame.
  • It does not repeat an identity a context above it states. The node error policy names the node, so the contexts beneath it do not, and a node that declares a setting names itself above the setting’s error.
  • It carries no payload value. Sensitive Data And Observability states what a context may name.

A source recorded that way is a frame of text: the rendered chain shows it, and a caller cannot find it by type. A cause a caller must classify therefore stays a typed context of the report, or a typed field of the context above it, as the resolver’s DnsLookupFailure is of a RabbitMQ or MQTT connection error.

Attachments are not contexts, and the rendered chain does not show them. A printable attachment carries the description a connector or a task gives its own failure, and one reader uses it: an emitter shows the first printable attachment of a failure as its transient error, and only without one the outermost context. A typed attachment carries a value for one consumer, as a sink attaches the retry delay its receiver stated for the emitter host to read. Neither replaces a context: a caller decides from contexts and typed fields.

Two failures of one operation stay one report. When a model alteration fails after its domain paused and resuming the domain fails too, the resume’s report is added beside the alteration’s and one context is placed above both, so a caller still finds a leadership loss in either branch. A report cannot be copied. A failure that reaches several routes or input batches is therefore reported once, with the union of their acknowledgements, as one runtime event.

Deciding From A Report

A caller decides from typed data and never from rendered text. Most decisions read the report’s current context. A few look for a typed context beneath it, or call the lookup the cause’s owner provides.

CallerTyped data it readsDecision
Rust clientClientError as the current contextRecover the session and send the request again, report an uncertain command or upload, or return the failure
Shared C bindingClientError as the current context, and the BackupDownloadError beneath a failed downloadThe NX_ERROR_* kind of the failure
Session edgeConsensusError::LeadershipLost as the current context of a proposal reportA leader redirect in place of a failed command
Transaction applicationRuntimeError’s revision preparation and readiness timeouts as the current contextApply the cluster state again and retry, in place of failing the transaction
Session transaction bindingSessionTransactionBindingError as the current contextThe TransactionTakenOver or TransactionDetached disposition
Postgres, MySQL and ClickHouse sinksThe driver’s SQLSTATE, code or named rejection in the current contextWhether the destination refused rows for good or the attempt failed as infrastructure
Connectors that resolve through a driver’s DNS hookDnsLookupError::find_in over the causes the driver wraps around the lookupKeep the typed lookup failure beneath the connection failure
Emitter hostThe retry delay a sink attached to its publish failureLengthen the retry schedule to the delay the receiver stated
WASM runtimeThe saved-state verdict of the guest-call failure, an invalid emission, or an exhausted execution limitThe stage the failure is reported under, whether rejected-state recovery starts, and whether the instance is discarded
Restore conversionAn execution admission or staging refusal among the report’s contextsRelease what the conversion holds and convert again within its wait for room

Outcomes That Are Not Reports

An ordinary outcome is a typed value, never an error report. Waiting for or skipping a message whose materialized state is unavailable, following a new leader, a temporary Busy or Suspended refusal a producer resends, a settlement’s StaleReference, a domain clock attachment’s AlreadyAttached and every command disposition are results their caller branches on. Each section below says which outcomes of its boundary are ordinary.

A failure of one row or one record is a typed value in its batch’s outcome too. The expression VM returns the row errors of a batch beside its result, each a SideError with its reason and expression span. A sink answers a write with the records it delivered, the records it rejected, each with a structured message error, and at most one infrastructure failure of the attempt, which is a report; a record in neither list stays unresolved. Branch construction keeps the outcome of each row. None of these builds a report or formats a message per row: the typed value becomes a structured message error only where a route’s policy reports it, and hot paths do not allocate a formatted diagnostic when the variant already names the failure. A source poll is the exception that carries reports: it returns the report of each record it could not read beside the messages it could, so the rest of one external response continues through intake.

Fixed Outcome Boundaries

A report is local to the process that created it. Where a failure crosses a wire, an ABI, a stored record or a public protocol, the shape on the other side is fixed, and the report is projected into it exactly where that shape is constructed. That construction is the one place a report’s current context is cloned or its chain rendered into a field; Guarantees And Limits lists the contexts that still copy a cause as text.

Leading empty consensus probes return their matching boundary, conflict or higher vote through the existing management response. A conflict, higher vote or incomplete acceptance ends that input before data-stream admission. A management request failure uses the same unreachable-peer error as a heartbeat; subsequent data-stream setup failures remain transport failures. Neither path retries inside the adapter or changes the caller’s retry gate.

An ended consensus append input completes normally without a peer operation or a peer error. With a first request available, stream setup may return the existing typed admission, membership or transport failure as an unreachable-peer result. The caller’s retry gate runs before that setup attempt, so reporting an immediate local refusal does not create an unpaced retry loop.

BoundaryFixed shapeWhat crossesWhere a report resumes
Replicated transaction mutationThe Raft response’s TransactionMutationErrorThe state machine’s typed refusal, cloned from the report’s current context when the response is builtThe proposer creates a new report from the typed refusal
Consensus append streamThe stream’s wire recordsA matching, conflicting or higher-vote answer, or the request errorThe leader hands Raft an unreachable-peer error that names the target and the reason
Interconnect remote operationThe failure’s class and subject, with the answering node’s description for an executed failure onlyThe classified result; the local chain is not serializedThe requester keeps the class beside its own target and placement context
Remote relay payload refused before admissionThe refusal’s reason textThe rendered chainThe forwarding node receives the reason as text
WASM guest ABIAn integer result code, and the reason on the global-error channelThe code of the guest error’s current context, or the rendered chain as the reason of a guest failureThe host creates a guest-call report that holds the code or the reason
Structured message errorThe record’s reference, code, operation, affected fields, time and messageThe typed reason that selects the code and a non-sensitive messageThe error route receives the record, never the report
Session commandThe command’s disposition, message and diagnosticsThe disposition from typed data, and the rendered chain as the messageThe client receives a value; the Rust client reports its own failures
gRPC and HTTP edgesA statusThe status the failure’s class selects; a refused credential check names only the refusalA client classifies the status
Shared C bindingThe NX_ERROR_* kind and a messageThe kind from the current context and the rendered chain as the messageThe host language raises its own error

Foreign Interfaces

A foreign trait that accepts only a standard error receives one that holds the whole report, never a clone of the report’s current context. The DNS hooks hand Hyper, Reqwest, Smithy, MQTT and Redis a DnsLookupReport, and the owner’s own lookup, DnsLookupError::find_in, recovers the typed failure from the causes the library wraps around it. The node’s command-line value parsers return a refusal that displays the whole chain, because clap prints only the plain Display of the error a parser returns. The consensus store hands OpenRaft’s storage traits an I/O error that holds its StorageFailure. anyhow remains at integration and tooling boundaries whose caller has no domain choice to make: the gossip library’s transport traits, whose methods return its result type, and the benchmark load generator.

Rendering

A report’s plain Display is its current context alone. Its alternate form, {error:#}, is every context from the outermost to the cause, joined by : , as in failed to start domain 'edge': failed to apply runtime revision 7: failed to build domain execution for 'edge': failed to load lookup 'zips': .... Runtime reporting renders every context in the report it receives, using alternate Display ({error:#}), including report-bearing tracing fields. A failed command’s message, a runtime event, a negative acknowledgement’s reason, a domain’s instantiation error, the CLI’s text and JSON reports and the C binding’s error message are that chain.

Reports remain typed until these reporting decisions; sensitivity rules continue to apply to every context. A boundary renders a failure once and reuses the text: a failed dead-letter dispatch renders its report once and uses that chain in both its runtime event and its negative acknowledgement. Clock arithmetic and attachment, source cadence and lifecycle, Kafka partition inspection, relay dispatch and acknowledgement delivery, and state checkpoint publication and replica catch-up preserve their causes at those boundaries. Nested message-error construction reports retain the VM or Arrow cause.

Three renderings are not the whole chain, each by its owner’s decision. An ingestor’s transient status shows the most specific cause of a source failure, because the connector contract’s own context names only the operation that failed. An emitter’s transient status shows the description its sink attached, which for the HTTP sink is the description of the whole chain. A failed consensus proposal’s message is its context followed by the storage or Raft error beneath it. A node and the benchmark command return their report from main, so the process that exits with it prints the report’s debug form: every context, with its attachments.

sequenceDiagram
    participant Client
    participant Session as Session edge
    participant Registry as Registry decision
    participant VM as VM frontend
    Client->>Session: Submit statement
    Session->>Registry: Validate semantic Model
    Registry->>VM: Compile expression against schema
    VM-->>Registry: Typed failure with expression span and kind
    Registry-->>Session: Report with owning node and route context
    Session-->>Client: Failed command with diagnostic and source span

Ownership And Propagation

Layers

Dependencies point inward, and so does error ownership: a layer names the errors of the layers inside it and adds context above them, and no inner layer knows how an outer one reports. The subsections after the boundary table follow this order.

LayerError ownersWhat their callers receive
PrimitivesModeled adapters keep the error types of the library they stand in for, such as the watch channel’s RecvError and SendErrorThe library’s own error shape, never a report
VocabularyModel alteration errors, CanonicalNsplError, ArchivedCountError, DurationTextError and the name and value errors of the shared typesA report that names the Model, field or value refused, with nothing above it yet
LanguageParseFromSourceError for the lexer and parser, FormatError for the formatterA report whose context holds the stage, the rejected text and every diagnostic’s byte span
Engines and infrastructureThe VM’s CompileError, RuntimeError and row errors; UdfError; JaqProgramError and JaqFormatError; CodecError; the WASM ProtocolError, GuestError and WasmGuestError; the connector contract’s SourceError, SinkStartError and SinkPublishError above each connector’s own errors; ConsensusError and StorageFailure; the interconnect’s TransportError, RequestError and RemoteOperationFailure; DnsConfigurationError and DnsLookupError; AdmissionError and ExecutionError; ArchiveWriteError and ArchiveReadErrorA report, a typed per-row or per-record outcome, or a typed class of a remote result. An engine decides nothing about the graph, so its errors name no node policy
DecisionsRegistryError, RelocationPlanError, RestorePlanError and TransactionPlanningErrorA report that names the node, route, operation and fields of the refused model or plan
Data planeRuntimeError above ExecutionBuildError; IngestorStartError, EmitterStartError and GeneratorError; PlannedGeneralError, RouteOutputError, RelayProcessorError and the other node contexts; RuntimePersistenceError; the domain clock’s DomainClockAccessError and DomainClockWaitErrorA report for the node’s policy, with the acknowledgements of the work that failed, or a structured message error for a route
Control planeAppError, DomainAlterError, BackupError, RestoreRefusal and the transaction and lifecycle errorsA report the session edge turns into a command result
EdgesThe session service’s GrpcAuthenticationError, SessionTransactionBindingError and SnapshotEncodingError; ClientError and the Rust client’s DomainClockReadError; the CLI’s and the benchmark command’s own contextsA command disposition, a status, an NX_ERROR_* kind, or the rendered chain

Boundaries

BoundaryFailure meaning it ownsWhat its caller can decide
Vocabulary Models and the execution-graph descriptionAlterations the stored Model refuses; invalid placement members, inferencer tensor schemas, and upload identities; values canonical NSPL cannot spell; and execution-graph encoding or decodingRefuse the command and keep the stored Model unchanged, or report which statement or graph could not be rendered or decoded.
NSPL language and formatterSource text the lexer or parser rejects, with the stage that rejected it, the rejected text, and every diagnostic’s message and byte span; statements the formatter cannot render, and formatted output that does not reparse to the statements it came fromReport the stage and underline each diagnostic in the text that was submitted, or leave a file unchanged and report the formatter defect.
Arrow record and batch layerSchema, field, column, row, and batch construction or decoding failuresReject a malformed batch or a field operation without inventing a replacement value.
Bounded executionA memory charge a class could not grant (AdmissionError), a job refused because its class’s wait queue is full, a closed pool, a job that panicked on its worker (ExecutionError), and a job that stopped at a Cancelled check because its caller stopped waitingThe job’s owner maps each to its own typed outcome. A refusal judged nothing, so it stays retryable: an emitter keeps its rows, an ingested payload fails its dispatch rather than its decode and an endpoint answers it as a retryable rejection, a credential check answers UNAVAILABLE rather than failing authentication, and a restore’s deduplicator or window conversion releases what it holds and converts the refused window section or keyspace again, within a 30-second wait for room, rather than reporting archived state of another shape. The unfolding of a payload a quiesce buffer retained, or of a poll a paced source handed over, is not refused at all: nothing could present it again, so it waits for a place. The job that admits one key group into a keyspace a restore is converting waits for a place too, because the keys admitted so far could be presented again only by converting the keyspace from its first group. So does a payload a source handed over without an acknowledgement, when its transport survives a held loop; any other such payload is refused, reported as an ingestor error and counted in nervix_ingestor_unfolding_refused_total. A panic is the job’s own defect.
Expression VM frontend and runtime bridgeInvalid expression scopes, types, sensitivity, compiled program inputs, and evaluation failuresRefuse a model during validation, or classify an affected row or batch during execution. VM Functions owns execution detail.
Stateful processorsBranch-local deduplication, ordering, window, correlation, inference, and WASM execution or state failures, and a processor task’s restore of the branches its lifecycle checkpoint names (ProcessorBranchTaskError)Apply the processor’s message or node policy, or fail a checkpoint and its held acknowledgements. A restore that fails installs no branch and stays pending: the task retries it after a backoff, holds its input, and refuses a lifecycle checkpoint or guest-state reset with BranchesUnrestored until an attempt succeeds. Shutdown And Recovery owns that sequence.
Connector crates and hostIntegration-specific configuration, decoding, external source and sink outcomes; host-owned routing, retry, flush, and acknowledgement failuresSeparate a record rejection from a source or sink failure and follow the configured retry or acknowledgement contract. Connector Crates And The Connector Contract owns those contracts.
Materialized state and lookupsDependency resolution, field and schema checks, defaults, lookup evaluation, snapshot opening, and state exchangeUse a declared absence policy only for unavailable state; report a genuine failed read or invalid state.
Ingest grouping and relay batchingRoute grouping, branch-key construction, Arrow batch assembly, relay admission, and delivery failuresKeep the affected concrete branch and fail or retry the correct in-memory attempt.
Client ingestor endpointWhether a submitted batch is a canonical Arrow IPC stream of the input schema (ClientBatchError), whether the node had capacity to validate it, and how its acknowledgement root resolvedAnswer the batch as not admitted with its defect or a temporary refusal, or with its terminal outcome, and end a producer with the reason its attachment ended.
Registry, placement, and planningInvalid domain models, references, capabilities, branch relationships, flush contracts, schedules, and placementsReject the command before activating an invalid graph or refuse a relocation plan.
Runtime installationA committed revision this node cannot plan or apply, a domain execution it cannot build or change (RuntimeError::BuildDomainExecution above the ExecutionBuildError step that failed), and ingestors that do not start once their revision is preparedKeep the previously applied schedule as the predecessor for a retry, and report the whole chain with the failed command, the domain’s instantiation error and the runtime event.
Backup archive formatRecords that do not encode or exceed their size limit, and archives whose structure, record headers, record values, section lengths or digests do not match what the manifest declaresRefuse to write an archive, or refuse a whole archive naming the section and the check that failed.
Restore planningArchives a restore cannot apply to the cluster: the wrong scope, a domain the archive lacks or the cluster has, an archived user the cluster has under ON EXISTING USER FAIL, resource versions the archive does not hold consistently, models that bind no restored version, and archived branch keys that are not keys of the branching their restored entity declaresRefuse the restore before it changes anything, naming the domain, user, resource, version, or model, or the archive section, entry, entity, branch and field of the key.
InterconnectAuthentication, framing, limits, transport, and the class and subject of a remote operation failureDistinguish a transport failure from a peer’s rejection, absence, unreadiness, or executed failure.
Deadlock detector and diagnostic runA detector that cannot be installed or is installed twice (InstallError), a diagnostic run that cannot install it or record its starting evidence (DiagnosticError), and evidence that does not encode, decode, fit its bounds, write or read (EvidenceError)Refuse to start a diagnostic process, or refuse a whole evidence file naming the check that failed. A deadlock finding is not one of these errors: it is a diagnostic result that ends the process with its own status.
Native client and its edgesA call that could not connect, was refused or cancelled, was answered outside the protocol, or lost its session (ClientError), above the transport status, the codec’s report, a local archive’s I/O error, or the failure that left a command or upload uncertainRetry, recover the session, recover an uncertain command or upload by its reference or identity, or display the whole chain; the CLI and the C binding classify from the current context.
Control plane and public edgesTransaction and lifecycle results, command dispositions, session diagnostics, and HTTP response selectionReturn a recoverable command outcome or an appropriate response to a client or operator.

Cluster Availability Reports

SHOW CLUSTER STATUS keeps gossip visibility and effective member unavailability separate. A Raft member missing from the raw live gossip set gets a visibility warning. Its unavailable-member warning comes from the cluster’s effective availability snapshot, which retains an established peer while its health observation still bridges a gossip lapse. The reason names application health when that owner excludes the peer, or Chitchat when gossip excludes it after the retained observation ages out. A client observing the warning therefore sees the same availability owner that controls member removal. The report is an observation; the mutation checks availability again.

Vocabulary Models

The vocabulary is the innermost owner, and its Model operations report the same way. An alteration is applied to a copy of the stored Model, which replaces the original only when every operation succeeds, so a refusal leaves the stored Model unchanged. Each refusal names what it refused in typed fields: the field, input relay, route target, or materialized dependency, or the stored and requested names when an alteration targets another Model. An ingestor alteration whose head names another connector and entity than the stored ingestor reads is refused with both typed kinds, which its message renders as their phrases, such as the stored ingestor reads HTTP ENDPOINT, so ALTER KAFKA TOPIC INGESTOR cannot alter it. An input, route, or dependency operation that junctions, deduplicators, reorderers, and reingestors share is reported in the altered processor’s own error where it is detected, so the report begins at the failure rather than at a conversion. Canonical NSPL rendering refuses only a value the language has no spelling for: a NaN or infinite F64 literal, which the error carries, or a codec declaration its wire format cannot express, such as encoding rules on SYSLOG or a JAQ-transformed format without a program, which the error names by codec. The execution-graph description keeps the JSON encoder’s or decoder’s error beneath its own when the public wire form cannot be written or read. It keeps a typed columnar JSON writer error beneath a named codec encode failure. Unsupported columns and invalid string offsets fail batch preparation with the codec name. Required nulls identify their field and row; write failures retain their source without quoting a payload value. Registry planning keeps an alteration’s report beneath its invalid-model refusal of the named Model, and the refusal quotes the rejection’s message, so a failed ALTER shows the same reason the vocabulary gave. SHOW CREATE answers a Model canonical NSPL cannot spell with a fixed diagnostic.

NSPL Language And Formatter

The language layer reports rejected source the same way. Lexing and parsing each create the report at the stage that failed, and its context names that stage and holds the rejected text with every diagnostic’s message and byte span into it. A batch of statements is lexed once and each statement is parsed from its own run of those tokens, so a diagnostic indexes the whole submitted text wherever in the batch the rejected statement starts. The session edge turns the stage into the failed command’s lex error or parse error message and passes every span through unchanged, so a client underlines it in the text it sent. A statement grammar reads an expression it embeds from the statement’s own tokens, with the grammar a standalone expression uses, for as long as that grammar can go on, and its next clause begins where the expression ends. Where the expression cannot begin at all, the diagnostic expects the placeholder the clause names, such as where_expression. Where the expression goes on with a token and then fails, the statement reports the expression grammar’s first diagnostic at the tokens of the statement where it failed, with the message the standalone reader gives the same text. Where a complete expression is followed by a token no clause of the statement expects, the statement reports that token with its own expectations. An expression’s diagnostic carries no expectations of the expression grammar, so completion inside an unfinished expression offers nothing rather than guessing at expression syntax. A caller that owns a larger operation adds its own context above the language’s report instead of copying the diagnostics into its error: splitting a client batch reports that the batch could not be split, and the formatter reports a source that did not parse, the line of a statement the vocabulary could not render, or a rendering defect whose output changed meaning or no longer parses. The formatter’s command line reads the language’s report beneath its context to draw each diagnostic over the whole file at its line, and writes a defect as the report’s whole chain, ending with the cause the vocabulary or the reparse gave.

Expression VM, UDFs And Jaq

The expression VM returns reports for compile, batch, and runtime failures. CompileError keeps its typed diagnostic code, stable code spelling, operation span, and safe message; validation adds the model and route context without losing that cause. Roto setup returns UdfError reports and Roto’s VM injector returns runtime reports, so a failing Arrow operation can remain in the chain. Jaq returns JaqProgramError or JaqFormatError reports for compilation, evaluation, and format conversion. A codec or runtime caller retains that report under its operation context. VM row errors remain typed values in the batch outcome and are formatted only when a message error is reported; this conversion does not turn them into report allocations per row.

A compile failure fails validation or binding as a report. An evaluation failure of one row is a row error in the batch’s outcome, which the route’s ON MESSAGE ERROR policy receives as a message error with the evaluation code, and only a failure of the batch as a whole is a runtime report. Row Errors And Batch Errors owns which failure is which.

Binding a lowered program on a node returns a RuntimeVmCompileError report that names the program and its node: a filter, a FILTER-MAP route, an output branch construction, a WASM output construction or its refused INVOKE, and a generator output. The lowering, LOOKUP_HASH_MAP, materialized-state binding or VM compile failure stays beneath it. The binding names no domain; its caller adds that context above it, as the processor plan, the entrypoint binding, and an emitter’s or generator’s start do.

WASM Guests

The WASM FlatBuffers decoder reports protocol failures with their verified payload cause. It checks the complete header length before reading the identifier; a truncated header returns the typed length or identifier error even when its size prefix matches the received bytes. The Rust guest SDK retains that report beneath its envelope or snapshot meaning, and its Processor callbacks return guest-error reports. It renders a failure only when returning an ABI code or global-error reason; rejected snapshot bytes and rejected application state keep their distinct codes and text. The host retains a typed guest-call cause beneath the failed operation, so its runtime caller can still distinguish a resource limit, invalid emission, and a saved-state verdict without classifying a rendered string. Callback and checkpoint acknowledgement decisions stay the same; WASM State And Recovery owns those boundaries.

A guest export answers the host with an integer, and the SDK renders a guest report only there. A guest’s own failure puts the rendered chain on the global-error channel, latches the instance’s error state and returns the error-state code; a panic latches the same state with the panic’s reason. Any other guest error returns the code of its current context: an invalid size, an access out of bounds, an uninitialized guest, an Arrow IPC failure or an envelope protocol violation. A saved state the guest refuses returns one of two codes of its own, for snapshot bytes it cannot decode and for application state it rejects, and reports its reason on the same channel without latching, because the host discards an instance whose restore failed. The host turns each answer into a typed guest-call cause: the code of an export, a global-error reason, a trap, exhausted fuel, an exceeded memory limit or an invalid emission. Rejected-State Recovery owns what the host does with each verdict, and Error Handling what a guest author returns.

Ownership preparation of a stopped WASM domain carries its validated durable checkpoint inventory without guest execution. A stopped clock is ordinary passive state, not a missing-checkpoint failure or a reason to reset guest state. Guest restore failures are classified when the running revision restores the save under the active clock at START.

Codecs And Ingress

Codec compilation, decoding and encoding return CodecError reports. The codec context names the codec, and the field when one is at fault, and the typed reason stays beneath it: a CodecContractError for a declaration or use the wire format does not support, such as a missing wire field or ON INGESTION program; a FieldDecodeError or FieldEncodeError for a value that does not fit its field; a SyslogDecodeError or SyslogEncodeError for a syslog frame; the Arrow builder’s RuntimeSchemaError; or the CBOR, Avro, simd-json, protobuf or UTF-8 error that failed. An unfolding payload keeps the report of the message that failed and names its zero-based input and output position after it. The ingest group returns that report unchanged. A source host keeps it beneath its intake’s decode or dispatch failure and reports the whole chain, an HTTP or WebSocket endpoint renders the whole chain in its decode notice, a lookup line keeps it beneath the line it failed on, and an emitter keeps it beneath the record it rejects or the encoding it could not start. The rendered chain reads as the codec diagnostic did before: codec 'events_codec' failed to parse field 'user_id': ... followed by the Arrow builder’s own reason. A codec that fails to compile while a domain execution is built keeps its report beneath RuntimeError::BuildDomainExecution, which names the domain. A codec failure whose parser or writer error is its #[source], such as a simd-json, CBOR, Avro, protobuf, I/O, UTF-8 or timestamp error, leaves that error out of its own message: error-stack records a context’s source as the frame beneath it, so the rendered chain names each cause once.

Schemaful JSON parsing has one codec decode failure carrying the simd-json source. Malformed syntax, invalid UTF-8, and invalid escapes enter through that failure; object shape, missing or unexpected fields, nullability, exact wire types, integer ranges, datetime parsing, base64, and nested sequence shapes keep their existing typed codec or runtime-schema failures. Diagnostics name the codec and field when one is known and never attach the rejected payload value.

Codec jaq transformations are compiled during registry validation for every declared direction. A syntax error names the codec, domain, and direction and rejects the transaction before the model is committed. The browser keeps the draft editable so the program can be corrected and submitted under the same name.

An endpoint that decodes a payload but cannot dispatch or flush its ingest group uses the same rendered report chain in its runtime event and log. A temporary unfolding admission refusal logs its report chain while preserving the endpoint’s retryable refusal outcome.

Stored Shapes And Archives

The vocabulary’s ArchivedCountError reports a fixed-width archived count that the receiving target’s usize cannot represent. Archive decoding retains it beneath the owning storage or transport failure. Registry Model records validate their current frame signature and report RegistryError::InvalidModelArchive with a recreation instruction for an unrecognized shape. A record’s key holds its domain, Model kind and name exactly as a commit encodes them; a key spelling a name another way or holding bytes after its encoding is RegistryError::DecodeKey, so no stored record is read as another Model. Consensus validates its complete current keyspace namespace and state encoding and reports StorageFailure::InvalidState with a recreation instruction. Window snapshot decoding reports WindowSnapshotIssue::Header with a recreation instruction for an invalid current frame signature. These boundaries reject unrecognized data before its counts can be reinterpreted; none clamps, truncates, or supplies a replacement value. See Archived Counts.

An rkyv archive can pass shape validation and still contain a value that its vocabulary decoder refuses, including a typed name, size limit, reference, or range inside a list. The owning boundary reports its existing decode failure with the value’s cause. The decoder drops values it has already read and releases any partially read list or fixed array, boxed value, or shared pointer allocation before returning that failure; it neither publishes a partial value nor retains memory for a refused archive.

Runtime State Storage

Runtime state storage keeps the cause of each failure beneath its RuntimePersistenceError. Opening the store keeps the storage engine’s error beneath its keyspace, read, write or synchronization failure. An encoding or decoding failure keeps the serializer’s error, or the StoredStateIssue the stored bytes have, such as a key without its domain separator or a restore whose staged inventory differs, beneath EncodeState or DecodeState, so the rendered chain still reads failed to decode runtime state: runtime state key has no domain separator. A memory or storage refusal while a materialized relay’s restored snapshot is opened is a storage admission or execution failure, never a decoding failure. A caller keeps the storage report beneath its own context: RuntimeStateOperationError::Persistence for a replica’s installation or a snapshot task, OwnershipHandoffError::Persistence for a handoff, and the domain build’s ExecutionBuildError step, which names the node and the state kind, for a state assignment.

Native Kafka stream conversion retains the rkyv validation failure beneath DecodeState and the placement-qualified StateReplicationError::Capture. A cancelled conversion keeps Cancelled at that same boundary without publishing the candidate table. Installation refused after an assignment changes retains StateAuthorityError beneath RuntimeStateOperationError::Authority and that placement-qualified capture context, so a caller can distinguish malformed bytes from lost authority.

Name Resolution

The node resolver’s own errors belong to nervix-dns. A resolver configuration that cannot be loaded is a DnsConfigurationError, which fails node startup beneath failed to load the name resolver configuration and a native client’s connection as ClientError::LoadDnsConfiguration. A lookup that fails is a DnsLookupError: the host as the caller wrote it and one DnsLookupFailure, the closed set that Lookup Outcomes lists. A client library that resolves through one of the resolver’s hooks receives the DnsLookupError itself and keeps it among the causes of its own connection error, where DnsLookupError::find_in recovers it. The HTTP request sink, RabbitMQ, Syslog, WebSocket, Redis, MQTT, ClickHouse, SQS and the native session client keep it beneath their own failure, as the paragraphs below describe. HTTP polling, Prometheus, Sentry, OTEL and Iceberg report their failed request, export or catalog call without it, so their diagnostics do not name the lookup failure.

During cluster startup, a node that has recovered Raft peer endpoints logs a failed lookup of its configured bootstrap host at warn and continues with the recovered seeds. A node without recovered peer endpoints keeps the lookup error beneath AppError::StartCluster, because it needs the configured bootstrap host for first contact. A failed lookup of the node’s own advertised endpoint also fails startup.

The node’s own trace export waits for the resolver installed by startup. Its connector’s TraceConnectError distinguishes a closed installation, a connection timeout, and a Hyper connection failure retaining its DnsLookupError cause. Tonic and the OTLP SDK report the failed export. The export timeout encloses DNS and TCP after resolver installation; a missing DNS configuration still fails startup as AppError::LoadDnsConfiguration. Telemetry failures have no connector retry or ACK disposition.

Connectors

A connector reports through the contract’s own contexts and keeps its integration’s error beneath them. SourceError names the connector and the operation that failed: opening, reading, acknowledging, rejecting, suspending, resuming or closing. SinkStartError separates an invalid configuration and a missing external entity from a failed initialization. SinkPublishError separates a failed publish, finish or commit, which the host retries on its backoff, from a misconfiguration, which it does not retry. A record the destination refuses for good is none of these errors: it is a rejection in the write’s per-record outcome, with the structured message error the route’s policy receives. Failure and observation owns the contract, and the paragraphs below record what each integration keeps beneath it.

The connector helper errors for OTEL, Syslog, WebSocket signaling, Postgres, MySQL and ClickHouse carry error_stack::Report from the failing operation. A caller adds context at a connector or host ownership transition; it does not recreate the top-level error from its formatted text. Syslog TLS material reports keep the file, certificate or rustls cause, and stream frame reports remain beneath the connection failure. WebSocket signaling compilation and execution retain jaq, frame encoding and transport causes. The runtime’s Syslog source-plan and signaling compilation errors keep those reports as typed fields while preserving their startup messages.

An HTTP request attempt that fails is an HttpAttemptError, owned by the HTTP sink: a timeout, a DNS, connection, TLS, send or response-header failure, an invalid destination, or a retryable or authentication status with its number. The sink keeps the resolver’s DnsLookupError, the response-header failure, or the socket or TLS error as the context beneath it, and attaches the description of the whole chain, such as HTTP TLS handshake failed: invalid peer certificate: UnknownIssuer, to its publish failure. The emitter reports that description as its transient error and runtime event for as long as the request stays pending, which DESCRIBE EMITTER shows. It names the status or the cause of the connection and never the evaluated target, a header value, a credential or a body. A refused request is not an attempt failure: it is a record rejection with code external, operation publish and its numeric status.

HTTP request-field compilation retains the VM report beneath the emitter’s request-field context and attaches its safe message for diagnostics; an invalid request program never starts the sink.

Iceberg object storage retains the Iceberg storage error contract when it installs the node’s HTTP resolver. Invalid object URLs are DataInvalid, and an unsupported Azure connection string is FeatureUnsupported. Building the storage HTTP client or an OpenDAL operation can fail as Unexpected, with the underlying error retained as its source. Those failures enter the existing sink failure and retry path; they do not release a staged record’s acknowledgement before commit.

A RabbitMQ connection that fails is a RabbitMqConnectError, owned by the connector’s connection module: an invalid address or CA file, a lookup failure that keeps the resolver’s DnsLookupFailure as a typed field, no address that accepted a connection, a failed or overdue TLS handshake, a Lapin runtime that could not be created, or a failed AMQP handshake, each naming the broker host where one is involved. The source keeps it beneath its connect and resume contexts, so DESCRIBE INGESTOR shows the deepest cause, such as the resolver’s own lookup error. The sink changes it into a configuration failure for an invalid address or CA file and an initialization failure otherwise, leading with the connection error’s message, which DESCRIBE EMITTER shows. Neither attaches credentials from the address.

A RabbitMQ publish that ends with the broker closing the sink’s channel is classified by the broker’s own reason, which the sink reads from its connection. A refusal of a message body larger than max_message_size is a RabbitMqRecordError, owned by the RabbitMQ sink, which carries the body size and the limit as typed fields and becomes a record rejection of that message with code external and operation publish, reaching every member of a batch message. Any other close, a lost connection, and a close whose reason never arrives fail the attempt as an infrastructure failure, which the emitter retries on its backoff.

Syslog emission and WebSocket-client ingestion retain DNS failures from the node resolver beneath their existing infrastructure contexts: SinkStartError::Initialize while a Syslog sender opens and SourceError::Resume while a WebSocket source connects or reconnects. The resolver’s typed missing-name, no-address, timeout, invalid-name or transport cause stays in the error report. Address, TLS and WebSocket-upgrade failures remain connection outcomes. None is a record rejection, and a DNS result by itself never marks a Syslog record delivered.

Redis command connections receive the node resolver through the driver’s DNS hook. A failed initial or later pool connection keeps the resolver’s typed failure in RedisClientError::Resolve; a later failure sits beneath SinkPublishError::Publish for the emitter host to retry. A Pub/Sub source resolves before opening its dedicated stream and retains DnsLookupError beneath RedisPubSubSourceError::Resolve and SourceError::Resume. A refused address, failed TLS name check or failed Redis protocol setup is also a connection failure. None rejects an input message or changes Redis Pub/Sub’s server-acceptance boundary for PUBLISH.

An MQTT client’s event loop reports a failed or lost broker connection as an MqttConnectionError, owned by the connector’s connection module. The socket connector hands the driver the resolver’s DnsLookupError as the failure of a lookup, and the driver keeps it as the cause of its connection error. The connector finds it there and reports MqttConnectionError::Resolve, with the host and the resolver’s DnsLookupFailure as typed fields and the DnsLookupError beneath it. Any other failure keeps the driver’s own description, such as an address that refused the connection, a failed TLS handshake, including a certificate that does not name the configured host, or a refused MQTT handshake. A source keeps the error beneath MqttSourceError::Connect while it connects and MqttSourceError::Receive while it reads, and those beneath SourceError, so DESCRIBE INGESTOR shows the deepest cause: the resolver’s lookup error or the driver’s description. A sink’s event loop records the connection error’s message as the emitter’s transient error, which DESCRIBE EMITTER shows, and reconnects on the emitter’s retry policy. None rejects a record or acknowledges input. A QoS 1 or 2 publish keeps the driver’s ClientError when the client refuses to take a record, and its PublishNoticeError when an answer leaves a record unresolved, beneath SinkPublishError::Publish, each with the connector’s description of the failure for the emitter’s transient error. A missed ACK TIMEOUT is the confirmation window’s ConfirmationWindowError, which the window describes the same way.

ClickHouse and SQS reach the node resolver through their drivers’ own DNS hooks, which hand the driver the resolver’s DnsLookupError as the failure of the lookup. The driver carries it as a cause of its connection error, and the connector finds it there by type and keeps it as the context beneath its existing infrastructure failure: SinkPublishError::Publish for a ClickHouse insert or an SQS send, SinkStartError::Initialize while an SQS sink looks up its queue, and the SqsSourceError of looking its queue up, receiving or deleting beneath the source’s SourceError. An SQS source looks its queue up when an instance resumes, so that failure is SourceError::Resume above the typed lookup failure: a queue the service does not have is SqsSourceError::MissingQueue, which names it, and the source host shows the deepest cause as the source’s transient error and retries it. The failure’s message names the host and the lookup failure, which DESCRIBE EMITTER and DESCRIBE INGESTOR show. Any other failure to reach the service, such as a refused connection or a certificate that does not name the configured host, is described by every cause of the driver’s connection error, which describes the connection and carries neither credentials nor a record; a response from the service keeps its existing description. None is a record rejection, and none acknowledges input.

OTEL keeps a row conversion failure in the invalid-record channel and names its mapped key as the affected field. A lower OTEL value type or range error stays in the internal report until the rejection is constructed. Database sink insert reports keep transport driver or pool causes while the connector inspects the current typed error for definite row rejection. Postgres SQLSTATE, MySQL SQLSTATE and code, and ClickHouse named rejection remain the same external classifications. Database response text that could quote a bound value is discarded after extracting that safe classification; diagnostics do not quote the row payload.

A Pulsar message refused for good is a PulsarRecordError, owned by the Pulsar sink: a message larger than the maximum message size the broker announced, which carries the measured size of its metadata and payload and the limit as typed fields, or a message the broker answered with NotAllowedError, which carries the broker’s reason. Either becomes a record rejection with code external and operation publish. Every other failure of the client, its connection or the broker stays an infrastructure failure of the attempt, which the emitter retries.

Connector Status Observation

Each source or sink publishes its safe transient error and optional retry together. Repeated healthy operations read the retained status without writing it; a transition clears an active failure. Reporting a different error without selecting a new retry preserves the active retry. DESCRIBE renders error, backoff and remaining wait from one immutable observation. A failed record obtains its prepared message-error route from its task’s retained routing publication and preserves that plan while its VM program and delivery execute.

Console Drafts

The visual hash-map and Roto UDF forms validate incomplete drafts before rendering canonical NSPL. Missing resource versions, codec output fields, argument types, or source are shown as form validation errors while the draft stays editable. After submission, lookup loading and Roto compilation or test failures retain their server diagnostics; correcting the same draft starts a new command without claiming the failed creation succeeded.

Junction and reingestor drafts report the input, materialized dependency, route, or assignment whose required reference or expression is incomplete. A retained reference whose domain or upstream choice changed is reported as a changed context and must be selected again. These are local draft errors before canonical NSPL exists. A completed Model still passes through registry validation, whose schema, branch, sensitivity, and graph diagnostics remain authoritative and appear in the editable form after a rejected submission.

Absence, Validation, And Planning

Materialized Dependencies

Materialized dependencies run in declaration order against the current branch. An available record binds at once. A declared default supplies typed constant fields, with omitted optional fields becoming typed nulls. REQUIRED SKIP drops that message; REQUIRED WAIT retains its batch in memory, applies backpressure, and restarts resolution from the first dependency after progress. These are successful resolution outcomes, not error reports. A missing record during an ownership handoff may take the same declared policy when a remote description says rejected, absent, or not ready. A transport failure or an executed remote failure does not become absence.

Schema mismatch, a repeated dependency, an invalid default expression, a missing required default field, and a snapshot that cannot be decoded are failures. The materialized read or snapshot owner reports them with relay and placement context, and the branch-local processor that resolved the dependency names its concrete branch. Branch-local processor and relay failures name their branch the same way, so another branch cannot be mistaken for the failed one. A failure names a concrete branch by the fingerprint of its key, as described under Sensitive Data And Observability, and unbranched work as unbranched. Data Plane owns branch execution and Cluster Interconnect owns snapshot exchange.

The materialized installation owner refuses a lower snapshot revision with RuntimeStateOperationError::MaterializedSnapshotRevision { received, current }, a snapshot from an earlier branch lifecycle with MaterializedSnapshotBranchGeneration { received, current }, and one sealed under a superseded ownership fence with MaterializedSnapshotFence { received, current }, preserving both values in each diagnostic. It checks these before publishing any restored row, alongside assignment validation. An assignment that no longer grants the installation fails as RuntimeStateOperationError::Authority with the assignment’s StateAuthorityError beneath, and the snapshot exchange keeps the refusal beneath its own install failure. A cached snapshot from another assignment is rebuilt under the current fence rather than reported as current; a fence change during encoding remains the snapshot owner’s typed OwnershipChanged failure.

sequenceDiagram
    participant Processor
    participant Resolver as Materialized resolver
    participant State as Relay state
    Processor->>Resolver: Resolve ordered dependencies in branch
    Resolver->>State: Read current branch record
    alt Record available
        State-->>Resolver: Typed fields
        Resolver-->>Processor: Ready
    else Record unavailable
        State-->>Resolver: No record
        Resolver-->>Processor: Skip, Wait, or declared default
    else Read or snapshot fails
        State-->>Resolver: Typed failure
        Resolver-->>Processor: Report with relay and branch context
    end

Registry Validation And Planning

Registry validation and planning refuse invalid models before graph activation. Validation names the owning node, the route when the rule is route-local, the operation, and relevant fields or references. A branch mismatch in an error route, for example, identifies the source route, error relay, and both branch declarations. A flush-based route without FLUSH EACH or FLUSH IMMEDIATE fails validation; the registry does not supply a cadence. The current registry reports this as an invalid-model failure naming the node and output in its diagnostic. An ingestor whose body names a client of another connector, or an endpoint of another TYPE, than its head’s entity reads is refused with a typed failure naming the domain, the ingestor, its connector and entity, the client or endpoint, and both connectors or both endpoint types, such as KAFKA TOPIC ingestor 'orders_in' in domain 'shop' requires a KAFKA client, but client 'cache' is a REDIS client. Planning failures likewise retain the selected entity or placement so an operator can correct the request. A relocation the graph cannot plan is a RelocationPlanError report beneath RelocationError::Graph: a member that does not exist names the domain and the member, a server-listener ingestor names the ingestor, a corridor whose FROM/TO pairs are all disconnected, a hard group whose overrides request different strategies lists every member of that group, and an override outside the unit names its member. The failed command renders the chain, such as relocation plan failed: junction 'chain_distant' is not part of the relocation. See Control Plane for activation and Typed States And Validation Boundaries for required state.

Visual schema, branch, relay, and subscription editors report incomplete names, fields, types, modes, references, branching, capacities, instance limits, filters and sample rates before submitting, while retaining the editable draft. Once a completed branch command reaches the registry, the registry remains the owner of branch key validation: a schema containing BYTES, even under a collection type, returns the branch, domain and field in its diagnostic. The console presents that command failure inline and does not replace it with a local guess or silently change the selected schema. A subscription filter is parsed locally only to confirm that it is an expression. The server compiles it against the relay’s schema when it creates the subscription, and its refusal of an unknown field or scope, a type mismatch, a relay that no longer exists, or an open transaction is the subscription’s failure, shown inline without opening a tab. A predicate that does not lower or compile is a SubscriptionPredicateCompileError naming the subscription, with the VM’s failure beneath, and the failed command renders the whole chain.

Domain activation has typed failures for a relay or codec missing its schema, a codec missing its wire definition, a relay missing its branch or carrying an invalid branch TTL, and an endpoint missing its VHOST or signaling protocol. The report identifies the owning relay, codec, or endpoint and the missing reference. The control plane builds activation, resources, entrypoints, emitters, processors, message-error routes, placement and the ownership fingerprint as one typed revision before runtime installation. A planning failure leaves the previously applied schedule as the predecessor for a retry; runtime installation adds domain context and never selects a fallback configuration.

Resource planning checks the committed lookup key and codec, generator materialized source, output branch and route construction, and WASM guest-state generation before runtime binding. These failures name the owning node and relevant relay, codec, or field. A missing lookup file is rejected during candidate binding validation; malformed records remain a loader failure when the pinned file is decoded. Neither failure silently selects another resource version.

ONNX binding preflight preserves the distinction between invalid tensor bindings, an ONNX loading failure, worker refusal, worker panic and inspection timeout. Worker refusal retains the bounded executor’s report beneath the inferencer context; it does not classify an uninspected model as malformed. The thirty-second inspection bound covers admission to a bulk worker through release of the temporary session. Debug events record submission, worker entry, builder initialization, model loading and session release. A timeout also records the bulk class’s configured workers, running jobs and pending jobs, so an operator can correlate queue delay with dependency work. These are observations of the run; a successful later inspection does not identify the cause of an earlier timeout.

Runtime Installation

Installing a domain on a node, whether it builds the domain’s execution, applies a schedule delta, swaps or reassigns nodes, or applies a dynamic update, returns a report whose top context is RuntimeError::BuildDomainExecution, naming the domain. The step that failed stays beneath it as an ExecutionBuildError, which names the node, relay, lookup, codec, signaling protocol or WASM processor it concerns and keeps the step’s own report beneath itself: a domain clock that does not bind; UDFs, protobuf descriptors or a lookup that do not load; a WASM module that is not prepared; forced recovery or prepared handoff state that does not activate; runtime state that cannot be placed, assigned, restored, shared or purged; a relay, emitter or processor task that does not stop or hand off its branches; processor plans, message-error routes or reingestors that do not bind or start; an emitter flush change the running task does not apply; an execution revision without the plan of a node it names; an installed execution that is gone; and a node whose state access its assignment does not grant. A codec or signaling protocol that does not compile, an ingestor, emitter or generator that does not start, and a WASM state reset the processor’s task does not take, answer or apply keep their own reports directly beneath the domain context, because those already name what failed.

Each admitted runtime revision is applied by the session service. A committed revision that cannot be planned against the one applied before it is RuntimeError::PlanScheduleRevision, with the decision layer’s report beneath. A revision this node cannot apply fails as RuntimeError::ApplyRevision, naming the revision, above the runtime’s report, and an ingestor of a running domain that does not start once the revision is prepared fails as RuntimeError::StartIngestors above the ingestor’s start report. A failed command and the event a session receives render the whole chain, such as failed to start domain 'edge': failed to apply runtime revision 7: failed to build domain execution for 'edge': failed to load lookup 'zips': ..., and a domain whose build failed records the same chain as its instantiation error. Revision preparation and readiness timeouts keep variants of their own, which a transaction recognizes in the report’s top context to retry its application rather than fail it.

Ingestors, Emitters And Generators

Ingestor and reingestor planning has typed failures for an ingestor whose source is missing or resolves to another kind or name, a missing codec, a route or input relay missing from the domain, a route whose declared branch is not the branch of its relay, reingestor inputs whose schemas differ, a node without inputs or routes, and a filter, route or branch construction that cannot be lowered. The report names the ingestor or reingestor, the route or input relay where the contract is route-local, and the operation. Binding the lowered programs on a node has its own typed failures: a relay or branch schema the node has not instantiated, a program that does not compile against the node’s schemas, lookups, state and UDFs, and a route or input the node cannot prepare. The last carries the runtime planning failure beneath it, such as a relay without its registry or an unparseable flush or collection cadence, rather than restating it. Runtime installation adds domain context to either report, and an ingestor that fails to start while its domain execution is built records that report as its transient error. A program that does not compile keeps the runtime binding’s RuntimeVmCompileError beneath the binding context, and the domain’s binding failure renders the whole chain, ending with the VM’s own CompileError.

Starting an ingestor on a node returns an IngestorStartError report. An ingestor already running, a domain execution or codec the node has not instantiated, and a binding failure beneath the binding context are start failures of their own. Every failure to compose or open the source is IngestorStartError::Initialize, naming the ingestor and its domain, with the cause beneath it: a SourceStartError for a missing node resolver, signaling protocol or endpoint, Kafka DOMAIN offsets this node does not own, or a delivery-mode duration that does not parse, or else the report of the client configuration, connector plan, source instance or domain cadence that failed. The runtime keeps the whole report beneath its own context: the domain build’s RuntimeError::BuildDomainExecution, or RuntimeError::StartIngestors when the session service starts the ingestors of a prepared revision. The ingestor’s transient status shows the start report’s chain, such as failed to initialize ingestor 'syslog_source' in domain 'edge': invalid Syslog client config key 'framing': UDP does not use stream framing, and the failed command shows the same chain beneath the contexts above it. An ingestor that recorded no start failure of its own is not running because its domain’s execution failed to build, and its transient status shows the domain’s instantiation error instead. A build attempt clears the start failures its predecessor recorded before it can fail, so an ingestor never shows a failure a later attempt did not record.

Emitter execution planning has typed failures for missing source relays or codecs, an unresolved or mismatched client, unsupported publishing mode, an invalid source predicate or route, invalid HTTP request fields or SQS ordering group, empty or invalid row mappings, nonliteral OTEL resource attributes, and invalid Iceberg commit settings. Each report names the emitter and, for a source predicate, its relay. The decision fails before a new emitter plan or remote consumer edge is published. Binding a valid plan against installed schemas and UDFs may still fail during startup; opening an external sink may fail independently and follows the emitter’s retry policy.

Starting an emitter on a node returns an EmitterStartError report. EmitterStartError::Start names the emitter and its domain, and the step that failed stays beneath it: an input relay the node has no schema or resolved branching for, a client whose configuration does not resolve, a codec the node has not instantiated or that cannot hold a batch, a route, HTTP request fields, ordering group or FROM WHERE that does not compile, with the compile failure beneath it, and an invalid sink client configuration or input collection policy. Starting a generator returns a GeneratorError report the same way: GeneratorError::Start names the generator and its domain above an output relay the node has not instantiated, an output program that does not compile, a cadence the domain clock cannot bind, an invalid output flush policy, or a source relay without a dispatch gate. The domain build keeps either report whole beneath RuntimeError::BuildDomainExecution, so a failed command renders it after the domain, such as failed to build domain execution for 'edge': failed to start emitter 'audit' in domain 'edge': the route program did not compile: FILTER-MAP compile failed for 'audit': ....

A relay, processor, generator or emitter setting whose value does not parse is a NodeSettingError naming the setting and its value, with the duration parser’s error beneath it or the byte-size parser’s reason in its message. The node that declares the setting adds its own context above it, so the setting’s error does not repeat the node: GeneratorError::FlushPolicy names the output relay, EmitterStartError::CollectPolicy the emitter’s input collection, and MessageErrorHandlingError::FlushPolicy the message-error route and its relay. An emitter’s VALUES mapping that does not compile is a MappedValuesError naming the sink and the emitter, with the VM’s failure beneath, under the emitter’s sink initialization failure.

Stopping an emitter’s task to swap it fails as a ScheduledEmitterStopFailure, which keeps the task running beside a ScheduledEmitterStopError report: the task was unavailable, did not accept its stop command in time, dropped its answer, did not drain before its deadline, or answered that its drain failed, with the task’s own failure beneath. The swap installs the retained task again and fails with ExecutionBuildError::StopEmitter above the stop report, beneath RuntimeError::BuildDomainExecution. The emitter holds the task’s own description of a failed drain as a printable attachment, which a rendered chain does not show, so StopEmitter carries that description and its message ends with it. A later stop can still end the retained task.

Node Startup

Node startup validates execution memory limits before admitting any work. A Commands budget must hold both the bounded resident replication window and one bounded normalized command-state write; the larger requirement controls admission. Arithmetic that cannot represent either requirement is a typed execution-configuration failure. A budget below the selected requirement names the memory class, operation, configured budget, and required bytes, so the node fails startup with an actionable diagnostic instead of discovering insufficient storage capacity while applying a transaction.

Memory-pressure watermarks are validated where the node reads its options. A low watermark that is not below the high one, or a zero check interval, is a MemoryPressureConfigError carrying the configured values beneath AppError::InvalidMemoryPressureConfig, so the node does not start. The supervisor’s constructor validates the same configuration beneath MemoryPressureError::InvalidConfig, and an allocator sample that jemalloc refuses is MemoryPressureError::ReadJemalloc, naming the control it read, the epoch, stats.allocated or stats.resident, with jemalloc’s own error beneath. A refused sample at startup fails it beneath AppError::InitMemoryPressureMonitor; a refused sample while the node runs is logged with its whole chain and the supervisor keeps its current pause state until the next sample.

Startup applies each domain’s stored schedule before the node serves. A registry that cannot list those changes fails as AppError::ReadStartupRuntimeChanges, a stored graph that does not plan into an execution revision as AppError::PlanStartupRuntime, and a revision the runtime does not install as AppError::ApplyStartupRuntime; the last two name the domain. Each keeps the registry’s or the runtime’s report beneath it, so the node’s exit renders the whole chain rather than the top context of the failure.

Runtime Message Errors

The Message Error Record

A record-specific failure can become a structured message error. It carries a stable reference, machine-readable code, operation, affected field paths, occurrence timestamp, and a non-sensitive message. The code classifies evaluation, validation, external, or internal failure; the operation names the work that failed. This record is the route-policy view of the failure, rather than a copy of the internal report. The route may inspect the eligible original input, its captured materialized-state snapshot, and an all-optional partial_output of construction completed before failure. An error handler whose own construction fails does not recursively invoke itself.

Paced ingest admission reports a rejected event timestamp as a message error with code validation and operation admit. A null declared timestamp has the same classification and names its field. The host keeps the rejected row’s source metadata and ACK attached while each output route applies its message-error policy. Accepted rows from the same decoded group continue through their normal routes.

Before a domain execution or replacement becomes active, the registry selects each DLQ route and resolves its source, partial-output and destination schemas, branch declarations and flush contract, then lowers its ordered SET assignments. The runtime binds that program, lookups, state and UDFs once for that installed revision. Missing inputs, codecs or relays fail planning; missing runtime relay services, invalid flush settings or a failed VM compilation fail binding with the owning node and DLQ relay. A failed record uses the installed plan and never reads the scheduled Model or compiles its handler. If unavailable, it reports the delivery failure and does not acknowledge the source record. Binding a buffered route prepares its bounded queue; successful running publication starts the worker once and the plan retains its exact delivery handle. A failed record sends directly through that handle. Replacement cancels the preceding handle before installing the new worker and drains the earlier task, preserving its pending acknowledgements. Changing to immediate delivery or withdrawing the route also retires its buffered worker.

ON MESSAGE ERROR belongs to the route and handles record-specific work. Ingestor and emitter ON GENERAL ERROR handles node-wide source and sink failures. A WASM processor’s node-wide ON GLOBAL ERROR handles guest failures outside an individual message route. Error delivery preserves the branch in which the operation failed; ingestor errors are unbranched. See Runtime Node Error Policies, Message Errors, and Error Routes for the public behavior and syntax.

Hot paths retain typed failure variants and row or batch error masks; they do not allocate a formatted diagnostic for every row or batch when the variant already names the failure. Formatting belongs at the policy or public reporting boundary. This keeps error construction from changing the processing cadence and avoids putting source payloads in reports.

Sink And Emitter Rejections

A batch payload’s rejection becomes one message error per member, each a copy of the sink’s structured error: the members share its reference, so an operator can see that they failed together, while each keeps its own occurrence time and branch. A prepared row request’s rejection, such as an OTEL Export request the receiver refused, does the same for every row the request carried. A sink answer that breaks the write contract, by naming a record the write did not carry or answering twice for one, is not a message error of any member. Neither is a row request sink’s preparation that breaks its contract: one that answers for a row the write did not hand over, answers twice for a row, prepares rows out of source order, prepares a request that carries no row, or leaves a handed-over row unanswered. Either fails the attempt without a retry as the typed emitter error that names the violation, the preparation keeps nothing, and the emitter’s unresolved rows then follow ON MESSAGE ERROR as a failed publish.

The Sentry sink rejects a final serialized event above its decompressed event limit before sending the envelope. The Syslog sink rejects a UDP datagram above its payload limit, a stream frame whose octet count needs more than ten digits, or an LF-bearing non-transparent TCP frame before writing. A row sink — ClickHouse, Postgres, MySQL or MongoDB — and a batching OTEL sink, which prepares its requests from mapped rows, measure each request they would send. If halving still leaves one row whose own request exceeds MAX SIZE, that row receives a validation message error of the encode operation naming the measured size and the limit, such as Postgres insert of one row measures 1219 bytes, above MAX SIZE 600B, and the other rows are sent in bounded requests. A row within MAX SIZE that its destination could never accept is the destination’s external rejection of the publish operation, named with the destination’s limit: a Postgres insert above the largest protocol message the server reads, or a MongoDB document above its 16 MiB document limit, which is rejected before the write that would carry it. A SQL write that fails for a reason specific to its rows is written again one row at a time, and each row the destination still refuses receives an external publish error with the destination’s reason. That includes a violated MySQL CHECK constraint, which the server reports under the generic SQLSTATE HY000, and a Postgres cardinality violation, which is how ON CONFLICT DO UPDATE refuses one insert that carries a key twice. An OTLP receiver’s partial_success has no member identities, so it acknowledges the entire request and emits a warning instead of inventing per-record rejections. An OTLP/gRPC export the receiver never answered — a timeout, a lost connection, an unreadable answer — is an infrastructure failure the host retries, whatever code tonic reports for it. An answered status other than INVALID_ARGUMENT, RESOURCE_EXHAUSTED or a code the OTLP specification lists as retryable is a misconfiguration failure, which the host does not retry.

An HTTP emitter rejects a record at the first request field that fails, in the order it evaluates them: METHOD, PATH, and then each header write. A failed expression keeps the evaluation code and an invalid value has the validation code. Method and path failures report the publish operation and name their request field, method or path, beside the fields the expression reads; a header write reports invoke with its zero-based invocation position. A body the codec cannot encode rejects its record with the encode operation when a flush releases it. The message names the emitter and the violated rule and never quotes the evaluated value. An admitted request keeps its original source record and the materialized state its batch was admitted with until it completes, so every rejection of it after admission gives the handler the same input, state and attempted codec record that a request-field failure does.

An HTTP endpoint’s complete final 2xx headers deliver the record. Other 3xx/4xx statuses, except 401, 403, 407, 408, 425 and 429, reject only their record with code external and operation publish; 101 has the same outcome. The rejection message includes the numeric status but no evaluated destination, headers or body. The exception statuses and 5xx keep the request pending as infrastructure failures; 401, 403 and 407 name authentication or authorization in their typed cause. DNS, connection, TLS, timeout, malformed or oversized response headers, invalid final framing and loss before complete final headers are infrastructure failures as well. The connector reports them without a request URL or sensitive response value. The emitter host owns their retry schedule and keeps the prepared request and ACK lease while they are pending. A valid Retry-After on a retained status can only lengthen that schedule: it never turns a delivered or rejected record into a retry, and an invalid one is ignored rather than reported.

A batch container that cannot be produced keeps its BatchContainerError report in the packing outcome until the emitter builds the message error of each member. That message error is a fixed public outcome, so only the report’s typed reason selects its code and message: the evaluation failure beneath the reason can quote the payload it evaluated, and is not rendered.

Whole-Batch And Node Failures

A planned batch that fails as a whole, rather than one message at a time, returns a PlannedGeneralError report together with the acknowledgements of every message the batch held. The error names the step that failed: preparing lookup inputs or the input batch, executing the program, materializing an error input row or the successful rows, building the output batch, a row sidecar whose count differs from the batch, a row selected outside the input, an output relay whose schema was not prepared or does not match, an ordering group column that is missing or not STRING, or the HTTP requests of the batch. It names the program by its clause, such as FILTER WHERE, FILTER-MAP or branch construction, and the failure the step returned, such as the VM’s RuntimeError or a RuntimeSchemaError, stays beneath it. The node’s general or internal error policy renders the whole chain when it reports the failure and resolves the acknowledgements. That report already names the node, so the planned failure does not repeat it.

Branch construction keeps the outcome of each row as a typed value. A row whose branch SET recorded an error holds that SideError, and a row the program did not select says so; neither builds a report or a message until the row is reported. An ingestor route reports such a row through its ON MESSAGE ERROR policy with the evaluation code and the set operation. A reingestor route fails its whole batch with a PlannedGeneralError naming the input row, with the row’s failure beneath it. On either kind of route, a branch field that cannot be read or is null, and a program that writes no branch field, fail the whole batch.

A failure that ends a node’s work as a whole, rather than one message of it, reaches the node’s policy as a typed error_stack::Report. An ingestor and an emitter apply their ON GENERAL ERROR policy to such a failure, and so does a WASM processor whose guest instance cannot be created, whose input cannot be encoded for its guest, or whose guest callback fails. Every other processor failure, every reingestor failure and a branch dispatch task that fails are internal errors, which always fail the acknowledgements of the work. The policy renders the report’s whole chain once. The runtime event and every negative acknowledgement carry that chain after the node’s kind, its name, the policy’s class and its domain, such as junction 'enrich' internal error in domain 'orders': ..., and the log entry records the chain beside the node and domain fields. No owner formats a reason string for the policy, and the context an owner adds does not repeat the node’s name. Two contexts keep naming their node because they are also reported without the policy: a WASM instance’s lifecycle failure, which a coordinated or guest-requested state reset reports on its own, and an ingest group’s failure to hand a batch to a branch entrypoint, which the group’s flush also returns to its caller. A branch whose domain routing can no longer be read fails the acknowledgements it was handed with that routing failure directly, without a policy or a runtime event. Each failure reports its owner’s typed context above the cause it kept:

  • RouteOutputError, which every node that buffers route output shares, for holding a route’s output under its flush policy and releasing it: a domain clock that cannot be read while output is buffered or released, a flush deadline that cannot be started, inspected or waited for, a route without a flush policy, buffered output that does not concatenate, a relay without a branched entrypoint on the node, and a relay that refused the forwarded output. A clock that cannot be read while output is released fails the output of every route the node holds, as one report.
  • RelayProcessorError for a relay processor’s input and its own operation: collected input that cannot be inspected or concatenated, an unprepared FROM WHERE or FILTER WHERE program, an execution time that cannot be read, a DEDUPLICATE ON or reorderer BY key whose input cannot be built or whose expressions fail, an input batch that does not decode into messages, and output buffers that do not match the routes.
  • CorrelatorError, WindowProcessorError and InferencerOutputError for the steps of a correlator, a window processor and an inferencer, and WasmInstanceError and WasmOutputError for a WASM processor’s instance and the output its guest emitted. The VM, Arrow, ONNX or relay batch failure stays beneath. A WASM output route’s FILTER-MAP reports the PlannedGeneralError step a planned batch reports, and the callback whose output did not forward is named by its kind. Window statistics reject non-finite arguments or results at their respective boundaries. Overflow of a finite-input intermediate sum is handled by the moment kernel’s normalized mean, so it does not itself classify the window as failed; constant overflow-scale runs keep zero centered moments. SIMD Kernels owns that numerical path and its reference bounds.
  • ProcessorBranchTaskError, BranchEntrypointError and ReingestorError for the concrete branch work of a processor, a branched entrypoint and a reingestor: the domain time of accepted input that cannot be read, a branch that cannot be instantiated, a branch task that is gone or whose dispatch task failed, and input a processor receives before it has restored its branches.
  • EmitterRuntimeError for an emitter’s own processing: a publish batch whose rows stopped agreeing, a source filter that failed for its named input relay, and a batch whose publish failed but which cannot be split into the messages its message-error policy decides, which keeps the emitter’s description of the publish failure.

The relay interaction a node consumes its inputs through fails with a RelayInteractionFailure: a RelayInteractionError report together with the acknowledgements of every batch the interaction still held. A failure to collect names the relay, with the domain clock, deadline or concatenation failure beneath it, and a deadline the interaction cannot resolve is its own variant. The consumer reports the failure under its own policy. An emitter whose own wake deadline cannot be resolved keeps its buffered work and records the failure as its transient error instead, because no wall-clock fallback exists for a logical cadence.

A branch runtime, processor branch, generator, reingestor, emitter or relay task that stops because it cannot bind or read its domain clock or routing snapshot, or cannot wait for one of its deadlines, reports a runtime event that names the task and its domain and renders the whole chain of the failure, so a stale clock generation or an unrepresentable deadline beneath the clock failure stays visible.

An owner-delivery admission failure logs the domain, relay, branch scope and target together with the transport report, including retained cancellation and rejection causes, before returning the undelivered batch. This keeps admission and connection failures visible even when the batch carries no acknowledgement, without logging branch field values.

Cross-Node And Public Boundaries

Interconnect

The duplex writer’s typed TransportError::ProgressTimeout means that a frame awaiting send capacity received no nonzero flow-control grant for its write progress bound. It does not bound the total time to transfer a frame. Expiry resets the exact stream; steady byte progress preserves the stream even when a large frame spans several progress intervals.

The interconnect owns three kinds of failure. TransportError is a connection, TLS, framing, limit, deadline or relay delivery failure of the transport itself. RequestError is a typed request that could not be admitted, encoded, delivered or answered, with the peer’s own RemoteRequestFailure when the peer’s transport refused it. RemoteOperationFailure is the answering node’s classified result of an operation it received, Rejected, Unavailable, NotReady or Failed, with a subject that is a domain, an entity, a state or a subscription interest. Failure Ownership And Persistence owns what each means for a requester.

The interconnect validates and bounds the wire request before its operation handler runs. A delivery correlation that has no free position reports CorrelationCapacity; an exhausted generation reports CorrelationIdentityExhausted; failure to reserve record storage reports CorrelationMemory. A receiver whose relay budget has no room for a batch’s acknowledgement watches holds the batch back unadmitted rather than failing it, because that lack of room is temporary. It reports RemoteAckAdmission, with the typed AdmissionError beneath it rather than rendered into its own text, only for watches larger than the whole budget or a hold still without room when the five-minute admission bound has passed, before runtime admission. These refusals judge no payload and preserve source retry ownership. Ending a registered delivery before admission or shutting its owner down resolves its held shares negatively once. Stale generations and registrar runs are ordinary unmatched reports logged at debug, and never resolve current work.

A transport error describes connection, admission, framing, deadline, or delivery failure. A remote control response instead preserves a typed failure subject and one of four classes: rejected by the answering node, unavailable there, temporarily not ready, or executed and failed. A requester can use class and subject for routing, retry, and recovery without parsing text. Only an executed failure carries the answering node’s opaque operator description. That text is an explicit wire boundary for an already classified failure; it is not used to recover a new class. Runtime-state replication, a replica’s branch checkpoint listing included, and materialized-snapshot description use this envelope, and local errors retain the remote class alongside their target and placement. Checkpoint and branch catalog request failures keep the typed interconnect request cause beneath that target and placement context. Their replica diagnostics render the cause chain, distinguishing deadline, admission, connection and framing failures without retaining checkpoint payloads. The checkpoint description carries only revision, length and digest. Its subsequent bulk fetch retains the target and placement context on a transport, memory admission, declared-length, truncation or digest failure; none can publish or acknowledge a partial checkpoint. An ownership handoff destination converts the same fetch failure to a checkpoint preparation failure before persisting or activating the candidate state. Diagnostics include the revision and lengths when useful, never guest bytes. Kafka offset replica catch-up uses the same typed envelope for its revision description. A subsequent bulk stream failure remains a transport request failure, with the target, placement and typed staging, admission, verification or native conversion cause retained beneath it. The replica diagnostic prints that cause chain. Cancellation or failed conversion publishes no partial table, and an assignment-token refusal keeps a delayed transfer from overwriting a promoted owner. A commit that lacks its required replica acknowledgement still reports the existing quorum deadline; catch-up retries do not turn that deadline into a successful commit. A stopping node’s stopping_node_drain request answers with outcomes of its own instead, because its only subject is the authenticated sender: completed or failed, each with the leader’s report for the sender’s log, or not the leader, which sends the sender to the leader it observes next. A node that receives the request without leading changes nothing. If leadership is lost during a drain, the interrupted report remains in the former leader’s log and the new leader continues from the committed schedule within the original remaining budget. This classification uses the typed command disposition and the retained consensus leadership observation; it never interprets report text. A failed drain while the answering node still leads remains failed. When that request fails in transport, the drain counts as requested but unanswered: the sender reports its drain-support phase abandoned and still clears the cordon the request may have set; see Topology Cases. A unit whose ownership handoff cannot prepare within its share of the stopping node’s drain budget appears in the leader’s failed drain report with its kind, name, former owner and typed entity-gate cause, including pending node and work counts when quiescence timed out. Exhausting the remaining unit budget is reported as a failed drain instead of waiting for the sender’s outer timeout.

Schedule eligibility compares live voter identities and the placement candidates after the captured cordons and terminating flags are applied. A cordoned former owner’s later terminating advertisement is an unchanged planning input. A changed live incarnation or eligible destination still reports SchedulePlanningStale::Eligibility; changed membership or cordons is rejected by the separate topology fence.

Relocation validates membership and liveness, then reports a captured terminating incarnation as RelocationError::DestinationTerminating, then a committed cordon as DestinationCordoned. The planning snapshot captures termination independently from the composed placement destination set, so a cordon cannot be interpreted as termination and a terminating cordoned voter retains its termination reason. These diagnostic facts do not add a publication fence for an already excluded former owner.

A handoff confirms closed admission while its local admitted work and flush can still be pending. The coordinator’s exact-operation drain query retains the pending node and counts; exhaustion reports DomainAlterError::EntityQuiesceTimeout and releases the held scope. Independent schedule units remain available to continue, and a subsequent attempt can retry the stalled unit. A gate that expires during engagement reports EntityGateOperationError::EngagementExpired and releases its partial intake and relay holds. The preparation deadline bounds engagement, drain and capture independently of the activation lease. A typed reply timeout can retry the same engagement identity and scope within that budget; rejection and other request failures keep their classification.

A listing that arrives but names a branch key that does not decode is a failure of its own, distinct from a failed request. A relay payload that does not decode is RuntimeError::DecodeRemoteRelay, naming the domain and relay, with the RemoteRelayDecodeError that says what the payload got wrong beneath it: no admission registration, a body that is not one Arrow section of the relay’s schema, metadata or acknowledgement sidecars whose row count differs from the body’s, acknowledgement registrations on a subscription fan-out, a branch key that does not decode, or rows that do not assemble into a relay batch. The Arrow body, BranchKeyError or relay batch failure stays beneath that. An Arrow body that is not one canonical IPC stream framed within its own bytes is ArrowBodyError::Framing, naming the IpcFramingDefect: a message without its continuation marker, a stream cut inside a message, a negative length, metadata that is not an Arrow message, a column buffer outside its message’s body, or bytes behind the end-of-stream marker. The decoder refuses it before it allocates or reads anything from a declared length, for a relay body and for an Arrow section of a sealed snapshot or a backup archive alike. A client batch names the same defects as the reason of its Malformed defect. A stream that is framed within its bytes and declares what Arrow’s reader would panic on, a field type Nervix does not carry or a record batch at odds with its own schema, is refused by the same scan as ArrowBodyError::Decode, a defect of the body, before the reader reads it. Should the reader still panic on a body the scan admitted, that ends its decode job, and the decoder reports it as ArrowBodyError::Decode too rather than as work the node could not execute: decoding the same body again would panic again. A decoded batch the local relay boundary refuses is the separate RuntimeError::DispatchRemoteRelay. Remote payload handling returns these as error-stack reports: the receiver logs the whole chain, and a payload it refused before admitting it is answered with the chain rendered as the reason. Cluster Interconnect defines the exchange forms, limits, deadlines, and relay acknowledgement boundaries. A record acknowledgement lost between two nodes becomes an ordinary negative acknowledgement: the node that forwarded it fails it once the receiver has reported nothing about it for fifteen seconds, with a reason that names the silent receiver, and the source retries the record as it retries any failed acknowledgement; see Record Acknowledgements The Receiver Stops Reporting. Local interconnect transport, typed request, and streaming-handler failures carry reports through their callers. A layer that changes the failure’s meaning adds context to the existing report, so the caller can still inspect the transport or producer cause. The HTTP/2 and rkyv boundary sends the classified remote result or its rejection text, rather than serializing the local cause chain.

sequenceDiagram
    participant Requester
    participant Wire as Interconnect
    participant Owner as State owner
    Requester->>Wire: Request snapshot description for placement
    Wire->>Owner: Validated, bounded request
    alt Ownership moved or state not ready
        Owner-->>Wire: Rejected or NotReady with typed subject
        Wire-->>Requester: Classified remote result
        Requester->>Requester: Apply dependency absence policy if appropriate
    else State operation failed
        Owner-->>Wire: Failed with subject and opaque description
        Wire-->>Requester: Classified remote failure
        Requester->>Requester: Retain failure in local report
    else State available
        Owner-->>Wire: Description with size, digest, revision and fence
        Wire-->>Requester: Successful description before bulk transfer
    end

Replication Routing

Replication frame routing treats an absent state, an ended route or a replaced assignment as an ordinary non-admission: it creates no state and records no replica progress. Synchronization request admission retains the existing rejected-assignment outcome. A WASM reset lifecycle wait reports the existing typed superseded-state failure when its retained assignment loses identity or primary ownership, the existing replica-plan-shrunk failure when its promised boundary loses replicas, and the existing replica-confirmation failure when its deadline expires.

Consensus

A proposal, a linearizable read or a membership change returns a ConsensusError report, and its variants are the classes a caller routes on. LeadershipLost carries the leader this node observes, when it knows one. RaftStorage is a fatal storage error Raft reported for a write, and Storage a failure of the node’s own consensus store. RaftWrite is any other failed write, Conflict a change the replicated state machine refused, and startup, transport, linearizable-read and membership failures have variants of their own. The class of a failed write is decided once, from the type of the Raft error: an answer that forwards to a leader is LeadershipLost, a fatal storage error is RaftStorage, and everything else is RaftWrite. The Raft or I/O error stays beneath the context as the report’s cause.

The session edge reads the current context of a proposal’s report. LeadershipLost becomes a NotLeader disposition with a redirect to the observed leader when nothing of the command was admitted, and OutcomeUnknown with leadership loss as its cause when leadership moved while the command’s admission was being decided or after it. Any other class fails the command with the operation and the consensus owner’s message: the context’s text, followed by the storage or Raft error beneath it for a storage or write failure.

A refusal of the replicated state machine is data every voter must agree on, so it is a typed value in the Raft response and never a report. ConsensusConflict distinguishes an expired execution reference and a conflicting one, with the kind of the conflict, from a refusal described by its reason. TransactionMutationError is the refusal of a transaction mutation, such as an unknown or finished transaction, an owner or position that does not match, a stale preview, or a lost domain mutation fence. The state machine evaluates a mutation with reports and clones the report’s current context into the response where the response is built; the report’s frames stay on the node that evaluated it. The proposer wraps the typed refusal in a new report as ConsensusTransactionError::Mutation, beside Consensus for a proposal that failed and InvalidResponse for a response of another kind.

The durable store’s own failure is a StorageFailure: a write beyond its admitted byte budget, a record that does not encode, a failed database write, stored state of an unrecognized shape, a superseded snapshot generation, and Stopped for every operation after a failed write, whose message tells the operator to restart the node. Appended Batches owns what a failed write means for the requests that waited on it.

Commands And Sessions

At the public edge, the session maps a typed validation or execution result to a command disposition, message, and diagnostics; a transaction’s admitted and retained outcomes stay distinct from a new execution. Replicated admission preserves an expired execution reference and the kind of a conflicting reference as typed consensus conflicts. The session returns ExecutionReferenceExpired or ExecutionReferenceConflict from those variants, including when a leader change lets the replicated check discover the conflict after the leader’s local check. A client never has to classify those refusals from message text. Consensus reports also keep Raft leadership, fatal storage, and other write failures distinct through the control plane. A command attempt that returns OutcomeUnknown, a leader redirect, or TransactionDetached preserves that disposition at finalization and leaves its admitted execution applying. Recovery determines its terminal outcome under the same reference, including during reconciliation. A transaction mutation refusal crosses the Raft response as its exact typed outcome and becomes a new report on the proposing node; the client still receives the same disposition and acknowledgement semantics. Command Dispositions defines each disposition, the phase that produces it, and what a client may conclude from it, and typed request rejections are covered in Rejections. DESCRIBE TRANSACTION and SHOW TRANSACTIONS read beside an attached transaction only as separate requests; combining either read with another statement returns a session planning diagnostic before anything enters the queue. An incomplete impact report carries its planning diagnostics and cannot supply a commit preview. A stale preview is a recoverable command disposition that applies no effects and tells the client to refresh its inspection before retrying COMMIT. Transaction Quiescence And Impact Inspection defines the planned and actual report outcomes these diagnostics describe. A buffered ingestor’s intake-only drain uses the same typed entity-quiesce timeout, naming the domain, pending node, work counts, and outstanding ACK roots. Its failure releases the attempted hold and retains the committed endpoint contract. A failure while releasing that intake hold keeps the typed release cause beneath the model alteration’s gate error; it cannot publish the replacement. Parse diagnostics retain precise expected and found tokens and byte spans into the submitted source for a client to underline, whichever statement of a batch was rejected. Validation diagnostics attach a source span when the relevant identifier is present in the submitted text; failures without a source location have an unlocated diagnostic. HTTP endpoints choose their response status at the boundary according to the request and failure category. For example, HTTP ingestion returns 202 Accepted after admission and 503 Service Unavailable, with Retry-After when provided, when the runtime cannot admit the payload; malformed requests and unknown paths are rejected separately. The resource upload edge maps classified archive quota failures to 413, invalid archives to 400, and unclassified store failures to 500. Sessions describes client command and delivery behavior, while Inspecting A Transaction describes retained command outcomes and ALTER Lock And Quiesce Classification describes impact inspection.

The session service’s own edges report the same way. A gRPC call whose credentials do not authenticate it fails as a GrpcAuthenticationError report, Required, Failed or Busy, and the call ends with UNAUTHENTICATED, or with UNAVAILABLE when the node could not verify the credentials now; the status names only the refusal. A command on a transaction its session is no longer bound to fails as a SessionTransactionBindingError report, whose current context selects the TransactionTakenOver or TransactionDetached disposition and whose chain is the command’s message. A domain snapshot that cannot be encoded for a session is SnapshotEncodingError::Graph or Frame above the graph serializer’s or the frame encoder’s report, and the node logs that chain instead of sending the snapshot.

A model alteration that pauses its domain reports each step of the pause and the resume above the report of the owner that failed: DomainAlterError::PauseDomain and ResumeDomain above the consensus report of the domain’s lifecycle change, and StopIngestion and RestoreIngestion above the runtime’s report of the cluster state it could not apply. When the alteration fails after the pause and resuming the domain fails too, ResumeAfterAbandonedAlter keeps both reports beneath it, the alteration’s failure first, so a consensus leadership loss in either still answers the command with a leader redirect. The failed command, the session’s error broadcast, a backup’s capture failure and the step’s impact diagnostic render the whole chain.

Domain Clock Attachment

A domain clock attachment answers with its own typed disposition rather than a command disposition. An attach is Attached with the observed clock, AlreadyAttached when the session already follows that domain’s clock, DomainNotFound when the committed domains the serving node installed hold no such domain, or Failed; a detach is Detached, NotAttached when the session does not follow that clock, or Failed. Every disposition but Failed names its domain. Failed carries only its message, and the server answers it only while the session holds a transaction. A node that is still starting answers an attach only once it has installed the committed domains, so DomainNotFound never reflects a restart. While the session holds a transaction, both fail with the session-local refusal that other session-scoped statements receive. The server ends an attachment with a frame whose typed reason is DomainRemoved, and the Rust client reports a lost session as an interruption of each clock it follows before it attaches again. An attach the new session refuses is not an error of any call: the Rust client reports it as a typed restoration failure carrying the refusal’s message and the wait before it tries again, and reports a refused subscription reopening the same way. An attach the new session leaves unanswered past the request timeout ends that session instead, and the next session attaches the clock again; one it answers DomainNotFound ends the attachment as the server would. In the shared C binding a restoration failure carries only its domain, without the message or the wait. Each disposition’s message is display text for a client that prints it; a client decides from the variant. The Rust client’s execute turns any disposition but Attached or Detached into a Failed command outcome carrying that message, and its clock helper reports a stopped or uninstalled clock, or a projection outside the timestamp range, as a typed DomainClockReadError. When execute runs either statement, it fails with NoActiveDomain while no domain is selected and refuses the statement as client-local while a transaction is active, before it sends a request. Waiting for the next clock event returns a failure to reopen a session while the followed clocks keep waiting to be restored. The same statements sent as command text are refused as session-local commands that need their own request. See Domain Clock Attachment.

On the serving node, a state, tick or end frame that does not fit the session’s frame limit is not sent and is logged as a warning: delivery waits for the next state or tick, and an end it could not send still ends the attachment. Accepting clock progress fails only for a domain the node does not hold, and a report that does not match the committed fence, or is not newer than the accepted tick, is ignored without an error. That failure reaches the node’s sessions as the error notice failed to apply domain clock progress for '<domain>' with its whole chain, and its log as a warning.

The CLI’s domain-clock subcommand classifies attach refusals from those variants. A missing domain and an already attached clock have distinct typed CLI errors. An attach or detach answered Failed is ClockAttachRefused or ClockDetachRefused with the server’s message attached to the report, and exits nonzero; a detach answered NotAttached succeeds, because the clock is not followed either way. Transport or session failures while attaching, reading events, or detaching retain their underlying report beneath the CLI operation that failed.

The web console shows an automatic attach refusal in the clock panel and event log without retrying it. If its bounded request hand-off refuses a clock request before the session sends it, the console reports that local refusal in the event log; an automatic attach also leaves the panel in the refused state until the selected domain or connection changes.

If a paced clock cannot convert one period through its rate, the authority can still emit its already-due first tick. Scheduling a later tick then reports a rate-conversion or cadence error and stops production. A next-boundary overflow reports its own clock arithmetic error. None of these cases emits an early tick or silently clamps the interval.

Rust Client, CLI And C Binding

Every call of the Rust client returns an error_stack::Report<ClientError>. Its current context is the failure a caller acts on, and the frames beneath keep the cause: a call’s transport status, the wire codec’s report beneath EncodeRequest, InvalidUploadReply or InvalidRestoreReply, the name’s report beneath InvalidResourceName, a local archive’s I/O error beneath BuildUploadArchive or ReadRestoreArchive, a suggestion request’s value report beneath InvalidCursor or InvalidCompletionPageSize, the event queue’s overflow beneath EventOverflow, and the BackupDownloadError beneath BackupDownload. A failure that may hide an admitted command or an installed upload is UncertainCommand or UncertainUpload above the failure that left the outcome unknown. The client decides retries, session recovery and uncertainty from those typed contexts and never from text, and a context names its own operation without repeating its transport status, which the next frame shows. The CLI adds the operation it ran above the client’s report, failed to connect to the server or the request to the server failed; its text and JSON failure reports, its JSON inspection errors and its event-stream notices render the whole chain. A restoration failure the client reports as a subscription or domain clock event carries the whole chain as its message. The shared C binding classifies a failure from the current context, a failed backup download from the BackupDownloadError beneath it, and returns the whole chain as the error’s message.

Resource archive construction returns its first I/O failure beneath BuildUploadArchive. Failure and cancellation abandon the partial temporary archive; they perform no destructor finalization that could replace the cause, panic, or start asynchronous cleanup. If the enclosing command deadline cancels local construction, the caller receives its typed RetryDeadline. The client opens no upload call until archive finalization and flush have both succeeded.

A Rust client subscribe or unsubscribe runs on a task of its own, so that an attempt its caller stops waiting for still completes. Its caller classifies that task’s report by its current context, and recovers the session and sends the request again exactly as for any other call; a failure a new session cannot remedy is returned as that report, with its own classification.

The native Rust session client loads its Hickory resolver before opening a server channel. An unreadable or invalid resolver configuration is ClientError::LoadDnsConfiguration above the resolver’s configuration report; the shared binding classifies it as a connection failure. A failed lookup within an initial, seed, redirect, or reconnect attempt is ClientError::ConnectServer. Tonic retains DnsLookupError in that transport error’s cause chain, so callers can inspect its host and typed failure. The outer session retry deadline can instead end the wait as RetryDeadline. Connection timeouts and TLS name failures remain connection failures and do not become command dispositions. OTEL gRPC reports a failed lookup or connection through its existing infrastructure export failure; the emitter host keeps the batch and its acknowledgement under the declared retry policy. No record rejection is inferred from DNS.

Native endpoint recovery keeps failure states distinct. A lost producer submission resolves to ProducerOutcome::OutcomeUnknown(SessionLost) when its frame was sent but no outcome arrived; the client never calls that batch not admitted or replays it automatically. A consumer read crossing a session gap returns ClientError::ConsumerInterrupted before any batch from the replacement attachment. ClientError::ConsumerReopenRequired names a changed, stopped or removed endpoint that needs a fresh application open; ConsumerSessionUnavailable means the bounded reconnect attempt did not establish a session. A delivery from a revoked attachment returns DeliveryReferenceExpired before settlement, while SettlementUnknown means a settlement request may have reached the server but its answer was lost. The application must resolve such an ACK with its own idempotency policy. None of these errors claims that a downstream effect did or did not occur.

Before a restarted serving node has proved linearizable catch-up, both native endpoint opens use the ordinary retryable EndpointUnavailable refusal. They do not report a missing or stopped domain from that node’s stale local snapshot as a terminal application error.

The shared C binding converts a clock-event wait’s error_stack::Report<ClientError> at its reporting boundary. It classifies the typed current context as an NX_ERROR_* kind and retains the report’s contextual message. A cancelled or expired wait returns NX_ERROR_CANCELLED or NX_ERROR_DEADLINE without writing an event handle. Clock accessors return NX_ERROR_TYPE when the event kind or installation state lacks a requested field and leave outputs untouched; generation, state, and end-reason accessors require a non-null output pointer. The projections of an nx_domain_clock convert the Rust client’s error_stack::Report<DomainClockReadError> the same way: a stopped or uninstalled clock is NX_ERROR_TYPE, because it holds no logical time to read, and arithmetic outside the timestamp range is NX_ERROR_INVALID_ARGUMENT, because the instant the host passed is out of range for that clock. An attach refusal is not an error of the call: it completes with NX_DISPOSITION_FAILED and the server’s message, and nx_session_domain_clock reports whether the session follows the clock afterwards.

Producers And Consumers

A producer answers with typed values rather than command dispositions. A refused open carries a ClientProducerRefusal, every submitted batch one ClientSubmissionOutcome, and an ended producer one ClientProducerEndReason; see Producers. The outcome classes are the failure classification for a batch, and a client decides from them alone: NotAdmitted guarantees that no row entered the graph, ProcessingFailed reports an admitted batch whose acknowledgement failed and may have had effects, and OutcomeUnknown reports a batch whose effects cannot be established. An invalid batch names one ClientBatchDefect; its message and the detail of a processing failure are bounded to 1 KiB, never quote a payload value, and are display text only. A temporary refusal, Suspended or Busy, is an ordinary outcome that a producer resends on its declared backoff, not a failure. A node that cannot reserve memory or a worker to validate a batch answers Busy rather than holding the batch, and a batch that fails after it was dispatched resolves its acknowledgement root negatively, which the producer receives as ProcessingFailed with Rejected.

A consumer open is either Opened or a typed EmitterOpenRefusal for the domain, emitter kind, schema, execution availability or capacity. A settlement is an ordinary typed outcome: Confirmed, StaleReference, WrongConsumer, InvalidReason, or ConsumerEnded. It is never inferred from a timeout or from reading a batch. A bounded application rejection reason is treated as non-sensitive display text and applies the emitter route’s message error policy to each member. IPC encoding failures and an output row above the declared byte limit follow that policy, without quoting the row. Owner or forwarder loss revokes the attempt; the delivery remains volatile, and a client must not interpret a lost ACK reply as successful processing.

The shared C binding converts the producers’ and consumers’ reports at its reporting boundary as it converts a clock-event wait’s. A refused open is NX_ERROR_REJECTED, and nx_error_open_refusal reads its typed refusal. A batch built for another schema, or with too many rows or bytes, a builder input that does not fill its level or is not UTF-8, and an identity the producer does not hold are NX_ERROR_INVALID_ARGUMENT, and nothing is sent. A stream a host submits is not checked before it is sent: the producer answers it with its outcome, which a submission reports as a value rather than a failure, NX_SUBMISSION_NOT_ADMITTED with its batch defect for a stream that is not the canonical one. A closed or ended producer is NX_ERROR_CLOSED, and a consumer read past its close is too. ConsumerInterrupted is NX_ERROR_INTERRUPTED and ConsumerReopenRequired or a producer that must be opened again is NX_ERROR_REOPEN_REQUIRED, read with the handle’s reopen reason; a consumer whose bounded reconnect failed is NX_ERROR_CONNECT. A settlement of an expired reference is NX_ERROR_REJECTED, and SettlementUnknown is NX_ERROR_UNCERTAIN. A cancelled or expired wait returns NX_ERROR_CANCELLED or NX_ERROR_DEADLINE without writing its output. A submission cancelled that way sent nothing, and an outcome wait leaves its submission with the producer. A read stays with the consumer, which hands its reply to the next read, and a settlement may still have reached the server.

The paced simulation drivers classify a refused replacement producer or consumer as a configuration error and exit with status 2, retaining the typed refusal or schema diagnostic. A consumer’s refusal ends input planning and interrupts outstanding outcome, rejection-notice and producer-close waits, including after planning finished: batches whose output contract cannot be consumed cannot finish that close. The application retains their ledger entries for an explicit --replay; reopening never resends an unknown submission automatically.

Backup And Restore

Backup

A backup’s failures are owned where they are decided. The archive format reports an ArchiveWriteError for a record that does not encode, a record above the 64 MiB record limit, a streamed record whose caller stopped it or whose destination failed, a section path a tar header cannot name, or bytes that differ from the manifest entry they were written for, and an ArchiveReadError for an archive whose first entry is not the manifest, a record with a foreign magic, kind, or format version, an invalid record value, a missing, misplaced, unexpected, or out-of-order section, and a section whose length or digest differs from the manifest. Each names the section path and the check as typed fields, and none carries section bytes. The control plane’s backup execution reports a BackupError: no configuration yet, no selected or no existing domain, models that are not a valid graph or do not render or parse back to themselves, a clock mapping that cannot be projected, a resource version that is missing on the leader or differs from its catalog entry, a record that does not encode, and an archive the staging area cannot hold. A quiesced capture also names its domain when the mutation lease, pause, drain, owner capture, or resume fails or times out, or when its coordinator loses the leader tenure under which it acquired the cut. Owner capture failures are classified at the interconnect boundary without guest bytes in the failure. A branch lifecycle or Kafka offset section names its entity when its checkpoint does not decode, its serializer scratch or conversion cannot be admitted to restore_metadata, or its record cannot be written; the failure renders every context of its report. A selected WASM guest section likewise names its processor when its checkpoint read, restore_metadata admission, or staged write fails or is interrupted; no failure includes guest bytes. Stored materialized capture refuses malformed headers, inconsistent group or row counts, oversized identity or column frames, truncated checkpoints and failed stored chunk digests. These typed codec/storage failures follow the same domain capture failure path without column bytes. An owner still applying the selected revision waits within a five-second bound; a closed applied-state authority or an expired catch-up wait is a domain capture failure. A leadership change during that wait refuses the capture before state is read. The failed command’s message is backup failed: followed by that error’s text. A download the server does not serve is answered with a typed refusal, InvalidRequest, NotRetained, Expired, NotOwner or ReadFailed, or with a redirect to the leader, and a call without valid credentials ends with UNAUTHENTICATED. The client reports a BackupDownloadError beneath ClientError::BackupDownload, which carries the backup’s execution reference: the server’s refusal, a transport failure, a stalled or interrupted stream, a missing leader or a redirect loop, frames out of order or undecodable, an archive that differs from the backup’s summary, or a local write failure. An undecodable frame and a request that does not encode keep the codec’s report beneath InvalidFrame and EncodeRequest, rather than a copy of its error in the context. Only a transport failure, a stall, and an interrupted stream are retried, from the archive’s first byte. The C binding classifies a refusal as NX_ERROR_REJECTED, a transport failure as NX_ERROR_TRANSPORT, a mismatched or malformed archive as NX_ERROR_PROTOCOL, and a write failure as NX_ERROR_INVALID_ARGUMENT, and names the execution reference so a host can run the backup again. No diagnostic of a backup includes archive contents, password hashes, or resource bytes.

The native backup command wait is bounded independently of each domain’s quiesce budget and the archive’s per-frame stall bound. An exhausted command wait reports ClientError::UncertainCommand with the durable reference. The CLI’s JSON BACKUP_FAILED report includes error.execution_reference for that uncertainty and for ClientError::BackupDownload; its text report names --execution-reference as the recovery option. Reusing that reference preserves the server’s conflict, expiry and retention authority.

The CLI’s delivery of a downloaded archive to standard output has failures of its own, which follow the complete download that released the server’s copy. A staged archive that could not be read, or a write to standard output that failed, is WRITE_FAILED: the report keeps the typed error and its I/O cause, and names the durable reference with the verified archive the CLI kept, error.archive in JSON, as the recovery, because running the backup again cannot download a collected archive. A staging directory that could not be removed after every byte was delivered is CLEANUP_FAILED, which names the reference and the directory, error.staging in JSON. A staging directory that could not be created is WRITE_FAILED before admission, without a reference. So is a standard output that would discard the archive, checked before anything is staged: the null device, and a standard output that was closed when the CLI started, which the CLI finds holding the null device and cannot tell apart from it. A standard output the CLI could not inspect is WRITE_FAILED before admission as well, with its I/O cause.

The web console owns its own typed download and restore failures. A download failure names the server’s refusal, a transport failure, a stalled or interrupted stream, a missing leader or a redirect loop, frames out of order or undecodable, an archive that differs from the backup’s summary, or an archive the browser could not save; it retries a transport failure, a stall, an interrupted stream and ReadFailed from the first byte, and reports the rest as the reason the completed backup’s archive was not downloaded, never as a failure of the backup. A restore stream failure names an archive file the browser could not read or that is empty, a transport failure, a stall, a missing reply, a reply that does not decode or answers another request or reference, or a restore whose outcome stayed unknown through its repetitions, which the dialog reports naming the execution reference. A refusal of the stream is shown as restore refused (<failure>): <message>, and the restore’s own outcome as the dispatcher renders any command’s. The console WebSocket transport closes a call whose client broke its framing with the codec’s close code, a second download request with 1008, an answer that does not fit a frame with 1011, and every call when the node stops with 1001.

A captured-section opening refused only for Snapshot request capacity retains its inventory and retries within one 30-second opening deadline. The typed capacity classification determines this retry; other request failures end the fetch. Deadline expiry remains a capture failure, and an admitted or partially consumed response is never reopened by this admission retry.

Stored branch capture keeps storage and admission failures beneath BackupStateCaptureError, with Keyspace or Window naming the entity at native conversion. Mapping admission is decided before a file is mapped and before a group allocates its owned values. Keyspace execution refusal uses DeduplicatorArchiveError::Admission with the executor report beneath it; it does not classify an unexamined checkpoint as undecodable. A cancelled or refused reader releases its view, staged files and reservations, and the capture owner discards the incomplete section inventory.

Restore

A restore’s failures are owned where they are decided, in the order the restore meets them. The restore stream refuses what its frames get wrong with a typed RestoreUploadFailure: InvalidStream, InvalidStatement, SizeMismatch, DigestMismatch, QuotaExceeded, or StagingFailed, and a call without valid credentials ends with UNAUTHENTICATED. The control plane’s RestoreRefusal then names an archive the leader could not read, an unavailable or unaddressable retained preparation reservation (MetadataAdmission, with the executor’s typed admission failure beneath it), one that does not verify, with the archive format’s ArchiveReadError beneath it, a domain whose models.nspl does not parse, with the line and the parser’s diagnostic, a statement that creates no model, with its number and line, a restore that cannot apply to this cluster, and a domain whose models do not form a valid configuration, with the transaction planner’s report beneath it.

BranchKey names the archive section, the lifecycle entry or descriptor, the entity and the domain of an archived branch key that is not a key of the branching its restored entity declares, with ArchivedBranchKeyError beneath it: a key that is no typed branch key, with the runtime’s BranchKeyError naming the field beneath that, a key that is unbranched where the entity runs in a branch or concrete where it runs unbranched, a key of none of the branches an ingestor’s or reingestor’s routes write, or a key that is not a key of the entity’s one branch, with BranchKeyShapeError naming the missing, undeclared or mistyped field and RuntimeValueTypeError the declared and found types beneath it. UndeclaredBranching names an entity for which the restored schedule resolves no branching, with RestoredBranchDeclarationError beneath it. Installation repeats both checks before it stages any state and keeps the same chain beneath the failed step.

Beneath a restore that cannot apply, the decision layer’s RestorePlanError names the domain, user, resource, version, or model: a domain archive given to RESTORE CLUSTER, a domain the archive does not hold or the cluster already has, an archived user the cluster has under ON EXISTING USER FAIL, a resource the domain does not declare, a version outside its declared sequence, completed without checksums, or without its bytes, bytes that do not match the version’s root checksum, and a model that binds a version other than a restored one by number. Each of these is reported as restore refused: and its reason, and changes nothing.

Materialized archive descriptors and identities reject invalid counts, names, typed branch fields, watermark order and supported record headers before runtime installation. RestorePlanError::MissingClock refuses a paced RESUME without its committed mapping. RestoreRefusal::MaterializedState names the domain and relay when preflight conversion fails; MaterializedRestoreError distinguishes invalid section lengths, identities, a record identity whose branch key is not a key of the relay’s branching (BranchKey, naming the section and the identity), Arrow schema or row counts, metadata limits, admission, cancellation, framing and staging. A preflight admission refusal records no restore progress and can be presented again; it is reported as preparation refusal without judging the archive invalid. Native MaterializedSnapshotError checks framing, metadata bounds, unique keys, counts, exact schemas and complete container consumption. Diagnostics carry typed causes and entity identities, without payload columns or branch field values. Replica installation refuses a revision older than its currently installed materialized revision.

Once admitted, a step that fails ends the restore as restore failed at step '<step>': and its reason, with the restore’s report: the consensus command that records a step refuses it with a RestoreStepConflict naming the step and the domain, user, resource, or version, and a resource import, domain model batch, or state installation keeps its own failure beneath the step. The steps before it stay applied, and the message says so. RestoreStateInstallationError distinguishes an incomplete installation that blocks starting a domain from authority that no longer permits mutation. The runtime store reports a stale or competing published generation as RuntimePersistenceError::RestoreGeneration.

Native conversion keeps its codec failure beneath SnapshotStagingError::Encode and the restore step; cancellation and encoding beyond admitted disk quota discard the temporary artifact while retaining its memory and quota until the job actually exits. NativeEncoding identifies the native state kind and retains the serializer’s typed cause. Staging Create, Write and Read also retain the underlying I/O cause beneath a semantic context. Window reports the requested position and length and the artifact’s exact length without overflowing a diagnostic sum or exposing checkpoint contents. InvalidCheckpointChunks covers a missing, misordered, truncated or digest-mismatched current chunk set or a conflicting publication inventory. RestoreRead, Cancelled, Synchronize and storage admission preserve their owning failure boundary. CheckpointPlacementTooLarge rejects an encoding beyond the bounded storage key allowance. InvalidStorageFormat requires recreation of the node state directory when the required current format marker is missing or invalid.

RestoreStagingQuota carries the node’s unpublished checkpoint limit, current usage and incoming checkpoint footprint; RestoreStagingSize rejects an unrepresentable accounting sum. A quota failure remains a storage failure beneath the admitted restore step and leaves its activation gate closed.

Node-local maintenance logs admission, cancellation or storage failure and retries on its next sweep without changing the command outcome or gate. Metrics are updated only for a completed sweep, so a partial cancelled deletion cannot claim a completed reclamation count. Maintenance treats an absent or inline selected header, or another selected checkpoint revision, as ordinary chunk unreachability. Malformed chunk coordinates or bounded checkpoint headers keep their typed storage errors. Bounded deletion may already have committed earlier batches before cancellation or a later error; restart or the next successful sweep resumes from remaining keys. Completed reclamation counts include unreachable active chunks as well as unpublished staging.

Staging or publication failure leaves the durable start gate in place, including a failure after the complete generation’s pointer became durable but before runtime handles were cleared. Exact publication retry completes durability and bounded cleanup under the same authority and inventory. Transaction planning reports a blocked START as TransactionPlanningError::RestoreInstallation, naming the domain and restore execution before lifecycle admission. The client receives a definitive failure.

No restore diagnostic includes password hashes or resource bytes. When a state section’s entity is absent from the restored schedule or its schema fingerprint differs, installation skips that section and the successful command carries an unlocated warning diagnostic. The same applies to a verified state record whose kind tag or version is unsupported. The CLI includes those warnings in text and JSON reports.

The client reports an archive it cannot read as ClientError::ReadRestoreArchive with the path and the I/O error kind, above the I/O error when the client read the file itself, an empty file as ClientError::EmptyRestoreArchive, a failed call as ClientError::Restore with its status, and a reply that does not decode as ClientError::InvalidRestoreReply above the codec’s report; an error that may hide an admitted restore is ClientError::UncertainCommand with its execution reference, above the failure that left the outcome unknown. The C binding classifies an unreadable or empty archive as NX_ERROR_INVALID_ARGUMENT, a failed call as NX_ERROR_TRANSPORT, and an undecodable reply as NX_ERROR_PROTOCOL.

Sensitive Data And Observability

Error variants and public diagnostics identify operation, field, entity, and placement rather than carry sensitive payload values. Error-route metadata and hot-path logs must not reveal sensitive input, credentials, key paths, or certificate contents. A route that deliberately copies an input field into its ordinary output still obeys the normal explicit sensitivity rule. Operators can correlate a stable error reference with a code and affected fields without seeing the secret.

What A Diagnostic May Name

A report’s contexts reach sessions as failed commands and server notices, logs as they are, and other nodes as reasons, so every context is written as public text. Each owner applies the rule at the point where a value could otherwise enter a diagnostic:

Where a value could enterWhat the diagnostic carries
A payload a codec rejectsThe codec, the field when one is known, and the reason; never the rejected value
An expression that fails for a rowThe typed row error with its code and expression span, and in a message error the affected field paths
A batch container that cannot be producedThe typed reason alone; the evaluation failure beneath it can quote the payload and is not rendered
A row a database refusesThe SQLSTATE, code or named rejection; response text that could quote a bound value is discarded
An HTTP request that fails or is refusedThe status or the cause of the connection; never the evaluated target, a header value, a credential or a body
A broker address or a driver’s connection errorThe host and the cause of the connection failure; never the credentials of the address or a record
A WASM guest’s saved state and a checkpoint transferThe revision, the lengths and the digest; never guest bytes
A backup or a restoreThe section path, the entity and the check that failed; never archive contents, password hashes, resource bytes, payload columns or branch field values
Credentials that do not authenticate a callThe refusal alone, in the call’s status
A client batch’s defect and the detail of a processing failureDisplay text bounded to 1 KiB that quotes no payload value
A failure another node executedThat node’s operator description, as opaque text beside an already typed class and subject
A concrete branchThe fingerprint of its key, as the next section describes

A structured message error is the same rule for a route: its message is non-sensitive, and the handler reads the failed input, its state snapshot and its partial output as typed fields under the ordinary sensitivity rules, never as text in the error.

Naming A Branch

A branch schema may declare key fields SENSITIVE, so nothing that names a concrete branch in text renders its key’s field values. Errors, runtime events, negative acknowledgement reasons and logs name the execution a failure belongs to as branch <fingerprint> or unbranched; a log record carries the same text in its scope field. One type renders that text, and a branch key has no display form of its own, so a context that names a branch cannot print its key. The fingerprint is the lowercase hexadecimal digest of the branch’s canonical key text, the identity DESCRIBE WASM PROCESSOR checkpoint lines and DESCRIBE BACKUP print and transaction-impact reports carry for the same branch, so an operator matches a failure to those reports by that text. The per-branch statistics the execution graph carries to sessions name each branch by the same fingerprint, and per-branch metric series are keyed by it. A session subscription, which masks sensitive key fields, is where a key’s other field values are read.

The fingerprint is a plain digest of the key text, computed without a secret. It hides a key only as far as the key’s values are hard to guess: someone who can guess a sensitive key value can compute its fingerprint and confirm the guess against a diagnostic. The rule also governs only text that names a branch in a failure, an event, a log or a statistic. A statement or a sink that an operator directs at branch data follows its own contract: SHOW RELAY <relay> MATERIALIZED STATE prints each entry’s key and payload (Relay), and an SQS emitter’s FROM BRANCH group is the record’s branch key (Emitters).

Log Levels

Per-message and per-batch detail belongs at debug or trace; info is for lifecycle, administration, topology, and unusual transitions. A failure the caller recovered from and is the only witness of is recorded at debug. Metrics And Observability defines the available metrics and their aggregation.

Recovery, Panics, And Enforcement

Recovery Classes

Some outcomes are intentionally not propagated. discarded records why an already handled or irrelevant result owes no further action. reported is used when the recovering call is the only witness; it logs the failed operation at debug. A channel send with no receiver means shutdown when the receiver stopped with its node, domain, or task (means_shutdown), or withdrawal when a requester, session, or observer left (means_peer_left). If the receiver was guaranteed to remain, its absence is a broken invariant. A watch value needed by later subscribers uses send_replace so loss of current subscribers cannot leave stale state. Shutdown And Recovery owns where these outcomes occur during stop and drain.

Closing the runtime’s checkpoint announcement task owner after drain cancels pending dispatches and retry waits, then joins them before withdrawing their routes. This cancellation is an ordinary terminal task ending; replica synchronization supplies any missed checkpoint availability hint.

Panic Classes And Guarded Dependencies

Broken internal guarantees take the explicit panic classes assured for a construction or platform guarantee, verified for a condition checked on the current path, and todo for a deliberately unimplemented path. An actually reachable failure instead becomes a typed error or a valid state in the type. A dependency that panics on input a caller can supply is such a failure too: its owner refuses that input with a typed error before the dependency reads it, and that guarded read is the only way the rest of Nervix reaches the dependency. The vocabulary’s duration parser, parse_duration_text, refuses text whose spans would overflow humantime’s duration arithmetic with DurationTextError::TooLong, and every reader of duration text uses it: NSPL literals, Model settings, window aggregate arguments, node command-line options and their environment variables, benchmark settings and test harnesses. Clippy rejects a direct call to humantime::parse_duration and any use of humantime::Duration, whose text conversion reads through the same parser. Arrow’s IPC stream reader trusts what a stream declares: it panics on a field type or a type parameter it does not implement, on a list without its child, on a buffer that reaches past its message body, on a validity bitmap shorter than the nulls it is declared to hold, on an offsets buffer that ends inside an offset, on variadic buffer counts no field takes and on a fixed-size list too long to count, and it allocates a message’s metadata and body from their declared lengths before reading them. Every reader of an Arrow IPC stream from outside the node, a relay body, a snapshot section, a producer’s batch or a WASM guest’s generated pool, therefore first scans the stream: the continuation markers and lengths inside the stream, the schema message first with only the field types Nervix carries, uncompressed record batches that declare exactly the field nodes and buffers that schema’s fields take with every buffer inside its message body, and the end-of-stream marker ending the stream, where a generated pool may instead end after its last message, as the Arrow format allows. The scan alone opens Arrow’s reader over such a stream. A stream it refuses fails before the reader sees it: as misframed or undecodable for a relay body or a snapshot section; as malformed, of another schema or of invalid data for a producer’s batch; and as unreadable, or as declaring a field Nervix does not carry, for a generated pool. Two readers of Arrow IPC stay outside the scan because neither reads a stream from outside the node: the Iceberg sink reads back the staged files the node itself wrote, inside a storage job whose panic the executor reports as a failed commit, and the Rust client reads the deliveries a node wrote. DurationTextError describes only the reason, humantime’s own for malformed text or it is longer than a duration can be, so each owner keeps its diagnostic around it: the setting, the text it could not read, then that reason. An owner with a typed error of its own, such as the command line, an ingestor’s start, the activation, entrypoint, runtime and emitter plans, the WebSockets signaling compiler, and the benchmark settings, also keeps the DurationTextError beneath it. The benchmark settings name the parameter and the value it could not use in SettingsError::InvalidParameter, with the parser’s own error, a ByteSizeError, or a ParameterValueError beneath it: a value that is not a scalar or not a string, one beyond the template integer range, or a flush interval that would need a run longer than any benchmark can measure. A node given such a value on its command line or in an environment variable names the option and the reason and exits with status 2 before it starts. A dropped result with no stated recovery class does not establish that it was handled.

Benchmark Tooling

The rest of the benchmark tooling returns reports too. Catalog discovery and loading name the benchmark, the implementation and the file, and a template that does not compile or render keeps the template engine’s diagnostic beneath it once. A metrics report names the line, metric, series or quantile it could not use, with the reason as a typed cause, such as a sample value that is not a number or a histogram whose buckets decrease. A comparison names the run directory and the artifact it could not load, an A/B summary the arm whose artifact failed, and topic provisioning the topic and the partition count it waited for. The benchmark command adds the benchmark and implementation it was running above those reports and above the client’s own report of a connection or statement, and prints the whole report when it exits with status 1; run-all records each failed implementation with its chain.

The Reported-Error Guard

just validate-typed-errors, run by just validate, rejects textual Result<_, String> in product code without a baseline. It remains a separate source rule, including inactive source. The compiler architecture gate, run by just ratchet, owns three resolved Rust diagnostics:

DiagnosticContract it checks
nervix::bare_error_signatureA canonical Result carrying a Nervix-owned standard error returns a contextual Report, directly or inside resolved Future::Output and Stream::Item.
nervix::discarded_outcomeA let wildcard, unused statement or resolved drop consuming a Result, report, owning collection or transparent wrapper states its handling or recovery class.
nervix::bare_panicA resolved inherent Option or Result unwrap/expect uses meticulous to state its guarantee.

The same driver emits the source-owned layer, Model, language, awaited cluster, identity and installation-state diagnostics described in Data-Plane Concurrency. They reject compiler-visible architectural contract violations and share the exact-operation expectation checks. They do not classify a product operation’s error or replace its contextual report.

Aliases, renamed imports, borrowed failure definitions, associated types, generic substitutions, inferred closures and boxed future/stream contracts use compiler types and trait obligations. A same-named method on an ordinary type carries that type’s own contract. A plain value, borrow or optional ordinary value is not a dropped failure. Matching an outcome, propagation and the resolved recovery traits are explicit handling.

Resource state and guards can retain already observed reports while owning a separate lifetime. Releasing such a handle is ordinary lifetime management. The discard check follows transparent outcome storage and complete standard-error report carriers; it does not recursively treat every field of an arbitrary resource owner as a new operation outcome.

An error’s owner classifies a pure conversion refusal, cancellation signal, semantic row or record outcome, fixed wire response, or modeled external-library error at its exact type:

#[cfg_attr(nervix_lint, nervix::error_boundary(
    outcome, reason = "this conversion returns a pure range validation refusal"
))]

The library kind names the external contract a modeled primitive preserves. A classification may also name one exact return contract, including a trait method; it never inherits from a module, package or enclosing callable. Both kinds require a meaningful reason. Malformed, duplicate and misplaced classifications fail. Source metadata crosses crate boundaries. A type’s Error suffix establishes no classification.

Foreign hooks that require std::error::Error receive a carrier owning the complete report. The compiler checks that every carrier variant retains a report, including through boxed or shared ownership. Optional reports do not establish that guarantee. DnsLookupReport retains the lookup report at the Hyper boundary; the node trace connector retains its connection report through Tonic. The consensus append stream, control-operation responses and handoff responses keep their fixed semantic wire outcomes; local operation failures acquire context before their wire projection. VM row reasons and connector record rejections retain their semantic channels.

Node database opening and runtime key reads preserve their storage cause beneath the owning failure. The paced simulation’s ledger, effect store and run return reports, retain decoding and client causes, and classify exit status from typed contexts. Its terminal reporting prints the context chain. A newer consumer refusal or node observation may explicitly discard the report it supersedes.

These diagnostics use normal Rust lint levels and one-operation reason-bearing expectations. The required gate rejects unresolved warnings, broad suppression and unfulfilled or widened expectations. No numeric allowance approves a failure site. Fixtures exercise real compiler resolution; current API doctests pair supported calls with compile_fail examples. For a new fallible site, a reviewer identifies the deciding layer, ordinary outcome or failure class, actionable fields, context across boundaries and the public diagnostic or recovery that ends the path. That classification preserves branch and sensitivity rules.

Tooling And Diagnostic Runs

The compiler architecture gate owns typed tooling failures for invalid source contracts, conflicting findings and incomplete compiler passes. ContractProblem retains the specific argument, kind or missing contract coordinate inside an error-stack report until the Rust diagnostic boundary formats it. Reports preserve source location, resolved receiver/operation, owner and compiled configuration context. Missing or stale analysis fails rather than becoming a zero debt count. These are repository validation errors and add no runtime failure variants. Data-Plane Concurrency owns the gate’s coverage and synchronization policy.

The isolated architecture compiler emits ordinary Rust tool diagnostics: nervix::sync_acquisition, nervix::lifecycle_call, nervix::unknown_effect and nervix::invalid_contract, together with the typed error diagnostics above. Source contracts and narrow reason-bearing expectations own the architectural classification. Invalid contracts, unfulfilled or widened expectations, incomplete compiler reports and changed inputs fail the repository command; they are tooling failures, with no runtime error or public protocol disposition. The diagnostic gate rejects unresolved Nervix warnings too. Data-Plane Concurrency states the rule boundary and the claims the compiler does not make.

The external Chaos controller distinguishes an observation failure from a product recovery failure. A degraded-link bandwidth probe that cannot establish its connection, deliver its full payload, observe the receiver’s successful exit, or retain its helper evidence reports the probe’s sender, receiver, address and port. Its adjacent probe record preserves readiness, transfer deadline, both helpers’ outcomes, received byte count and cleanup outcomes, with raw helper logs. A timeout of the Docker client also ends the exact sender container; it does not leave a live transfer to alter a later measurement. A failed required attempt remains evidence even after a subsequent successful qualification. The controller’s measurement budget does not extend the product’s recovery budget. The external Chaos runner owns the commands, measurement interpretation and retained artifact paths. Network-fault preflight refusals retain the failing stage and original command status, exact arguments and bound, elapsed time, helper identities and both raw streams. The operation record exists before execution, so an interrupted inspection remains identifiable. Cleanup captures the injector’s state and logs before removing it and records its own failures independently of the original refusal. A sidecar that disappeared between enumeration and inspection is confirmed absent and recorded as an ordinary outcome; a failed inspection of a still-present sidecar fails cleanup. The public setup error reports the actual refusal rather than inferring that the worker lacks netem or iptables capabilities from a Docker client failure. A successful helper exit alone does not establish successful Docker-client completion within the inspection bound. Only the ping helper’s missing-replies outcome qualifies blocked traffic. A Docker execution refusal or a probe timeout supplies no effect observation and fails preflight with its own status. The real preflight failure controls qualify separately from a scenario’s timed verifier self-check. They resolve the pinned tool images before starting an injector with automatic pulls disabled. Missing-image preparation retains each inspection, pull and confirmation outcome and its raw streams; a preparation refusal ends qualification before any fault or product node starts. CI retains their raw outcomes before starting the suite; failure to complete that qualification fails the shard without consuming a product scenario’s execution window. Directed-link packet observations distinguish an incomplete sample from an effect outside the configured limits. The latter is an injection qualification failure whether too few packets were lost or too few replies remained; it does not assert that the fault was absent or that product recovery failed. Burst samples use 400 packets inside the existing observation window, and the effect verifier preserves the original received and lost fractions. Failed observations remain retained alongside subsequent qualified samples.

Kafka end-offset observations retain their exact command, raw streams, command outcome, parsed offset, start time, elapsed time and effective bound before the controller relies on them. Output stability requires consecutive successful equal observations above the accepted-input minimum. A failed query or a value below that minimum resets the sequence, and an individual query cannot outlive the remaining 30-second observation window or the whole-run deadline. The final record distinguishes an exhausted whole-run budget (controller) from failed observations, insufficient output or a moving boundary (observation). None of those observations alone proves a missing record; the exact ledger owns that verdict. Baseline suite budgets separately account for the full 600-second verifier preparation allowance and the topology’s execution allowance, without extending a product deadline. Kafka administration borrows no stdin or interactive terminal from the controller. A failed final query retains its command failure classification even after earlier successful reads, and a refusal to create or finish an evidence record fails the controller instead of accepting an unrecorded observation. End-offset queries execute in the existing broker, with no ephemeral container lifetime inside each observation window. The packaged-binary preflight retains its labeled helper until final run cleanup and records the command and container inspection as separate outcomes. An expired Docker client command remains a failed observation even if inspection sees a successfully exited helper; a missing or invalid inspection is also a failure. The controller does not reinterpret such refusals as missing binaries. Its live Docker event recording begins before this probe. Publishing a validated startup status is a controller operation. Its byte copy and atomic rename share a bounded command, clipped to the remaining run time, and retain their native outcome and monotonic elapsed seconds under diagnostics/startup-status/. A refused or expired publication keeps the attempted public status and fails without claiming a product readiness defect. A final transcript is published only after its copy succeeds; failure to record the operation also refuses publication. Subsequent container enumeration preserves failure under its own bounded command.

The end-to-end benchmark driver distinguishes fresh Kafka partition leader readiness from a failed measurement. Before any warm-up input, its empty-topic preparation retries only the driver’s typed NotLeaderForPartition and LeaderNotAvailable metadata failures within one thirty-second query budget per topic. A nonempty topic, another query failure, or an exhausted budget fails preparation with the Kafka cause retained. Measured delivery and output auditing keep their own failure boundaries and are never repeated by this preparation check.

A diagnostic node’s findings retain a typed source. WaitForGraph/ActiveCycle establishes an active tracked-lock cycle and ends the diagnostic process with status 3 after recording. LockOrderViolation/PotentialCycle is historical order, retained as unreviewed evidence while the operator’s workload continues. It never becomes a confirmed outage merely because the history is cyclic. Local qualification requires a correction or explicit non-overlap/shared-reader/lifecycle proof and a retained regression reference, keeping the original cycle. Missing or truncated source context, unreviewed potential order and active findings cannot qualify; selected exports cannot qualify the source process by hiding another finding.

An overload identifies the handoff, order history or evidence/output retention owner. Lost context, failed output/recording and a recording deadline are diagnostic execution failures, exit status 4, never a clean run. The recorder writes directly to the standard error descriptor and uses a cancellable ten-second deadline for each finding. Expiry exits without another write, because the same descriptor may be blocked. No error is returned to the code whose locks deadlocked. Nothing logs protected application values or uploads artifacts. The detector callback performs bounded handoff rather than reporting I/O; a sink panic aborts so a swallowed callback failure cannot look clean. A tracked constructor before installation panics with a configuration failure.

nervix-deadlock owns EvidenceError and review refusals: unsupported format version, malformed or out-of-bound current values, invalid proof, missing finding, nonpotential finding, incomplete context, and an exclusive mode contradicting a reader proof. The ordinary local report tool preserves these in error_stack::Report, exits 4 on an operation failure and 5 when valid evidence does not qualify. Before its verdict, qualify prints one evidence summary: line of counts: findings by kind, unreviewed and nonqualifying records, repeated deliveries of a retained cycle, and findings lost to each overload source. A qualification without that line is a tool failure, never a clean observation. It does not reinterpret a prior evidence shape. Data-Plane Concurrency owns the selections, source correlations, deduplication, bounds, triage examples and detection gaps, including omitted requested-read edges and the upstream dispatcher backlog. Qualification is about recorded evidence and requires the run’s completion/coverage record too. The diagnostic lane classifies each supervised process’s ending, an active cycle, a diagnostic failure, a signal, a timeout, a leftover process, incomplete accounting, and missing, partly written or nonqualifying evidence, as one named failure class with its own status, and reports a failed invocation’s recorded active cycle as the failure it is.

Qualification Evidence

The rules of this chapter are held by the source guard, resolved compiler fixtures, Cucumber scenarios through the public interface, registered Bolero properties for input a decoder must refuse, and unit tests beside each owner.

GuaranteeEvidence
The guard resolves the contracts it claimsjust test-typed-ratchet-contracts: resolved error aliases, associated outputs, generic calls, inferred closures, nested Future and Stream returns, complete foreign report carriers, named discarded outcomes, canonical panic APIs and source classifications across crate metadata. The distinct Result<_, String> source rule’s unit tests run with just test-docs
Local storage and foreign hooks retain complete typed causesjust test-typed-error-runtime checks database opening and trace DNS causes. Paced-driver ledger and effect-store tests retain typed I/O and decoding causes; paced_simulation.feature checks a contextual clock failure and malformed-file causes rendered once on one and three nodes
A parse or lex diagnostic locates its token in the submitted source, in any statement of a batchtyped_error_qualification.feature: the byte span and underlined text of a single statement, of a later statement of a batch, and of a rejected character
A validation failure keeps its owning model and its specific causetyped_error_qualification.feature: a VM compile failure names the model and the unknown function, a message-error branch mismatch names the node and route, and a rejected alteration names the model and the refused operation and changes nothing
A message error keeps its concrete branch and omits sensitive inputtyped_error_qualification.feature, with interleaved records of two branches, and node_error_policies.feature for the structured operation of a route filter
A runtime event and a failed command render the whole chainruntime_report_chains.feature for a source cadence above its clock arithmetic, lookup.feature for a domain build above a lookup, its line and its codec, and lookup_hash_map.feature for one internal error of a whole batch
A cause appears once in a rendered chaininferencer.feature: the cause of an unreadable ONNX model appears exactly once in the failed command’s message
A branch is named by its fingerprintwasm_processor.feature: the server error of a branch with a sensitive key carries the fingerprint and not the key value. The runtime’s unit tests hold every branch-local context to the same rendering
A consensus failure keeps its class at a client mutationconsensus_failure_reports.feature: creating a domain or a user, cordoning and draining a node, and queueing a transaction statement each report the failure that rejected the proposal
A session ends with the status of its failuresession_protocol.feature, authentication.feature and cli_session.feature
The formatter reports rejected source with its stage and exit statusnspl_format.feature
Input a decoder must refuse fails with the owner’s typed error and never panicsThe registered Bolero targets nspl-statement-text, models-duration-text, wasm-protocol-malformed, runtime-arrow-bodies-malformed, client-producer-batches-malformed, relay-wire-messages-malformed, client-discriminators, backup-malformed-records and consensus-state-corruption

Every registered Bolero property runs as an ordinary randomized and corpus test for each pull request. The sanitizer-backed fuzz campaign over the same targets runs in CI only for a pull request labeled fuzz; any other run skips it, and a skipped campaign is no fuzz evidence. Property Testing And Fuzzing owns the commands and the gate.

Paired compile_fail and compiling doctests hold current APIs beside their failure paths. The DNS hook accepts DnsLookupReport as a standard error, and the recovery API classifies a real outcome. The VM’s function injection receives the selected rows’ earlier errors together with domain time. Resolved compiler fixtures hold the contextual-report, discarded-outcome and canonical panic rules; the source guard separately rejects textual string errors, including inactive code.

Adding A Fallible Operation

A new fallible site, or a change to an existing one, answers these in order:

  1. Which layer decides its meaning. The failure belongs to the owner that can say what went wrong, and that owner’s existing error type gains the case. A second form of a failure the owner already represents is a duplicate to remove.
  2. Whether it is a failure at all. An ordinary outcome is a typed value its caller branches on. A failure a caller, a payload or a configured limit can reach is a typed error. A condition that cannot happen is a broken invariant and takes assured or verified with the guarantee as its reason. A row’s or a record’s failure stays a typed value in its batch’s outcome.
  3. Which typed fields let the caller act. The context carries the identities and values a caller decides from, and no preformatted reason. It carries no payload value, and it names a branch by its scope.
  4. Which context must cross each boundary. The report is created at the failure, and each layer that changes its meaning adds its own context above it. A context names its own operation, does not print its #[source], and does not repeat a node or a domain that a context above it names.
  5. Where the report ends. A fixed wire, ABI, stored or public outcome is projected from the report where that outcome is constructed, and the owner states which typed data selects it. A foreign trait receives a standard error that holds the whole report.
  6. Which diagnostic or recovery class closes the path. A reporting boundary renders the chain once with {error:#}. A failure the caller survives states its recovery class with discarded, reported, means_shutdown or means_peer_left.
  7. Which evidence holds it. A failure observable through a command, a session, an endpoint or a runtime event has a Cucumber scenario that asserts its message, disposition or status there. A decoder’s rejection has its registered Bolero target. The change keeps just validate and just ratchet passing without raising a count.

The classification preserves branch and sensitivity rules at every step.

Guarantees And Limits

The model guarantees:

  • Every product signature that returns a Nervix error returns it inside a report, apart from the shapes The Reported-Error Guard lists, and no product signature returns a String error.
  • A decision is made from typed data: a current context, a typed context beneath it, a typed field, or the class of a remote result. No retry, redirect, acknowledgement or disposition is selected from rendered text.
  • A projection leaves its fixed outcome as it was. NSPL diagnostics and their spans, VM error codes, wire and ABI results, retry and acknowledgement decisions and command dispositions do not depend on how many contexts a report holds.
  • A failure of one row or one record costs no report and no formatted message until a policy reports it.
  • No context, message error or log names a payload value or the field values of a branch key.

It does not guarantee:

  • A report does not cross a process. Another node receives a class and a subject, or reason text; a guest receives a code; a client receives a disposition and a message. The frames stay on the node that created them.
  • A rendered chain is display text. Its wording changes with any context in it. A client decides from dispositions, statuses and typed outcomes, and a chain is for the person reading it.
  • Compiler checks have a finite surface. They resolve the declared configurations and the documented owning wrappers and APIs. They do not prove whole-program ownership or determine an arbitrary resource owner’s recovery policy. The distinct string-error guard reads source text.
  • A fingerprint is not a secret. It hides a branch key only as far as the key is hard to guess.

The shipped code falls short of the model in these places:

  • Reasons that copy a cause as text. The registry keeps a vocabulary rejection’s report beneath its invalid-model refusal and also quotes the rejection in the refusal’s reason, which is the text the session shows. A model alteration’s rollback, backup drain timeout and entity gate failures, and a backup’s domain capture failure and timeout, carry the failure they report as a reason or details string, the rendered chain or its outermost context, with no typed cause beneath them. The interconnect copies a cause’s outermost context into the transport failure it places above that cause, so the rendered chain says it twice.
  • Sources printed by their context. The interconnect’s I/O, TLS and HTTP/2 transport errors print the source error-stack also records beneath them.
  • A node or a domain named twice. An emitter’s or generator’s start and a processor’s planning name their node above a VM compile failure that names it again, and a start report beneath the domain build names the domain again, as in failed to build domain execution for 'edge': failed to start emitter 'audit' in domain 'edge': ....
  • Storage failures without their cause. Some runtime state-store reads, writes and synchronizations still report their own context without the storage engine’s error. Key walks used by replica and WASM state operations retain that cause beneath their read context.
  • A formatter defect without a statement. A verification defect the formatter cannot attribute to a statement is reported at line 1.
  • A literal canonical NSPL cannot spell. A float literal too large for F64 reads as infinity, which canonical rendering then refuses.

Typed States And Validation Boundaries

Nervix represents absence and distinct semantic states in its types. A zero, empty string, empty collection, maximum integer, or all-zero digest is a value in its own right; it does not stand for a missing value or a different state. The owner of each state decides when a value is required, validates it there, and passes the resulting typed value to the next layer. This keeps the registry, execution plans, runtime, clients, and diagnostics from assigning different meanings to the same raw value.

This chapter describes the landed typed-state architecture across those boundaries. The Cluster Interconnect chapter owns node-to-node wire forms, membership, relay delivery, and transport failures. Domain Clock owns clock installation, authority, and time bounds. Shutdown And Recovery owns drain, handoff, and restart semantics. The state rules here apply to those systems without replacing their detailed contracts.

Execution Plans follows the validated values into a complete installed revision.

A State Has One Owner

Text is parsed into a semantic Model, registry validation resolves its references and contracts, and planning produces an execution plan. Each stage passes forward a value whose type expresses what has already been established. Runtime code executes the plan; it does not reinterpret a Model or infer a state from a display label. Shared identities live in the vocabulary so every layer uses the same meaning for a domain, branch, node, timestamp, or schema fingerprint.

Source-owned identity and installation contracts

Gate these reviewable contracts with cfg_attr(nervix_lint, ...):

#[cfg_attr(nervix_lint, nervix::identity(node, reason = "destination cluster member"))]
destination: ClusterNodeName,

#[cfg_attr(nervix_lint, nervix::state(installed, reason = "constructed before branch admission"))]
branches: OnceLock<NamedBranches>,

identity(node, ...) marks shared vocabulary identity types, and declares an identity requirement on an exact field, parameter, type alias or function return. The compiler resolves aliases and normalizes types. References, arrays/slices, Option, Vec, Box and shared Arc wrappers carry their typed identity; Result checks its success type. A tuple or unrelated container containing a typed node does not make another string member an identity. Identity types must belong to the vocabulary. The current vocabulary includes cluster node names, process-incarnation identities, graph node references and domain node references. Ordinary labels and external pass-through strings carry no identity requirement merely because their identifier contains node.

state(installed, ...) describes a field’s installation lifetime and forbids a resolved RwLock anywhere in its normalized representation, including aliases and outer wrappers. Immutable construction, one-time initialization or an appropriate publication primitive preserves the owning lifetime. A field containing RwLock<Option<name or shared handle>> without a state contract emits nervix::state_contract for review. Its type cannot establish whether the value is installed once or replaced repeatedly. A genuinely replaceable field declares state(mutable, ...) with the replacement’s actual owner and reason. The annotation proves neither a runtime lifetime nor a publication ordering; affected runtime corrections still need their owner tests and models.

These are ordinary compiler diagnostics in the complete architecture gate, without numeric allowances or a filename-based identity policy. The developer guide describes qualification and evidence.

The representation depends on the question being asked:

QuestionRepresentationBoundary obligation
May the value be absent?Option<T>Keep None distinct until the owner either permits absence or reports a missing required value.
Which semantic state exists?An enum variant with the data of that stateMatch the variant; do not reconstruct the state from a string, count, or collection shape.
Is a present value valid?A validated type or a fallible conversionReject invalid input where the owner has enough context to explain it.
Can a physical format encode the state?A private wire, storage, Arrow, or atomic encodingDecode at the boundary and expose typed operations to callers.

Consensus replication replies have explicit initial-probe, ordered-stream and finished phases. Leading empty requests confirm a log boundary over management capacity; the first data request transfers the remaining input to one ordered stream. Empty commit updates then remain in that stream’s order. A failed or rejected initial probe ends its input, so no data stream can follow an unconfirmed boundary. These phases are volatile protocol state and are never persisted.

A pure conversion refusal is an ordinary typed outcome, even when it implements std::error::Error for a parsing trait. Its owner states that contract at the exact error type with reason-bearing nervix::error_boundary(outcome, ...) metadata. A contextual operation failure returns a report; a missing value does not silently turn one into a success. The resolved compiler gate and report-carrier obligations are described in Errors And Diagnostics.

Zero work outstanding, an initial sequence value, an empty payload, and a genuinely empty result remain ordinary values. A documented scalar function may return zero for a particular input; that result is part of the function contract. Arrow values under a null or failure validity mask also do not encode absence in the numeric lane: the mask does. An Option or enum is needed when the state is absent or different, not merely because a literal looks special.

The guest protocol keeps an absent branch key and an absent source token distinct from present values. Empty application-state bytes remain present inside the Rust SDK’s snapshot envelope. Reset acceptance, reset refusal, an unusable snapshot envelope and rejected application state have distinct typed ABI verdicts. Protocol decoding checks the complete size-prefixed header before identifier access and verifies offsets and counts before constructing owned values.

Absence And Distinct States

Consensus append input. None from the caller’s request stream means that input has ended; the adapter returns an empty answer stream and opens no transport. Some carries the first typed append request, retained exactly once while setup runs. A membership, admission or transport error after obtaining that request remains a failure naming its peer; it is not inferred from ended input. The caller’s retry and cancellation gate runs before this distinction is resolved.

Runtime checkpoint exchange. A state synchronization answer uses None only when the requester already holds the owner’s current revision. Some carries a typed revision, byte length and digest; zero-length checkpoint bytes remain a present checkpoint. The subsequent bulk request names that exact revision. The receiver verifies the declared and received lengths and digest before it constructs an installable state entry, so a partial stream is a transfer failure rather than a different absence or an empty checkpoint. A handoff carries the same descriptions beside their typed placements and prepares state only after each selected stream verifies.

Node trace export. A tracing guard either has no trace export or owns its provider and resolver publication together. The publication carries Option<DnsResolver>: absence means startup has not installed the node resolver, and presence carries that same resolver’s shared handle. A closed publication before installation is a distinct connection failure, not a choice of another resolver. The lazy connector waits through the publication primitive; its export connection timeout starts only after installation, preserving early startup spans.

Branching. A validated branch declaration is either unbranched or carries its named branch and resolved schema together. The schema supplies the branch fields and their sensitivity, so a runtime plan cannot pair a present branch schema with an independently missing sensitivity set. Unbranched execution has no branch key; a concrete key has a nonempty declared shape. An empty field list or synthetic root identifier does not select a branch. A concrete key holds only finite floats, because the VM turns a non-finite float result into a row error and a non-finite float has no canonical text to key a branch by. A record’s fields are not held to that rule: a payload number rounding past an F32 field’s range, an Avro float or a producer’s Arrow batch can bring a NaN or an infinity in, and every JSON rendering of a record, such as a materialized report, a hash map answer or a hash map key, writes one as the string NaN, Infinity or -Infinity. A key read from a stored checkpoint or a peer that holds one fails with BranchKeyError::NonFiniteFloat, and one holding a datetime that is not RFC 3339 text fails with BranchKeyError::RemoteFieldValue; both name the field. A concrete key is a key of a branch schema when it holds exactly the schema’s fields, each with a value of exactly its declared type; a missing or undeclared field and a value of another type are distinct BranchKeyShapeError states naming the field, never a value converted to fit. The subscription Row encoder holds each key to its relay’s branch with that one check, and a restore holds every archived key it installs to the branching its restored entity declares before staging it; see Backup And Restore. Materialized-state reads use the incoming concrete branch, or the actual unbranched state, and reporting keeps the optional branch identity until the display boundary. The structural ASCII graph projection is domain-free; a serialized graph retains its real typed domain.

An emitter’s buffered Arrow carrier keeps its typed source relay and optional concrete branch key. The relay’s declared branch name is fixed, so the pair identifies the exact source branch even if another relay uses the same key fields and values. Payload assembly compares the pair before combining carriers, and unbranched absence remains None throughout buffering and packing. Each buffered row is one of three states: pending, carried by a batch payload or prepared request the emitter retains, or resolved. A retained payload’s members are therefore neither packed or prepared again nor mistaken for resolved rows, which a delivered flag could not express.

A client emitter’s consumer, delivery identity and ACK attempt are separate typed identities. The optional concrete branch remains an internal BranchKey; the client sees only its opaque fingerprint, never its key values. An attempt is pending or assigned to one consumer with one reference and deadline. Retry, timeout or detach makes that reference stale before another attempt becomes live. Confirmed ACK, retry and rejection results have bounded retention for idempotence and expire independently of the delivery identity. An absent consumer is not encoded as an empty consumer id, and an unbranched batch has no synthetic branch fingerprint.

The Rust client’s desired producer and consumer handles have distinct active, interrupted, restoring, reopen-required and closed states. An attachment belongs to one session exchange; a replacement receives a fresh request-derived identity. The producer’s unresolved sent batch has an unknown outcome, rather than a missing outcome defaulted to not admitted. A consumer reports the interruption before delivery from a replacement attachment. Its prior delivery reference is expired, while a settlement sent before losing its answer is separately uncertain. The domain START generation and endpoint contract fingerprint are required values on every successful consumer open, so absence cannot be mistaken for a matching contract.

Expression scopes and errors. The VM frontend receives a scope policy that says whether a bare field may be read, written, both, or neither. A generated or set-only route reports an unavailable message or input scope during lowering, rather than inventing a namespace that later fails lookup. Message errors carry a MessageErrorOperation variant through evaluation and route handling. Its display text is derived from the variant; changing a label cannot select a different operation. Structured errors identify the operation and affected fields without exposing sensitive payload values.

Progress. A node’s applied schedule revision is optional before its first successful application. Once applied, every u64 revision, including the maximum, is a real revision that suppresses equal or older publications. The revision is recorded under the lock that serializes schedule application, so the decision and its recorded state stay together. Consensus byte-based snapshot retention likewise distinguishes no request from a request made while there was no completed snapshot, and from one made at completed index zero. The trigger holds the optional completed index that existed when the request was made; it suppresses duplicate requests for that same completed snapshot without confusing its absence with index zero.

Remote acknowledgements and admission. A routing position is open with its generation and optional correlation, or permanently closed. A correlation owns either a delivery’s record rows or an admission response channel. Pending rows distinguish waiting for admission from admitted silence; ordered parked progress retains its required-wait guard. The wire number is an opaque position/generation/row encoding, validated behind this owner with the registrar process identity. No numeric value means missing, and an exhausted generation never wraps into a live identity. An authenticated peer is starting, awaiting membership with its bound epoch, live with that epoch, or ended. The admission choice’s private atomic byte exposes only pending, admitted and cancelled; one compare-and-exchange chooses an irreversible verdict. Borrowed admitted work and released transport permits have separate lifetimes. Cluster Interconnect owns their delivery guarantees and bounds.

Client ingestors. An ingestor’s input is either a transport, which carries its source and the codec that decodes it, or a client source, which carries the schema its batches hold and its producer policy. There is no optional codec beside an optional schema, so an ingestor cannot claim both or neither. A producer’s admission is Open or Suspended; a batch’s outcome is one of four variants, each carrying its own typed cause, so a completed batch has no cause and a refused one no failure; and a producer’s end is one typed reason. A producer’s identity is the request identity that opened it, and a forwarded producer’s link key is never reused within its process, so neither has a reserved value meaning none. The client ingestor endpoint’s published counts are genuine zero counts, never markers.

Sources and sinks. A source or a sink is addressed by the connector that serves it and the entity it consumes, and its Model states both in its shape: the union holds one variant per connector, that variant holds one variant per entity, and each entity has its own Model with only the fields its statement writes. A Kafka topic source therefore cannot hold a Pub/Sub channel, and an OpenTelemetry sink is a logs, a traces or a metrics sink, so only a metrics sink carries a metric and no signal value sits beside mappings that might contradict it. The kind that names a pair is read from the Model and never stored beside it. An ingestor alteration is nested the same way: its head selects one connector and entity, and the operations of that entity set a source of that entity’s own Model, so ALTER KAFKA TOPIC INGESTOR cannot carry a Redis channel and no kind field sits beside the source it sets. Only a transport’s operations include the codec that decodes its payloads, so a client producer alteration cannot hold one. A stored ingestor of another pair is refused by comparing the two kinds. The names an external system owns, such as a Kafka topic or a NATS subject, are distinct types from Nervix names: each is checked against its entity’s own rule once, when it is parsed or decoded, and keeps its text exactly as written, case included, so a connector sends what the statement says. What every source or sink of a pair declares, whatever its statement says, is declared once on that entity’s Model in the vocabulary: whether its messages carry headers, the typed metadata namespace they expose, whether it runs on every node, what its body references, and for a sink whether it writes headers, needs a codec, requires batching and which publishing modes it accepts. A client Model names the connector it configures as a typed kind, so a source or a sink is matched with its client by comparing kinds rather than labels. A session producer’s source is no transport, so the transport union holds no Model of it; the kind union still names it, because capability decisions ask about every source.

Endpoint availability. Gossip publication carries an optional typed interconnect endpoint. Parsing a present advertised endpoint checks its host and port syntax. Membership admission requires that validated interconnect endpoint; an incomplete publication cannot become a reachable member through an empty address. Other service advertisements, including the console, may be absent independently. Absence says the service is unavailable; it does not invent a reason. Peer selection and transport handling follow the interconnect contract.

Startup voter evidence. The reconciliation task owns a process-local set of typed node names actually observed live, including observations made while following. An absent voter in that set means no live observation; Chitchat’s current dead verdict does not supply one. A first relayed heartbeat can therefore leave a voter unobserved even though gossip knows its identity. Expiring the ten-second startup grace ends the observation requirement without declaring that voter live, and a leadership change retains the original deadline. The set is discarded after expiry. See Whole-Cluster Restart Keeps Ownership.

Identity Across Scheduling And Recovery

Schema-bound runtime state is keyed by a computed SchemaFingerprint supplied by the committed schedule. Branch aggregate and Kafka offset state that do not depend on a schema identify that independence explicitly. No fingerprint byte pattern means “missing” or “independent”; a computed fingerprint is accepted for its identity even if its bytes happen to be zero. Scheduling must publish the required fingerprint before schema-bound state can be loaded or replicated. Missing required identity is an error, not a default digest.

The runtime Kafka offset key remains schema-independent. A backup archive also records the ingestor’s scheduled fingerprint with those offsets, so restore applies them only to the same ingestor contract after publishing its target schedule.

Kafka replica catch-up describes revision state as Current or Advanced(revision), with typed remote-operation failures for refusal. An advanced revision selects a bulk native checkpoint; its header must name a revision newer than the replica’s request and no earlier than the described revision. Length, digest and the current archived shape are verified before a replacement table is published through the retained replica assignment token. A cancelled or invalid transfer leaves the installed state intact. Every successful synchronization returns the held revision, including a Current answer, after validating replica authority. It is acknowledged again so lost progress reports recover without another transfer; a promoted or replaced assignment rejects reports through a retained token.

The runtime checks a stored state’s schema identity against the current scheduled identity before accepting it. WASM guest state additionally uses its generation for the concrete branch. A state from another schema or generation cannot become current merely because it has a later revision. This identity check is separate from the snapshot and handoff durability rules in WASM State And Recovery, Consensus Storage And Replication, and Shutdown And Recovery. Those chapters define when state is saved, transferred, and recovered; this chapter defines how the current state is identified.

Nervix keeps one current stored and wire shape. Producers and consumers of a changed identity are updated together. A previously stored shape that cannot supply required identity fails to load clearly and must be recreated; it is not defaulted into the current state. Tests construct the current shape and assert its behavior.

Archive Preparation Admission

Archive preparation reads the first physical tar header into the backup crate’s ManifestHeader. That type carries the regular manifest entry’s validated identity and bounded encoded length. Restore uses its length to admit manifest decoding before allocating owned archive values. The full archive reader uses the same boundary and then verifies the manifest and every section; see Backup And Restore.

Archived Counts

Native usize and NonZeroUsize counts use the vocabulary’s CountAsU64 archive adapter. Every count-bearing archived field selects it explicitly, so its stored width is 64 bits independently of the archive’s pointer width. Zero remains valid for an ordinary count; a nonzero count uses the archive’s validated NonZeroU64 representation. Encoding preserves every bit of a supported native count. Decoding converts once with usize::try_from and returns ArchivedCountError if the receiving target cannot represent the value. No narrowing cast, clamp, or default supplies a count.

This contract covers relay capacity, transaction positions and operation numbers, queue limits, application progress and outcomes, plan and report counts, topology counts, WASM inspection totals and omitted-entry counts, and histogram delayed-removal bucket indices. The owning stored namespaces and frame signatures identify the current count-bearing shape before decoding, and the interconnect’s wire fingerprint fences it between nodes. Unrecognized stored state fails clearly and must be recreated. Complete equality properties exercise the production representations at their range boundaries; see Property Testing And Fuzzing.

Atomic States On The Data Plane

An ACK root’s ownership handoff can be tracking a bounded number of active shares or be complete. REQUIRED WAIT can leave a tracking root temporarily idle without completing it. Attachments, wait releases, and final completion race, so the ACK owner stores the state in one atomic word and changes it with compare-and-swap transitions. A reserved word value is private to that owner; callers observe typed tracking, idle, and completion outcomes rather than doing arithmetic on the encoding. Completion cannot be reactivated. This preserves the contentionless ACK path and the root tracker’s accounting without adding a lock. The delivery and handoff boundaries themselves are described in Data-Plane Concurrency and Shutdown And Recovery. Execution Primitives owns which backend supplies the atomic; selecting a modeled atomic does not validate the owner’s encoding or prove its transitions without the corresponding production-owner check.

A source instance owns starting, ready and retired states in a private atomic byte. Its handle alone interprets starting=0, ready=1 and retired=2. Compare-and-swap permits readiness changes only before retirement; a retired source cannot become ready or change a replacement instance. This relaxed scalar publishes no payload or other location. Buffered message-error workers separately own Prepared, Running and Ended lifecycle states; a fallible binding prepares the bounded queue, and successful running publication starts its worker once. An ended worker is never restarted.

The domain clock’s atomic maximum has a different meaning. Its initial i64::MIN is the documented identity for a maximum computation, and zero elapsed time is the value of an elapsed duration clamped at its physical anchor. Neither encodes an absent clock. The Domain Clock chapter defines clock absence, installation states, authority fencing, and overflow handling.

An entity-gate operation owns pending, held, released and failed outcomes. A held operation has closed its admission scope and requested its force flush. Ownership capture also requires a freeze owned by that exact coordination identity. The exact operation’s drain-status query publishes it only after observing no affected admitted work or flush obligations. The operation’s registry guard keeps its hold alive through that observation and publication. Request cancellation leaves the receiver-owned engagement and its original lease intact; retries await the same operation and renew no lease. The preparation deadline separately bounds engagement, drain and capture.

A domain schedule’s planning snapshot keeps its captured consensus cordons with two distinct identity sets: all live voters and eligible placement destinations. Termination of an already cordoned voter leaves both sets unchanged; losing or restarting that voter changes its live identity and invalidates the plan. Schedule and transaction publication validate the composed destination set and retain the independent consensus topology fence. The snapshot also retains the terminating live voters independently for destination diagnostics. After membership and liveness, relocation reports that process state before a committed cordon. Its terminating set is a captured diagnostic fact, while publication continues to fence the composed destination identities; termination of an already cordoned former owner still changes no eligible destination.

External Encodings And Conversion Failure

The stopping-node drain action carries a required remaining Duration budget. Its sender deducts elapsed request time before each leader redirect; the receiving leader owns the typed budget for the handoffs it performs. Preparation and activation deadlines are derived from that one value, and exhaustion produces a failed drain outcome. The stopping-node answer distinguishes a failure while its coordinator still leads from an interrupted drain whose coordinator no longer leads. The latter routes the remaining moves through the next leader without resetting the budget or repeating committed moves. Classification consumes the typed command disposition and consensus leadership observation rather than matching diagnostic text. The interconnect verifies the archived action and fences its current representation with the wire fingerprint. Shutdown And Recovery owns the lifecycle and deadline policy.

At an external boundary, a protocol may require a raw tag, signature, null lane, or optional scalar. The adapter owns that encoding and turns it into a typed internal state. In the other direction it encodes the typed state once. A format signature or syslog boundary marker can remain raw on the wire, while callers see the state the format describes. Arrow batches remain the data-plane payload throughout this conversion; codecs use typed builders and column values, and a single addressed message is a view into a batch.

A relay batch carries its rows’ ingestion watermarks the same way, in two Arrow buffers of Unix-nanosecond timestamps beside the payload. A kernel reads them as i64 lanes; a row addressed on its own, the interconnect wire, and a materialized-state snapshot receive typed timestamps, converted once where the row leaves the batch.

A conversion failure never becomes another valid payload value. The MongoDB sink returns a typed per-record failure if a value cannot be represented in BSON, including an unsigned value above BSON’s signed range. It does not publish that record with a replacement BSON null or a changed conflict key. An actual null remains null, and other valid records in the batch can proceed under the connector’s record outcome contract. See Connector Crates And The Connector Contract for the host’s publish and acknowledgement boundaries.

VM integer count and position operands retain their signedness and full magnitude. A large unsigned input does not narrow to the largest signed integer; an unsupported result size reports a per-row error before allocating it. UUID-v7 construction reports an unsupported pre-epoch time as a row error rather than substituting the epoch. Exact schema types and Arrow validity still govern the rest of the expression. The public function results are documented in Expression Functions.

A submitted client batch is validated as a whole before any row is admitted: a stream that is not exactly one uncompressed record batch of the ingestor’s canonical Arrow schema, with valid columns and within the row and byte limits, is refused with its typed defect. No value is cast, widened, coerced, or defaulted to make a batch fit, and no subset of a malformed batch is admitted. The shared Arrow IPC boundary checks canonical message framing, declared bodies and column buffer ranges before invoking Arrow’s reader. A malformed frame reports ArrowBodyError::Framing for a relay or snapshot body and ClientBatchError::Malformed for a client batch; neither gains a partial row from it.

Session replies carry typed command purpose and outcomes. An upload failure can carry an optional assigned nonzero resource version; before assignment, the version is absent. Diagnostic spans can be absent, while a present span beginning at offset zero is still present. The web console uses the typed outcome for domain-selection dispatch instead of matching reply message text. The FlatBuffers encoding preserves these optional fields and typed variants across the session edge; Client Session Protocol defines how a receiver keeps an absent optional value distinct from a present zero.

The shared C binding gives clock observations their own nx_clock_event_kind and installations their own nx_clock_state. Only NX_CLOCK_PACED has a mapping for nx_clock_event_paced; tick and end-reason accessors likewise require their corresponding event kinds. An interruption or a refused restoration carries a domain but no invented generation or end reason. A mismatched accessor returns NX_ERROR_TYPE without changing its outputs, so absence cannot look like a zero generation or timestamp. The paced and tick accessors allow omitted output pointers for fields a host does not need.

The clock of a followed domain, nx_domain_clock, always has a generation and a state, so those accessors cannot fail. A domain the session does not follow reads as a NULL clock rather than a stopped one, a clock without an accepted tick answers false from nx_domain_clock_tick rather than a zero tick, and an unpaced clock reports no admission window rather than an unbounded one. Only a paced clock has a mapping for nx_domain_clock_paced, and a stopped or uninstalled clock has no logical time, so its projections return NX_ERROR_TYPE instead of a fabricated instant.

Completion replies likewise carry a SuggestionStatus variant for ready, missing, stale, or failed context and an optional continuation. The server resolves typed semantic references from one committed configuration read with the requesting session’s ordered transaction prefix applied; a missing or mismatched transaction binding reports stale context instead of silently discarding queued changes. Each candidate carries an explicit UTF-8 byte-range edit. The CLI and web console apply the edit after checking the cursor boundary, and the web console converts browser UTF-16 selection offsets at its edge. A continuation is bound to the input, domain, revision, and candidate set, so a changed context cannot silently reuse a page.

Structured-control lookups use a separate typed choice boundary. A request names its semantic target and carries each dependency as a ChoiceValue; a placement choice therefore depends on a domain-pace variant rather than on the text PACED. An internal-schema, branch, or relay choice depends on a typed domain reference and returns a kind-qualified Model reference, and a relay-field choice depends on the domain and relay references and returns a typed field reference. Results keep the same typed union for enum variants and domain, resource, model, or field references, with label, detail, and group held separately as presentation. The FlatBuffers discriminant selects behavior. A missing typed dependency is MissingContext, and a page cursor binds the dependencies, revision, candidate values, and presentation so changed form state is StaleContext rather than a silently retargeted page. The browser keeps missing prerequisites, stale context, empty results, loading, and failures as separate choice states. A missing prerequisite carries a hint; stale context offers a fresh lookup; only lookup and transport failures are alerts.

Incomplete schema, branch, relay, and subscription form values stay in browser drafts. The completed conversion creates the current schema, branch, or relay Model, with field order, optionality, sensitivity, wire format and mode intact, or the current subscription client statement. A relay draft’s branching starts unselected, a state distinct from unbranched execution, so a completed relay is never unbranched by omission; its materialized state is absent until LAST BY TIMESTAMP is chosen, because a relay without one is itself valid. A schema, branch, or relay selection retained after its captured domain changes is explicitly invalid until reselected; no empty name or fabricated Model stands for a missing selection.

Junction and reingestor drafts use the same boundary. A junction’s branch is unselected until the operator chooses unbranched execution or a named branch; a reingestor route separately chooses preserve, unbranched, or a named outgoing branch. A materialized dependency remains incomplete until its relay and absence policy are selected. Ordered input, dependency, assignment, and route drafts become the existing semantic Model only when every required choice and expression builds. Changing the captured domain or a dependent reference invalidates selected references while keeping the operator’s draft text visible for correction.

Diagnostic evidence states

The diagnostic owner distinguishes active cycles, potential order and overload. Potential records carry Unreviewed or Reviewed(TriageProof), with a bounded explicit basis, reason and retained regression reference. New source/lifetime context revokes a review. Live, Ended and Unrecorded lock instances preserve different observations; absent construction or acquisition context stays absent. Reused source sites never replace run-local instance identity.

ProcessRecord requires its compile-time/runtime diagnostic selection, and an artifact requires whole-process or selected scope. A selected export cannot qualify a source process whose other findings it omitted. The current version-2 wire conversion checks every required field, bound, identity and review once; unsupported versions fail from their header without another shape or a default. Data-Plane Concurrency owns the current representation, qualification policy and coverage limits.

Validation And Failure Boundaries

Native connection options validate every timeout at setup: one millisecond through 24 hours. The backup command wait has its own required duration, distinct from an optional per-domain quiesce override and the ordinary request and retry bounds. Backup execution identity projects the typed scope, resource inclusion and capture options together with the selected domain; the local output destination remains a client download concern. Recovery may change that destination while the server continues to validate the original semantic request and execution reference.

The registry rejects unresolved or contradictory contracts before a graph becomes active. It resolves branch names, schemas, fields, and sensitivity as one branch state. Scheduling supplies the required runtime-state identity. Runtime owners enforce transitions that depend on concurrent execution or recovery. Connectors and the session edge validate external representations when they decode or publish them. A caller does not compensate for a failed lookup, absent required field, type mismatch, or conversion by supplying a default zero, empty value, or null.

Generated compiler findings carry a required execution-context field. An explicit null means that analysis has not established a source contract; it is an unknown effect, not a cold classification. The report decoder requires that field even though its value is optional. Complete compiler, configuration and worktree identities are also required before generated evidence is reusable.

A vocabulary type that validates its value when it is parsed or constructed validates it again when it is decoded, from JSON and from the archive alike. Every name type, a command execution reference, a resource upload identity, a JSON path, connection-pool bounds, and an emitter’s message and size limits decode through the rule that constructs them, and accept only a value that rule produces unchanged. Stored or received data therefore cannot hold a name with an upper-case letter, a path past its step limit, a minimum above its maximum, or a size that is not a whole number of its unit; such data fails to decode with a typed error and is never normalized into a valid value. A timestamp is its signed Unix nanoseconds, and every conversion into one goes through them, so two spellings of one instant, such as an offset and its UTC equivalent, or a leap second and the second after it, are one timestamp. Every duration Nervix reads from text, whether an NSPL literal, a domain-clock period or skew, a Model’s timeout, interval, retention or TTL, a window aggregate’s delay, or a node’s command-line option, is read by one guarded parser in the vocabulary. It refuses text whose spans could add up to the most seconds a duration holds with a typed error; the grammar library it wraps would panic on such text instead of failing, and Clippy rejects every other way of reaching that library’s parser.

Archive validation establishes the archived structure, then each vocabulary value is checked when it is read back. A refusal in a list can therefore follow values that were already constructed. The archive reader owns each completed value and frees it on refusal, together with the incomplete list or fixed array and any box or shared pointer allocation. No decoded fragment crosses the storage or transport boundary, and a stored record of an invalid current shape still requires recreation.

The current archive readback inventory for containers with a semantic value that may refuse its archived representation is:

BoundaryFallible contents inside archived containers
InterconnectApplicationCompletionPeersResponse.peers; RelayMetadata.acks; PrepareOwnershipHandoffStateRequest.checkpoints; the relays and affected_entities of EntityGateRequest and EntityDrainStatusRequest; the emitter_publishing lists of DomainDrainStatusEnvelope and EntityDrainStatusEnvelope; DescribeRelayRequest.bindings; ownership handoff checkpoint responses. These contain typed node, relay, emitter, model, or domain names.
Consensus replication and persistenceAppendEntriesRecord.entries, including membership configurations and nodes; durable batches of Raft entries and consensus state; the Vec and Box descendants of ConsensusCommand, domain schedules, command execution, transaction plans and reports, and stored models. They contain typed names, references, limits, and operation ranges.
RegistryStored Model variants, including schema fields, nested ParseAsType boxes, processor routes and inputs, connector configuration, and recursive expression vectors and boxes. Their typed names and other checked vocabulary values may refuse after earlier members decode.
Runtime state identityStoredHandoffPreparation.checkpoints and StoredForcedRecoveryPreparation.checkpoints contain placement envelopes with checked domain and model names.
Runtime window snapshotWindowDelayedRemovalSection.removals contains CountAsU64 buckets. A stored count above usize::MAX is refused on a narrower host.

There is no archived shared pointer field at these Nervix boundaries today; names share an Arc only in memory, and archive as text. The shared pointer reader is covered by the same dependency fix and by a refusal test. The deduplicator, Kafka offset, materialized identity, branch LRU and other runtime state snapshot containers, together with backup sections and deadlock evidence, first deserialize primitive or raw string wire values, then validate their meaning after the complete wire value owns its allocations. Their malformed input tests still check that every archive decode frees its allocations.

Constant integer division prepares a SignedDivisor or UnsignedDivisor at the kernel boundary. Its unsigned magnitude is NonZeroU64, and its private reciprocal state distinguishes a power-of-two shift from a multiply-high reciprocal. A zero input produces no prepared divisor; the numeric kernel reports failed lanes with the existing failure mask. Datetime callers already hold validated positive strides, so they can assert that preparation succeeds. These per-call values are execution artifacts and are never persisted; VM Functions owns their arithmetic and failure contracts.

One in-memory domain activation plan resolves each relay’s compiled schema, branch retention and materialized-state presence; each codec’s schema and wire definition; and each endpoint’s VHOST and signaling reference. A missing reference is a typed planning failure before installation. The same plan shape feeds running and passive builds. Passive builds retain the planned materialized relay identities and endpoint routes, while admission remains stopped. A server-side listener stays bound on every live node independently of graph placement or domain leadership.

Materialized origination is an exclusive task capability: moving it transfers the mutable branch selections, while reads and snapshot installation remain independently retainable. Read publications carry an immutable row with its Arrow schema and watermarks. Branch membership and an ended row publication are distinct states; absence in live state owns the answer even when storage retains an earlier checkpoint. Recreating a branch allocates a fresh publication. Installation validates assignment capability, branch generation, captured fence and revision before replacing any row; the current branch lifecycle and revision cannot move backwards.

Server endpoint configuration and source availability are distinct states. The immutable route table contains configured definitions; a bound source lifetime contains an optional prepared intake. Source ending publishes absence through that lifetime before removing its route binding. A retained request or WebSocket cannot interpret absence as a replacement source with the same node identity. A lease loaded before ending may complete; later admission sees absence. Domain replacement and teardown end the domain’s lifetimes and replace all of its route definitions together, preserving other domains. Unbind names the exact binding allocation so a preceding source’s close cannot end its replacement.

A second in-memory decision, the domain’s entrypoint plans, resolves every ingestor’s source, client, codec and routes and every reingestor’s inputs, node filter and routes. It records how a route’s records get their branch key as one of three states: unbranched, keeping the incoming key, or constructing a new key with a lowered program. The two branched states carry the branch and the retention of the relay the route writes, so the branch a route declares and the branch its entrypoint retains are one value, and a route whose declaration disagrees with its relay fails planning. An ingestor’s transport class is read from the one source it declares rather than stored beside it. A Kafka ingestor’s offsets are either a consumer group or domain offsets together with their placement. The planner lowers every filter, route and branch construction before a node binds them, so a bound route always carries its compiled program and a running task has no missing-program or undeclared-branch state to check.

The owner reports a semantic typed error; contextual propagation uses error-stack. A per-record conversion error is reported at the record boundary without formatting a fresh message on every hot-path operation. Diagnostics contain the relevant identity, operation, and field names, while sensitive payload values stay out of errors and logs. A truly optional value continues as Option until its consumer decides whether absence is valid. A label or rendered string is only a presentation of the state and never an input to execution.

Replicated command admission distinguishes a reference that has expired from one bound to a different owner, domain, transaction position, or content in its typed conflict result. The session carries that distinction into the public command disposition; rendering its message does not choose the disposition.

Error-route branch validation carries the node, source route, error relay, and both branch declarations as typed data. Direct emitter VALUES validation identifies a sensitive external target by name and requires explicit leakage; neither error needs the source payload value.

Restore Installation Authority

RestoreLifecycle is a closed Stopped/Resume policy in the semantic restore Model. Text omitting RESUME selects Stopped at the language boundary. The restore plan carries distinct initial stopped state and activation state. Both require the archived start generation and latest start point; paced activation also requires the archived mapping. Required report and DomainInfo generation fields are validated at their wire boundary, including the valid generation zero. Materialized archive descriptors and row identities are archive-owned types. Their raw schema fingerprint binds once to the archived start generation for native storage, independently of the installation authority; payload values stay exact-schema Arrow columns. Typed branch identities, watermark ordering, counts and framing are validated before those values reach the runtime, and each record identity’s branch must be a key of the restored relay’s branching.

A captured archive section carries its publication key, content kind and staged artifact as named fields. Guest capture retains a selected checkpoint handle until its scheduled generation is checked; its admitted storage job records the size for later admission, and opening that handle returns the checkpoint revision with the reader for those exact bytes.

Deduplicator and window capture likewise distinguish a live publication from an unopened stored checkpoint in the captured-state union. A stored selection carries its immutable view and inspected size, and opening it returns its actual revision with an admitted immutable artifact. Only a validated keyspace or window reader exposes counts, boundaries or columns. The control plane selects the scheduled owner and schema before opening; it receives typed conversion results rather than native bytes. A window’s live and stored readers retain their own required lifetime and metadata.

A materialized capture carries either current rows or stored checkpoints as distinct variants. A captured materialized checkpoint is a selected immutable source. Opening it produces a reader whose complete header is required; there is no optional or defaulted generation metadata. Each bounded group retains its scalar identities and original Arrow bytes, and completion checks the whole declared group and row count. Running and paused domains capture current rows; stopped domains capture stored checkpoints. An empty generation still has its descriptor and zero group/row counts. RESTORE resets the stored ownership fence to zero explicitly.

A restored domain has a replicated Pending installation from creation and an Installing authority once its state generation is admitted. Neither state permits START. Completion of the exact current generation removes the installation; terminal command failure does not. Authority carries leader identity and term, execution reference, mutation lease revision and generation. Checkpoint revision remains guest history and cannot grant installation authority. Validation at the applied-state boundary holds its read guard across the storage mutation and handle clearing. The state store validates authority-bound receipts, checkpoint headers and ordered chunks before synchronizing the complete namespace. One small active-generation record then selects that set atomically and is synchronized before completion. It retains both authority and inventory for exact retry and stale-attempt rejection. Cancellation after selection preserves the closed start gate until the same authority completes durability and cleanup.

Runtime storage has distinct initial and restored namespaces; restore installation generation is separate from guest-state generation and checkpoint revision. A current checkpoint is either an inline checkpoint or a segmented checkpoint with required revision, length and digest. A read uses one database view for namespace selection and payload data. A queued checkpoint job retains its selected namespace and validates that it remains current before writing. The storage format marker is required for nonempty checkpoint storage; corruption or absence fails explicitly and requires recreation rather than inventing a namespace for the stored keys.

A branch-aggregate metric checkpoint revision identifies one exact encoded payload. Capture retains its elapsed fields once, so another descriptor, stream or persistence request cannot choose different bytes for that revision. Initial, replica and persisted recovery preserve the received bytes with their revision. A dirty metric update selects the next revision; a snapshot mutex serializes capture and installation at that placement’s cold boundary.

Active chunk maintenance validates the placement and fixed revision/offset coordinates before forming a chunk-set cursor. A set remains referenced only when the same view’s selected header is segmented and names that exact revision. A missing or inline header is an ordinary unreferenced outcome; malformed coordinates or a malformed bounded header remain typed storage failures. Segmented headers have only their fixed archived root, so a size check bounds the caller’s copy before decoding. The required current storage shape makes larger valid values inline; Fjall may still read/cache those values internally. No second persisted liveness field or default is added.

Restore reclamation receives a borrowed RestoreStateRetention from one locked applied revision. Absence of an applied log means catch-up is unknown and retains all generations. A generation ahead of that log is also retained. Once applied, only the exact generation of an applying restore requires unpublished storage; terminal, expired or absent execution records do not. Publication identity remains a separate node-store protection, and reclamation never converts an incomplete installation into a completed one. Key/value usage is reconstructed from current namespace keys, including partial chunks without a receipt, rather than a defaulted persisted counter. Namespace cursors use the vocabulary’s canonical decode entry point for the required domain name. It requires lowercase stored text and retains the typed NameError beneath the storage format failure.

Qualification Evidence

The integration harness’s startup owner classifies a failed attempt once into a terminal outcome or a transient retry carrying its listener family. A listener bind failure names the addresses to replace; a readiness timeout names all listeners. The launcher applies that decision after cleanup within the same absolute startup budget. See Integration Test Lifecycle for its occupied-listener regression and endpoint observation.

The typed states qualification ledger maps each confirmed finding to its owning change, current source path, unit test, public Cucumber scenario, and local validation command. It records the checks on the landed implementation rather than claiming a new behavior change in this documentation chapter.

The focused public selection passed 217 scenarios and 2,784 steps across branch and scope rules, materialized state, error routes, MongoDB emission, VM functions, clock boundaries, membership and recovery, session protocol, and the browser console. The applicable runtime scenarios include one-node and three-node examples; ownership and recovery scenarios use the production sticky scheduler. Owner tests passed 2,399 tests across Models, VM, consensus, interconnect, MongoDB, Client Wire, client core, browser, and server. just test-shuttle passed 70 distinct checks under two schedules for ACK and state-recovery concurrency. The same qualification passed just validate and just ratchet.

These results establish the listed semantic paths and their tested failure boundaries. The ledger distinguishes source-audited findings from demonstrated runtime behavior and records which values remain legitimate zeros, empty content, Arrow masked lanes, and private encodings.

Recurring Task Observations

The paced simulation applications represent endpoint-open intent as CurrentGeneration or FollowingStart. Only the latter permits a bounded retry of DomainStopped after observing a later paced generation; initial and contract-change opens retain the stopped-domain refusal. The opened producer’s generation and its paced clock still own permission to plan readings.

Healthy connector status is absence of a failure; a failure contains its safe error and an optional retry in one publication. Domain-clock publication carries lifecycle pause, generation and start point beside the installation. Entity assignment absence invalidates a retained checkpoint reader; a present assignment contains its state identity, optional primary, executors and replicas together. An absent primary represents an assignment without one primary owner. Replication routes retain that same slot and an optional state intake; ending an intake permanently fences its exact route before the routing publication withdraws it. Assignment absence and ended intake are distinct states: identity removal invalidates every reader, while state retirement may leave the entity’s assignment available for storage-backed synchronization. Force-flush readiness is privately encoded as idle=0, available=1 and closed=2. Only its owning type writes or decodes that byte; the coordinator remains the authority for generation and claim state. The hint has no cross-location data-publication contract.

Connector Crates And The Connector Contract

An external integration meets Nervix at a data-plane boundary. The ingestor and emitter manuals define its NSPL surface and delivery options. This chapter defines who owns the work after validation: the shared connector contract, each integration’s transport, and the host that drives it. Data Plane describes what happens to the resulting Arrow batches inside the graph.

Execution Plans describes how one committed revision supplies those typed source and sink plans to the host. HTTP Emitter Architecture follows this boundary for one outbound request, from Arrow preparation to response and recovery.

Ownership and dependency direction

LayerResponsibility
Vocabulary and registryDefine and validate source capabilities, schemas, delivery modes, header availability, branches, and references before activation. The vocabulary nests every source and sink Model by connector and entity, and each pair declares its capabilities once there. The registry names no connector crate.
Decision and compositionConvert a validated Model into a typed source or sink start plan once. The registry decides every ingestor’s source plan together with its codec and lowered routes, validating the source name, kind, client and route identities as one decision. The server is the composition root and the only crate that names every integration. It resolves client resource mounts before opening a connector.
nervix-connectorDefine source and sink operations, typed boundary values, and opaque host services. It knows neither a driver nor graph execution state.
nervix-connector-*Own one integration’s driver, connection and configuration interpretation, transport headers, protocol acknowledgements, and per-record results. A crate implements the source contract, the sink contract, or both.
Host data planeOwn tasks, intake and Arrow decoding, branch routing, ACK trees, quiesce, buffering, retry and flush scheduling, metrics, events, and drain. It executes typed plans; it does not parse NSPL or read a Model during data-plane execution.

The contract may name shared vocabulary types, Arrow, the async and error facilities needed by its boundary, the node’s bounded executor, and configuration helpers for TLS, HTTP clients, service URLs, resource mounts, and physical time. It must not name the registry, runtime, relays, branches, schedules, or a driver. An integration crate may depend on the contract, vocabulary, Arrow, and its own driver stack. It must not depend on the server, another integration crate, or runtime collectors. Driver libraries belong in that integration’s manifest, with test-harness dependencies kept separately. A connector receives resolved configuration and a typed plan, not graph routing authority. Two sources have no external driver and live in the server. The node’s own HTTP/HTTPS endpoint uses the same source lifecycle and host intake. A client source admits the batches applications submit through their sessions; it has no connector crate, because the session protocol is its transport, and its host is the client ingestor endpoint described under Integration-specific boundaries.

The contract and integration engines declare nervix::input(plans, reason = "...") alongside their source-owned nervix::layer(engine, ...) contracts. Resolved configuration Model types, constructors and matches are rejected in their interfaces and bodies even though other infrastructure engines may own persisted Models. Module contracts travel with source moves. The compiler and independent language import/dependency checks run through just ratchet; these classifications introduce no runtime policy or connector lifecycle change.

The contract and every integration crate take their execution-sensitive primitives from nervix-primitives: synchronization, tasks, the timers and monotonic instants their deadlines and backoff are measured with, and every socket a connector opens itself. A modeled build therefore selects them for the whole graph, as Execution Primitives describes. The sockets and timers a driver library creates inside itself stay the driver’s and are outside that selection. A connector’s own code never resolves a name through the operating system: it resolves through the node’s resolver, as the sections below describe, and a driver that resolves inside itself is one of the residual paths Residual Resolution lists.

Connector hygiene standard

The following standard applies to each connector crate, its declarations, and the server paths that compose and report it. It also applies when another feature adds or changes one of those paths.

  • One source for names. The vocabulary’s ConnectorKind display supplies connector names; source and sink entity names come from their vocabulary declarations. Configuration errors, transport and HTTP-client labels, metrics, logs, dataflow identifiers and DESCRIBE output derive their names from those sources. A required namespace or prefix is derived once and reused, rather than introducing another spelling or case table. Protocol field names and external configuration passed through to a driver retain their external contract.
  • Typed causes until reporting. A connector owns semantic error enums using thiserror and propagates context with error-stack. Preserve a driver’s typed error beneath the connector’s operation context instead of replacing it with its Display text. A cause that Nervix can classify is a typed variant, not a free-text reason or Result<_, String>. Text received as part of an external protocol remains external data; it does not replace an available typed cause. Render a report only at the boundary that presents it, retaining the cause for callers that must decide whether to retry, reject a record or fail a start. Sensitive payloads and credentials stay out of rendered diagnostics.
  • Annotations describe the actual outcome. A nervix::error_boundary(outcome|library, ...) contract belongs on the exact error type or return contract it classifies, with a reason describing that path. An annotation may call a result a record rejection only when the implementation actually returns that record’s rejection. A failed connection, refused start or failed request is not made record-specific by its annotation. Errors And Diagnostics owns these classifications and reporting boundaries.
  • Unit tests exercise the owning paths. Cover each implemented publish path, window or batch algorithm, header and metadata mapping, and acknowledgement operation through its production owner. Include successful delivery and the supported rejection, transient and unanswered outcomes, partial confirmations, ordering and bounds where the path has those states. Exercise source polling, reads and close when the connector owns them. A path the connector does not implement needs no fictional test. Unit tests complement the public Cucumber scenarios and concurrency checks required by the behavior; they do not replace them.
  • Properties cover encodings and measures. Each connector-owned encoding or encoded-size measure has a bounded Bolero property over valid current values, registered in tests/bolero-targets.toml for ordinary and fuzz execution with its source-adjacent corpus. Lossless conversions compare the complete original value; size properties compare the measure with the exact production encoding, including headers and framing that count toward the limit, and exercise the limit boundaries. Lossy or one-way mappings assert their declared contract. A crate using a shared encoding or measure exercises that shared owner rather than copying its implementation. Property Testing And Fuzzing owns registration, budgets, execution and retained failure evidence.

A connector change keeps this chapter and the relevant ingestor or emitter manual current, runs just validate and the focused checks for its changed paths, and leaves the architecture ratchet at or below its baseline. Documentation changes also run just book. Execution-mode evidence follows the changed protocol or lifecycle, as Data-Plane Concurrency describes; changing labels or documentation alone does not introduce a synchronization claim.

Before closing a connector epic, review changes landed from parallel work for additional name sources, free-text causes, inaccurate boundary annotations, and publish, mapping, batch or acknowledgement paths missing their unit or property coverage. Record the review, evidence and any compliance tasks on the owning ClickUp epic and tasks. Implementation reports and temporary audit findings are not repository documentation.

DNS for HTTP and Iceberg

The node loads and validates one nervix-dns resolver at startup, and every integration below connects through it. Name Resolution owns that resolver: its configuration, answer order, cache and TTL bounds, lookup budgets and failures, the dependency features that select it, and the drivers that still resolve by themselves. These sections own how each integration connects through it.

Composition passes the resolver’s handle into the HTTP polling and Prometheus source plans and into the HTTP request, Sentry, OTEL HTTP, and Iceberg sink plans. The shared HttpClientConfig installs it on the Reqwest 0.13 clients of HTTP polling, Prometheus, Sentry and OTEL HTTP. The HTTP request sink resolves each request’s target host itself, as its request transport describes. Iceberg REST uses a separate Reqwest 0.12 client with that same resolver. Iceberg object storage uses OpenDAL 0.57 with a Reqwest 0.13 client that the connector builds with the node resolver and installs through HttpClientLayer; the layer also supplies HTTP calls made by OpenDAL’s credential providers through its accessor info. That client takes no timeout_ms, CA file or client identity from the client’s configuration; OpenDAL’s own timeout layer bounds each storage operation. The Reqwest 0.12 client receives an explicit AWS-LC rustls configuration with bundled trust roots, including in an isolated Iceberg connector build. The three Iceberg backends keep their S3, GCS, and Azure property mappings, URL-derived bucket or container, timeout and retry layers, and commit boundary. The standalone OpenDAL S3 detect_region helper constructs its own client, but Nervix does not call it; S3 operator construction requires its configured region or the driver’s environment policy.

No client Nervix builds ever initializes Reqwest’s default Hickory resolver: each replaces it with the node resolver before its first request. That default could choose a public name server if reading system DNS configuration failed. A bad node resolver configuration therefore fails node startup, while a lookup failure reaches the existing source or sink failure path. Request URLs, HTTP authority, proxy behavior, TLS verification, custom trust and identity, and connection pools remain with the HTTP client. A client’s timeout_ms includes DNS resolution, connection setup, TLS and response handling; the resolver’s own 30 second ceiling only bounds clients without a shorter request timeout, which includes both Iceberg clients. DNS failures use the host’s existing retry policy and do not create application-level probes or acknowledgements.

DNS for RabbitMQ

Composition also passes the node resolver into every RabbitMQ source plan and sink configuration. The connector reads the client’s addr as an AMQP URI and takes its host from the URL grammar, because Lapin’s own grammar substitutes localhost for an IPv6 literal host. Every connection resolves that host again through the node resolver: a sink’s start, its reopening after a failed publish, and each source instance’s resume. A literal IPv4 or IPv6 address is its own answer. The connector tries the answers in resolution order, each within an equal share of what remains of a 30 second budget, and for amqps completes the TLS handshake within what is left. The handshake verifies the broker certificate against the host addr names, whichever of its addresses was dialled, trusting the platform roots and the client’s tls_ca_file. The connector then hands the established transport to Lapin through Connection::connector. Lapin’s own reconnection stays off, so it asks that hook for a transport exactly once and runs the AMQP handshake over it. The AMQP handshake still has no deadline of its own, and the URI’s connection_timeout query parameter has no effect.

Lapin’s hickory-dns feature is deliberately not selected. It resolves through one process-wide async-rs resolver built on first use from the host’s /etc/resolv.conf, which ignores the node’s resolver configuration, hosts snapshot, lookup budget and concurrency bound. That resolver also keeps name-server connections whose tasks ran on the Tokio runtime of its first lookup, so a later runtime inherits connections of one that may have stopped. just validate-dns-dependencies keeps the feature off in the isolated RabbitMQ connector and in the server, and checks that Lapin’s TLS stays on AWS-LC.

A failed connection is a RabbitMqConnectError. A lookup failure keeps its DnsLookupFailure, and unreachable addresses, a failed or overdue TLS handshake, and a failed AMQP handshake are their own variants, as is a Lapin runtime that could not be created. A source reports the failure as a resume failure and retries on its declared RETRY POLICY. A sink reports an invalid address or CA file as a configuration failure and every other connection failure as an initialization failure, and the emitter host reopens it on its backoff. Neither path acknowledges undelivered data: a source that cannot connect holds no delivery, and a sink that cannot connect confirms nothing, so the input stays unacknowledged until a later connection delivers it. A connection that fails before its AMQP handshake has started no Lapin thread, and a failed handshake ends that thread and closes the socket. An established connection is not closed because its host’s answer changed or expired; the next connection uses the new answer.

DNS for Redis

Composition gives the node resolver to the shared Redis command pool and to each Pub/Sub source plan. Redis 1.7.1 exposes AsyncConnectionConfig::set_dns_resolver for command connections. The connector’s bb8 manager installs that hook whenever it opens a physical pooled connection; pool bounds, health checks and lease ownership remain with the existing pool and host. A pooled socket may remain open past an answer’s TTL. A replacement socket resolves the host again.

The driver’s Pub/Sub convenience connection does not accept that DNS hook. A source instead resolves the configured redis:// or rediss:// hostname through the node resolver on every initial subscribe and resume, and opens a dedicated TCP or TLS stream. It tries the returned addresses in order inside a 30-second budget shared by DNS, TCP, TLS, Redis setup, and subscribe. It hands the stream and the original client’s settings to Redis’s PubSub::new, so the driver still performs its authentication, database selection and protocol setup before SUBSCRIBE. A Unix socket remains a direct Unix connection with no DNS query. The dedicated subscription never occupies a pooled command connection.

For rediss://, the configured hostname remains the TLS server name after an address is chosen. The source’s TLS configuration keeps the same optional client certificate and key as the command client. Without a CA file both paths use native roots; a configured tls_ca_file replaces those roots in both paths. The shared TLS configuration helper exposes that replacement policy explicitly because other connectors add a configured CA to their default roots.

The Redis hook gives a lookup at most 30 seconds, inside the 30-second connection timeout the pool sets on the driver for the whole connection. Pub/Sub connection attempts have one 30-second budget. Missing names, empty answers, silence and transport failures are connection outcomes. They do not reject a record or confirm a publish; the host resumes the source and retries failed sink work according to its existing policy. A Pub/Sub connection broken by the broker or network is dropped and reopened with a new lookup, while an established connection is not interrupted solely because its answer expires.

DNS for MQTT

Composition passes the node resolver into every MQTT source plan and sink configuration. Both connect through rumqttc’s event loop, which asks a socket connector for the TCP stream of every connection it opens: the first, and each one after a connection is lost. Each client installs its own connector with MqttOptions::set_socket_connector in place of the driver’s default, which resolves with Tokio’s lookup_host. On every call the installed connector resolves the host the driver names, the host of the client’s addr, through the node resolver, so a sink’s event loop resolves again on each reconnection and a source instance on each resume; a literal IPv4 or IPv6 address is its own answer. It dials the answers in resolution order with the driver’s own per-address dialer, connect_socket_addr, which applies the driver’s NetworkOptions to each socket. Each attempt gets an equal share of what remains of the driver’s connect timeout, five seconds, which already bounds DNS, the address attempts, TLS and the MQTT handshake of one connection together, so resolution adds no deadline or retry of its own.

The driver layers the rest on the returned stream as before. For mqtts it completes TLS with the client’s CA and optional client identity and verifies the broker certificate against the host addr names, whichever of its addresses was dialled. It then runs the MQTT handshake with the client’s identity and session. Nervix’s build selects neither the driver’s proxy nor its WebSocket transport, so no other connection path exists.

A failed lookup reaches the driver as the resolver’s DnsLookupError, which the driver keeps as the cause of its connection error. The connector finds it there and reports MqttConnectionError::Resolve with the host and the lookup failure; every other failure keeps the driver’s own description. A source reports the failure as a resume failure and retries on its declared RETRY POLICY, or on the host’s reconnect cadence in MODE NO ACK. A sink’s event loop reports it as the emitter’s transient error and reconnects on the emitter’s retry policy. Neither acknowledges undelivered data: a source that cannot connect receives nothing, and a QoS 1 or 2 sink confirms a record only once the broker acknowledges it, so the input stays unacknowledged while the broker name does not resolve. An established connection is not closed because its host’s answer changed or expired; the next connection uses the new answer.

DNS for ClickHouse and SQS

Composition passes the node resolver into every ClickHouse sink configuration and into every SQS source plan and sink configuration. Both connectors keep their driver’s HTTP client and hand the resolver to it at the driver’s own DNS hook, so every new connection resolves its host again while the request URL, its authority, the TLS server name and the signing inputs remain what the client configured. nervix-dns implements both hooks on the node resolver: Hyper’s resolver service for hyper-util’s HttpConnector, and Smithy’s ResolveDns.

A ClickHouse client builds HttpConnector over the node resolver in both of its forms. Without TLS entries it is the plain HTTP client the driver would build for itself in this build, which has no driver TLS feature: an https address fails its request, and the connector keeps the driver’s 60-second TCP keepalive and 2-second pool idle timeout. With tls_ca_file or client identity entries the same connector is wrapped in the AWS-LC rustls configuration those entries build, trusting the bundled WebPKI roots, the platform’s native roots and the configured CA, and serving http or https addresses. Hyper dials a literal IPv4 or IPv6 host without asking the resolver, dials a name’s answers under its dual-stack policy, described in Dialling The Answers, and applies the URL’s port or its scheme’s default. The connector’s timeout_ms bounds the send and the response of an insert, and the connection and its lookup run inside that response wait.

An SQS client installs a Smithy HTTP client whose connector resolves through the node resolver with build_with_resolver. Without tls_ca_file the client is the SDK’s default HTTPS client with that resolver: AWS-LC, the platform’s native roots, and a proxy taken from HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY or their lowercase forms, whose host the node resolver resolves while the proxy resolves the service. With tls_ca_file it trusts that CA alone and uses no proxy, as before. The SDK signs each request with SigV4 for the configured endpoint before the connector resolves its host, so the signature, the Host header and the certificate check all name that host, whichever address accepted the connection. The SDK’s default 3.1-second connect timeout, and the operation and attempt timeouts a sink’s timeout_ms sets, include the lookup. The client names static credentials and its region, so aws-config never consults the default credential or region chains that could reach IMDS, ECS, STS or SSO over HTTP. It still builds its SSO token chain, which SQS never asks for a token because it signs with SigV4; the loader receives the same HTTP client, so that chain would use the node resolver too.

Both hooks give a lookup at most 30 seconds, like the Reqwest hooks; the client’s own deadline cancels it sooner, and a lookup cut short that way fails as that deadline’s timeout rather than as a lookup failure. A lookup failure reaches the connector as a cause of the driver’s connection error, where the connector finds the typed DnsLookupError and keeps it as the context beneath its existing failure: a ClickHouse publish failure, an SQS sink start or publish failure, and an SQS source resume, read or acknowledgement failure. The report’s message names the host and the lookup failure. An SQS source plan builds its client from configuration and TLS material alone; each instance looks its queue up when it first resumes and keeps the queue’s URL. A failure there, a lookup failure included, is a resume failure, which the host shows as the source’s transient error and retries on the source’s retry policy. A queue the service does not have is the typed SqsSourceError::MissingQueue, which names it, whether the service answers with AWS’s query-compatible code AWS.SimpleQueueService.NonExistentQueue or, as ElasticMQ does, only with the JSON protocol’s error type QueueDoesNotExist. Any other failure to reach the service, including a certificate that does not name the configured host, is described by every cause of the driver’s connection error, which describes the connection and never a record; a service’s own response keeps its existing description. None of these failures rejects a record or acknowledges input. The host retries each on its declared backoff. An SQS sink keeps the SDK’s own retries disabled, while an SQS source keeps the SDK’s standard retry mode, which makes up to three attempts at a request whose connection failed, lookup failures included, before the source reports the failure. Pooled connections stay open when their host’s answer changes or expires; the next connection resolves again.

DNS for OTEL gRPC

The OTEL sink receives the node’s resolver in its typed sink configuration. Its gRPC transport builds a Tonic 0.14 lazy channel with a custom Hyper HttpConnector that resolves through that resolver. Constructing the sink or channel asks no DNS question and opens no connection. When an export first needs a connection, Hyper resolves the configured endpoint’s host, dials its answers under its dual-stack policy, and applies the endpoint port. Tonic keeps the original URI for HTTP/2 authority and TLS server-name verification, and retains its configured roots, optional client identity, metadata, compression, and request timeout. A client that sets timeout_ms uses it as both the request timeout and the connection timeout, which encloses DNS, address attempts, and TLS. Without timeout_ms the channel has no connection deadline, and only the DNS hook’s 30-second ceiling bounds the lookup.

An unresolved name or failed connection remains an infrastructure export failure under the sink host’s existing retry and ACK policy; the export failure reports the gRPC outcome, not the lookup failure beneath it. A successful pooled channel may outlive an answer’s TTL; a new connection resolves again through the node resolver. OTEL HTTP protobuf continues to use the shared Reqwest path described above.

The native prepared-request check uses a real loopback DNS authority and gRPC receiver. It observes an initial negative answer without delivering or rejecting the prepared log record, publishes the receiver’s address, and requires another DNS question and exactly one accepted request with the original prepared bytes inside ten seconds. The public one- and three-node outline also checks log and trace exports after clearing an unavailable sink and a negative DNS answer. These are bounded recovery observations, not a simulation of Tonic or a guarantee about every external collector’s availability.

sequenceDiagram
    participant NSPL as NSPL and Models
    participant Registry as Registry and decisions
    participant Host as Server host
    participant Driver as Connector crate
    participant Graph as Runtime graph
    NSPL->>Registry: Semantic Model
    Registry->>Registry: Validate capabilities and references
    Registry->>Host: Validated typed start plan
    Host->>Driver: Open with resolved configuration
    Driver-->>Host: Source messages or sink outcomes
    Host->>Graph: Decode, route, and track ACKs

Blocking work and the node’s executor

A connector runs on the host’s asynchronous tasks and never blocks one. Synchronous work it does itself, such as writing or reading a local file, goes through the node’s bounded executor, which the sink host hands a connector as SinkHost::executor. The connector chooses the storage or CPU class of its work and the memory it charges; the executor admits that work beside the rest of the node’s, and a refusal is the connector’s own typed failure. The Iceberg sink writes and reads its staged Arrow IPC files on the filesystem storage workers, so a node that cannot take the job now fails the publish or the commit, which the host retries as it retries any other.

A driver call that parks its thread while it waits on the network computes nothing, and admitting it would hold a worker idle for the whole wait. librdkafka’s producer flush and metadata fetch are such calls, so the Kafka connector runs them on the runtime’s blocking pool as declared owners outside the executor, bounded by the host’s flush deadline and a per-request metadata timeout. Blocking work outside the executor lists every declared owner, and the boundary check rejects any other connector code that names the pool.

The source and sink traits carry source-local compiler execution contracts. Polling, message/header access, acknowledgements, publish, retry, flush and commit callbacks are recurring. Source open and close are explicit lifetime boundaries; Kafka domain-offset initialization is a resume installation boundary. Implementations inherit the trait method’s frequency. External driver, conversion and callback dispatch is documented at its owning callable, and local callback bodies remain checked. These annotations describe this chapter’s existing lifecycle; they supply no runtime admission or ownership proof. Data-Plane Concurrency owns their syntax, inheritance, diagnostics and operation-specific repair expectations.

Source boundary

Endpoint and Syslog sources are assigned to all live voters when a schedule is prepared, including cordoned nodes excluded from placed work. Restore, rescheduling, and voter removal use that same decision, so a restored coordinator serves intake after RESUME even when its relays run elsewhere.

Each domain revision installs its ingestor plans with its schedule. Building a domain, swapping or relocating an ingestor, starting the ingestors a runtime revision leaves missing, and placing Kafka domain offsets all read those same plans; none of them reads the ingestor’s Model. Starting an ingestor binds its codec, node filter, routes and branched entrypoints against the installed domain surfaces and parses its declared acknowledgement before any connector instance opens, so a start that fails leaves nothing running.

A source plan combines connector-specific settings, validated capabilities, and the host’s ACK policy. Capabilities state whether header reads are available, which typed metadata scope exists, whether quiesce is supported, the nonzero instance count, the supported acknowledgement mode, and the position class the source’s entity declares. The source owns open, suspend, resume, close, the next broker batch or scheduled poll, and the meaning of acknowledging or rejecting its own position. A broker message lends its payload, headers, typed metadata, and transport position to the host. Positions remain connector-owned; the host never interprets one as a graph identity. Each source entity also declares its acknowledgement confirmation, how far its transport has confirmed positions once acknowledging them returns; see Confirmed acknowledgements.

A client source’s plan is not a connector plan: it carries the input schema, its compiled form, the producer policy of window, ACK timeout, and retry backoff, and the endpoint contract digest producers attach to. It declares no header or metadata scope and supports only SUSPEND.

The metadata boundary has distinct header, Kafka, and Syslog scopes. Kafka carries topic, partition, offset, and headers; Syslog carries the peer address. The scopes, and the fields of each with their exact types, are declared once in the vocabulary, and each source entity names the scope its messages expose. Registry validation reads that declaration for the fields an ingestor’s programs may name, the host builds its metadata columns from the same fields, and a connector’s metadata row names the scope it carries, so the three cannot disagree. Transport headers are visited from the borrowed message in arrival order. The host copies them only when the quiesce control decides on a payload, which may retain it, or a WebSocket session must retain them after the source message ends. Header support and each system’s mapping are explicit: HTTP endpoint and polling headers, WebSocket upgrade headers, Kafka record headers, NATS headers, Pulsar properties, RabbitMQ AMQP headers, and SQS attributes retain their connector-specific semantics. Other connector and entity pairs do not offer header reads. read_header and read_headers are validated against those capabilities; header values do not travel through relays unless an ingestor writes them into schema-backed fields. See Header Context for the exact expression behavior. The session completion resolver uses the same vocabulary source capability before the full ingestor is parsed, so it offers those functions only for sources that can read headers. Its emitter INVOKE completion similarly uses the vocabulary sink capability to offer write_header only for sinks that can write headers. Runtime validation remains authoritative.

The source host decodes consecutive payloads into one ingest group’s Arrow builders. For a schemaful JSON codec, that group also owns mutable payload scratch and simd-json parser buffers; the connector continues lending immutable payload bytes, and the host reuses its storage until the group closes. Compiled field keys direct borrowed JSON values into typed columns without a serde tree or an intermediate row representation. A rejected payload abandons only the partial Arrow row it started, including any list value it left half-filled at any depth, preserving the accepted rows and transport positions around it: every payload decodes into its group exactly as it would alone. At group execution, the host resolves event timestamps into one Arrow column and tests paced-domain admission with one bitmap. It selects the accepted batch and its ACK and metadata sidecars together. Each timestamp rejection retains the source row’s ACK for its route-local message-error delivery; accepted neighbors continue into the graph. A source position is acknowledged only after every message it unfolded into has completed its own route or error delivery.

Remote delivery retains that host-owned ACK share in a bounded delivery owner, with independent row outcomes and an exact generation identity. Admission refusal or cancellation before admission returns the share negatively; admitted records remain pending through keepalives and required waits until their terminal outcome or the silence bound. A relay owner whose relay memory has no room for the acknowledgement watches of a delivery holds the delivery back unadmitted instead of refusing it, and the host’s dispatch waits for that admission within its five-minute bound. A source in MODE NO ACK therefore stops reading while its delivery is held, as under any relay backpressure, and its output is delivered once the relay owner has room rather than lost. In an acknowledged mode the held rows’ shares stay pending without keepalives, so a hold longer than the source’s ACK TIMEOUT rejects their positions and the source delivers them again, possibly after the held delivery was admitted. Relay construction retains the domain’s ACK-root tracker, so intake does not resolve a mutable domain registry for each row. Receiver watchers retain the sender’s full process identity and charge their memory to the relay budget; sender replacement or shutdown ends them. These correlations remain volatile and are never a connector position or persisted commit. Cluster Interconnect owns their capacity and deadline contracts.

The host refreshes pending ACK progress every 100 milliseconds during connector requests and retries. This cadence must stay shorter than a source’s one-second ACK TIMEOUT so a pending attached sink attempt does not make the source replay work while the sink still owns it.

Each source host retains its exact instance readiness handle. Poll success and suspend/resume publish through that scalar, without looking up the ingestor registry. Replacing an ingestor retires its preceding handles before installing the new instances; host drop also retires its own handle. Retirement is final, so late polling or teardown cannot mark a replacement ready or unready. Readiness observers aggregate the installed instance handles. Quiescence and ACK/commit ownership remain with the host’s existing loop and controls.

The host runs three source loop families, with a listener using the broker loop:

FamilyHost behaviorSource behavior
Broker and listenerOpen instances, manage readiness and quiesce, request batches, decode and dispatch, wait for ACK roots when configured, then acknowledge or reject positions and pace retry.Subscribe, receive, expose transport positions and metadata, and perform transport ACK or rejection. Syslog is a listener with no broker ACK.
PacedBind and wait on the domain cadence, hand the scheduled instant to one poll, admit its returned messages without broker ACKs, and report poll failures.HTTP and Prometheus perform one transport poll; they do not bind a domain clock or choose the cadence.
Request scopedBind endpoint routes to request intake, admit and dispatch each request there, replay retained quiesce work, then unbind on close.The endpoint source has no polling transport or broker position.
Client batchesKeep the producers of one client ingestor, admit their batches one at a time through one admission worker per execution, give each admitted batch one ACK root, and answer each batch with its outcome.There is no source connector: producers submit Arrow IPC batches through the session protocol.

The broker, paced and request-scoped loops each drain what their instance’s quiesce buffer retained once the ingestor resumes, oldest first. A retained payload stays in its buffer, counted with its bytes, until its messages enter their ingest group, and the buffer hands out nothing behind it until then. Its sender was already answered and its source has moved past it, so the host admits its ON INGESTION unfolding to wait for a place on the node’s extension workers rather than to be refused, ahead of work that asks afterwards. A shutdown or a new quiesce ends the wait and returns the payload to the front of its buffer, so a drain delays neither. A payload its codec rejects leaves the buffer and is reported.

At the start of a graceful stop, the host closes intake on the stopping node before the control plane moves scheduled sources or sinks. The shutdown quiesce suspends broker polling and listener admission regardless of the ingestor’s declared ON QUIESCE mode; already admitted payloads and ACK roots continue through the graph. The connector does not choose the move or finish its own acknowledgements early. A batch’s work stays outstanding until its source has confirmed the batch’s positions on the transport, so both the move and the in-place drain wait for that confirmation; see Confirmed acknowledgements. A source started on the destination node after handoff uses that node’s open intake.

A live payload a source hands over without an acknowledgement, a paced poll or a batch read in a MODE NO ACK, cannot be presented again either. An acknowledged batch the extension workers refuse is rejected for its source to deliver again, and an endpoint refuses a body its sender sends again; a live unacknowledged payload follows its source’s unacknowledged admission instead, which each source’s composition declares. Waiting holds the source’s loop, so a source waits for a place only when its transport stays connected with bounded memory while nothing reads it:

SourceUnacknowledged admissionThe transport while its loop is held
HTTP polling, PrometheusWaits for a placeNothing is read. The next poll is delayed, and the domain cadence coalesces the occurrences it missed.
NATSWaits for a placeThe client’s connection task keeps reading and answering the server, and holds at most the subscription’s 65,536 messages before it drops the newest as a slow consumer.
ZeroMQWaits for a placePULL messages stay in the kernel buffers, and then the pushing peers’ sends wait.
SyslogWaits for a placeThe kernel drops UDP datagrams beyond the socket’s receive buffer; TCP and TLS connections fill the listener’s 64-frame queue and then push back on their senders.
MQTTRefused and countedThe event loop, which sends the keep-alive, runs only while the loop reads, so the broker closes the connection once one and a half keep-alive intervals pass without a packet from the client.
Redis Pub/SubRefused and countedThe client’s connection task reads every message into an unbounded queue.
WebSocket clientRefused and countedServer pings go unanswered while frames are unread, and a server that pings closes the connection.
KAFKA TOPIC in MODE NO ACKRefused and countedA consumer-group member that does not poll for max.poll.interval.ms leaves its group, and a domain-offset instance would keep its partitions through an ownership handoff.
PULSAR TOPIC in MODE NO ACKRefused and countedThe consumer engine blocks on a full receive queue, and the consumer’s close, which suspension and stop perform, then waits on it.

A waiting payload races the ingestor’s stop and every new quiesce decision. The stop drops it. A new decision, for a source whose input passes through the quiesce control, hands the payload to the control as input that arrived under that decision: BUFFER retains it, DROP drops and counts it, and a suspension lets it dispatch, so it waits on. A refused payload is reported as an ingestor error and counted in nervix_ingestor_unfolding_refused_total. A Pulsar message the host refused stays unacknowledged until its consumer reconnects.

For broker sources, None admits without an ACK root; Sequential requests one message and waits for its ACK tree; Parallel requests up to the declared in-flight limit within its batch timeout. The host takes in an acknowledged batch as one batch acknowledgement and waits for every accepted message’s ACK outcome before acknowledging the batch’s transport positions; a message a quiesce buffer or drop policy took over adds nothing to wait for. A failed or timed-out ACK rejects the positions and retries according to the source’s delivery policy. What the host does when rejection itself fails depends on the position class each source entity declares, which states what its positions still mean once the connection that delivered them is gone. The host handles each class once:

Position classEntitiesAfter a failed rejection
replayableKafka, PulsarThe host retains the positions, suspends the source, and reestablishes its assignment. It retries the same rejection before polling any later batch or committing a later position, so a retried rejection must not depend on a poll. The Kafka source therefore does not seek a partition that a consumer-group rebalance has moved away, because only a poll can assign that partition again.
broker_redeliveredRabbitMQ, SQS, MQTTThe host retains the positions, suspends the source, and resumes it. Once the resume succeeds it discards the retained positions, counts them in nervix_ingestor_transport_redeliveries_total, and reads again: the broker redelivers everything the lost connection did not acknowledge, and the new connection can neither acknowledge nor reject a delivery of the old one.
noneEvery source with only NO ACK, the paced sources, endpoints and client producersNothing is pending. The host keeps no position and reads again after its retry delay.

A transport acknowledgement that fails because the connection died is handled the same way: the host rejects the positions, that rejection fails on the same lost connection, and the class decides. A broker_redelivered source therefore delivers at least once across a reconnect, and a message whose acknowledgement the broker never received is ingested again. A transport without an acknowledged delivery mode has no redelivery guarantee from Nervix. The sequence below shows an acknowledged broker policy. The precise source-specific effects and NSPL modes are in Ingestors and Shutdown And Recovery.

sequenceDiagram
    participant Source as Source connector
    participant Host as Source host
    participant Graph as Attached graph paths
    Source->>Host: Batch with payloads, metadata, positions
    Host->>Graph: Decode and dispatch Arrow batch
    Graph-->>Host: One ACK outcome per accepted message
    alt All ACK roots succeed
        Host->>Source: Acknowledge transport positions
        alt The transport confirms them
            Source-->>Host: Confirmed
            Host->>Host: Report progress, release the batch's work
        else The confirmation fails
            Source-->>Host: Typed acknowledgement failure
            Host->>Host: Show the transient status
            Host->>Source: Reject transport positions
        end
    else Intake or an ACK root fails
        Host->>Source: Reject transport positions
        Host->>Host: Apply declared retry policy
    end

Confirmed acknowledgements

acknowledge returns only once the source’s transport has confirmed the positions, as far as the source’s entity declares in its acknowledgement confirmation, or with a typed acknowledgement failure. A source whose client returns before that declares so rather than reporting a confirmation it did not get:

Acknowledgement confirmationEntitiesWhen acknowledging returns
answeredSQSOnce the broker answered: an SQS message is acknowledged by a DeleteMessage request, and acknowledging returns with its response.
writtenRabbitMQOnce the acknowledgement is written to the connection. AMQP does not answer a basic.ack, so nothing more can be confirmed.
queuedKafka, Pulsar, MQTTOnce the client queued the acknowledgement, before the broker answered it and possibly before it reached the connection. A Kafka consumer-group commit is never awaited, a Pulsar acknowledgement returns once queued, and an MQTT PUBACK is written when the client’s event loop is next polled.
noneEvery source with only NO ACK, the paced sources, endpoints and client producersThe source acknowledges nothing on its transport.

The host reports progress for a batch only once acknowledging it returns. Until then the ingestor’s transient status stays as it was, and the batch’s work stays outstanding: the last of the batch’s acknowledgement roots to resolve keeps counting against its domain and ingestor until the source loop drops the batch acknowledgement after acknowledge or reject returns. A drain or an ownership handoff that waits for admitted acknowledgement roots therefore also waits for the source’s confirmation, and a node’s graceful stop completes outstanding acknowledgements before the source closes, within the drain timeout. A root that waits only on what a drain does not wait for, such as a REQUIRED WAIT dependency or a window that retains its row, still does not hold the drain, and neither do the resolved roots of the batch it belongs to, because the batch cannot be acknowledged until it resolves.

A confirmation that fails is the ingestor’s transient status, which DESCRIBE INGESTOR shows until a later acknowledgement is confirmed, and the host rejects the positions for their position class to handle. A stop that reaches the source loop once a batch’s acknowledgement roots have resolved does not abandon the batch: the loop takes the resolved outcome before the stop and confirms the batch first. A batch whose roots had not all resolved when its source stopped is left unconfirmed; the source instance logs source instance stopped before its transport confirmed positions it read; the transport presents them again with the count, and its transport’s own redelivery contract applies.

Sink boundary

Each committed domain schedule becomes one complete typed execution revision before it reaches the host. That revision carries placement, entrypoint and emitter plans, and the exact ownership-handoff fingerprint of the committed schedule. It decides one typed emitter execution plan per scheduled emitter before publishing the domain execution. The plan resolves its sink clients and codec, retains its ordered source relay edges and lowered source predicates, and lowers its route, HTTP request fields, SQS ordering group and the mappings of row and row request sinks. It also converts OTEL resource literals and the Iceberg commit cadence and size into connector values. Initial startup, reassignment and an entity swap use that same plan. A swap publishes new source and remote consumer edges from the new plan; it does not reconstruct the emitter from a Model in the host. The host resolves resource mounts and binds the lowered VM programs to installed schemas and UDFs when it starts the task. A retry reopens the sink with the same typed configuration.

A TO CLIENT SCHEMA emitter is a native sink plan, not an external connector plan. Its typed plan carries the exact output schema, declared BATCH limit and required ACK window, timeout and retry pacing, with no client object or codec. The host constructs Arrow IPC from the projected columns, retains each payload’s bytes and source member positions through the common prepared payload contract, and resolves every member only after the application ACK or route-level message error. A failed or interrupted attempt retains the prepared payload for the host’s next publish. The node’s client emitter endpoint owns competing consumer attempts and physical retry; the host continues to own flush, branch routing, quiesce, source ACK propagation and message errors. No connector crate reads the native output or chooses its graph semantics.

The host prepares one write for one of four sink contracts. A record sink receives codec-encoded keys, payloads, headers, optional ordering groups, timestamps, and the identity the host assigned each record of the write. A row sink receives a run of host-projected Arrow carriers of one source relay and concrete branch, the target columns their mapped columns are written to, and each carrier’s selected rows, execution time and, for a sink that retains them, acknowledgements; it encodes its external representation from those columns. A row request sink receives the same projected columns one carrier at a time and prepares requests from them once: each prepared request is the exact bytes the connector will send and the source positions of the rows it carries. The host retains every prepared request and hands it back, under an identity it assigns for the write, until the connector answers for it. An HTTP request sink receives prepared requests: each carries the identity the host assigned it, the validated method, the target normalized on the client’s origin, the application headers after case-insensitive replacement, and the exact body bytes the codec produced or no body at all. The host evaluates VALUES once per batch and excludes rows with mapping errors before calling a row sink or a row request sink. It retains the ACKs of the source rows every record, mapped row or request carries, so no runtime ACK map enters the connector. Each publish is one call per write, never a virtual call per row.

The host compiles a row or row request sink’s VALUES projection before opening that sink. A failed VM inference or compilation retains its typed VM report beneath a MappedValuesError that names the sink and the emitter, then the sink-initialization context. The emitter follows its existing initialization retry policy; the connector never receives a partially compiled mapping.

The ClickHouse row sink uses the shared columnar JSON writer for JSONEachRow. It prepares typed column readers and string escape masks once for each carrier of a write, then writes each selected row in mapping order without building per-row JSON values. It selects the writer’s octet encoding for binary columns, so a String column stores a BYTES value’s own octets rather than base64 text. It keeps the host’s request cadence and per-record outcomes.

An ordering group exists only where the sink plan declares one; today that is the SQS FIFO GROUP. The host binds the lowered declaration, evaluates it once per filtered source batch, and carries the result beside the batch: the batch’s branch key for FROM BRANCH, or a string column for an expression, whose null rows keep the reason they have no group. It selects that column through the rows the emitter’s route keeps, so each record keeps the group of its own input row. A record without a group is rejected by the host under the emitter’s message error policy and never reaches the connector, which receives each remaining record’s group already evaluated. An emitter whose plan declares no group carries nothing beside its batches.

Each connector classifies definite delivery and rejection per record, and may report one infrastructure failure for the attempt. A record sink answers for a record under the identity the host gave it, and a row sink answers for a mapped row under that row’s source position. A row request sink refuses a mapped row under its position while it prepares requests, and answers for a prepared request under the identity the host gave it. The host checks a record or request sink’s answers against the write before applying any of them: an answer for a record the write did not carry, or a second answer for one record, breaks the contract and fails the attempt without a retry. A record left unanswered without a reported failure leaves the attempt unresolved, and the host retries it, because nothing says that record was not written. It checks a row request sink’s preparation the same way before it retains anything: every row it handed over must be a member of exactly one request or refused, every request must carry a row, and every member must follow the members of the requests before it in source order, so the retained requests are sent in the order the connector prepared them. A preparation that breaks any of these fails the attempt without a retry and retains nothing, because the same connector would prepare the same rows the same way again. The host applies the answers to the corresponding ACK roots and error policy. The emitter task owns its buffer, maximum batch size, flush cadence, the commit cadence of the rows a staging sink holds, retry schedule, fault injection, stop deadline, and metrics. The connector owns the external operation and its completion point. A receiver-requested delay, which a connector attaches to the attempt’s failure, can extend, but cannot shorten, the host’s retry backoff; the backoff sequence advances as it would without it. The host ignores a delay whose end its monotonic clock cannot represent, so the backoff alone decides that wait. finish lets a transport empty a client-side queue within the remaining stop deadline; Kafka uses it. A sink may keep its client after a publish failure when reopening it would discard staged work or a persistent session, or, as the HTTP request sink does, when it holds nothing between attempts and reopening it would clear the transient failure its pending work is still waiting out.

A brokered record sink in MODE ACK awaits its broker’s answers through the contract crate’s ConfirmationWindow. The sink hands each record to its driver and gives the window the record’s identity, its execution time and the future through which the broker answers for it: a confirmation, a refusal for good with the message its message error reports, or a failure that leaves the record’s outcome unknown. The connector maps its driver’s answer to one of those three; the window decides nothing else about the driver. A record’s deadline is ACK TIMEOUT after its own hand-over. While the window holds MAX records it waits for the oldest answer until the oldest’s deadline and no longer before the sink hands over another record, so at most MAX records await answers at once and ACK SEQUENTIAL is a window of one. Once every record was handed over, the window waits for each remaining answer, oldest first and each until its own deadline. An answer given at the instant of its deadline is an answer. A failure ends the publish at once: the driver could not take a record for a reason that is not the record’s own, the oldest answer failed or missed its deadline, or a deadline is past the monotonic clock’s range. The window describes a failure of its own, a missed deadline or one past the clock’s range, in a printable attachment, which the emitter shows as its transient error; a failure the connector hands it carries the connector’s own description. The window holds the publish’s outcome, and both ways of ending a publish go through it, so before it reports a failure it settles every answer the broker already gave, the oldest’s included: a confirmed record is delivered and a refused one rejected, and the host’s retry never resends a record the broker confirmed. A record without an answer, or whose answer failed, stays unresolved and is sent again, which is the at-least-once delivery MODE ACK promises. The registered Bolero property connector-confirmation-window holds the window to a reference model of this rule over scripted answers given within, at, past or never by each deadline, failed answers and driver failures, and the Shuttle checks of the confirmation window hold its settlement while answers arrive concurrently with the publish.

The MQTT sink awaits its QoS 1 and QoS 2 answers through the window. A completed handshake confirms a record, a reason code about the record itself (not authorized, an invalid topic name or an invalid payload format) refuses it, and every other ending leaves it unresolved: the client, its connection or its session failed, or the reason code is not about the record. The client’s request queue holds MAX requests, as many as the window awaits, and the event loop takes them in order, so the client can refuse a record only while the queue still holds an earlier failed attempt’s requests, before the broker answered any record of the publish. The Kafka, Pulsar and NATS JetStream sinks still await their answers through windows of their own, which drop their in-flight records when the driver refuses to take one, so a retry can resend records their broker already confirmed; RabbitMQ settles its records when it loses a channel instead.

The task loop keeps the connector state, buffer, retry schedule, backoff, and reconnect decision in one mutable owner. Force flushes, cadence or retry wakes, and input-triggered publishes all apply one outcome transition: success clears retry state and records in the sent metrics the rows the sink delivered, each once however many attempts that took and never a rejected one, a retryable failure defers the owned work and decides whether to reconnect, and a terminal failure records the rows delivered before it and routes every other still-owned source row through the emitter’s message error policy. Stop requests retain their separate deadline-bounded final flush and transport finish, and a stopped interaction performs its final drain before the loop exits.

An ownership handoff closes source admission and boundary dispatch while installed routes and sinks finish their admitted work and requested flush. Engagement confirms the closed admission hold; the participant’s exact-operation drain-status query publishes its ownership freeze only when that local drain completes. Capture requires the held operation and its exact freeze identity, so it cannot prevent a route from completing its flush. Preparation retains its original deadline; the distinct gate lease protects capture and activation. Shutdown And Recovery owns the complete handoff lifecycle and failure outcomes.

A quiesced backup keeps these source and sink tasks installed. The host stops domain source admission according to each source’s declared quiesce policy, reports admitted ACK roots and publishing sinks to the domain drain, and runs a confirming force flush after admitted work clears. Listeners for other domains continue serving. A sink that does not confirm before the backup’s quiesce timeout leaves the backup incomplete; it is not treated as a successful publish.

Generated source loops flush pending work when paused and release their in-flight work guard before waiting for resume. They acquire a new guard before executing again. This lets the host finish quiescence and capture fresh materialized state while the installed generator task waits.

When that policy sends a failed emitter record to a DLQ, the host executes the message-error SET program bound during domain installation or replacement. The prepared route retains the input and optional attempted codec-record schemas, the source branch, relay target and flush cadence. The connector receives no error-record Model or VM program and makes no DLQ routing decision.

Terminal node or domain teardown is a different boundary from a successful stop request. After its drain budget ends, it cancels an emitter task even when the connector is waiting for an external answer. The host drops that task’s prepared payloads and unresolved ACK guards without turning the cancellation into a delivered response or a record-specific message error. The source’s acknowledgement and recovery contract then decides whether the record returns. An entity-pause swap uses the stop request instead and cannot install the replacement until its old task drains successfully.

For a record sink using the emitter BATCH clause, the host selects rows from successive Arc-backed Arrow carriers released by one flush. It retains each carrier’s source relay, exact branch key, execution time and original batch and row position. The host prepares members in arrival order and seals a payload when the source relay, branch, key, ordered headers, ordering group or codec container metadata changes. The codec encodes each candidate under MAX SIZE, and the host subdivides a candidate that does not fit; Arrow memory accounting still belongs to FLUSH. The connector sees one encoded record per completed payload and answers for it under the record’s identity, without learning which rows the payload carries. A candidate whose container cannot be produced fails each of its members with one message error, which the container failure’s typed reason decides; the evaluation failure beneath that reason can quote the payload it evaluated and is never rendered.

The connector owns the destination’s own limit on each record, whether the record carries one row or a whole batch payload, because only the connector knows what it writes around the payload. Where it can learn that limit it measures the complete message and rejects a record that cannot fit before writing it, with a reason naming the size and the limit, so the rejection follows the message error policy instead of failing the transport: the MQTT sink measures the PUBLISH packet against the Maximum Packet Size of the broker’s latest CONNACK and the largest packet MQTT can express, the SQS sink counts attributes and the FIFO group against 256 KiB, and the Kafka producer, the NATS client and the Pulsar client check message.max.bytes, max_payload and the maxMessageSize of the connection’s CommandConnected with the key, headers or message metadata they write. A limit the connector cannot learn stays with the destination, and the destination’s own answer decides the outcome. A Pulsar broker answers a message above a topic’s own maxMessageSize policy with NotAllowedError, a definitive rejection of that message. RabbitMQ never tells a client its max_message_size, but its refusal of a larger body closes the channel with the limit named, so the RabbitMQ sink rejects the first message of the write the broker had not answered whose body exceeds that limit, with the same kind of reason, and writes the messages the broker discarded behind it again on a new channel. Anything else a destination reports when a message exceeds its limit is classified like any other publish failure.

Syslog sends a completed codec payload as one transport frame. Its UDP writer rejects a frame above 65,507 bytes, octet-counted TCP and TLS reject a count needing more than ten digits, and non-transparent TCP rejects one containing LF. The Sentry writer accepts one JSON event per envelope and checks the final event after default fields are added against the 1 MB decompressed event limit. It writes every other member as the codec wrote it, a number to its last digit. Both classify a definite local refusal as a record rejection; their transport and service failures retain their existing retry boundaries.

The host owns that membership. It keeps every payload it offers the sink, with its exact bytes, key, headers, ordering group and member positions, in the emitter buffer beside the batches the members came from, and marks the members prepared so that no later attempt packs them again. A confirmation delivers every member at the completion point MODE selects. A rejection routes every member through ON MESSAGE ERROR with the sink’s one error reference, each member keeping its own execution time, branch and acknowledgement. A payload the attempt left unanswered, because the sink failed or its outcome is unknown, stays retained unchanged. The next attempt, whether a retry, a force flush or a drain, writes it again byte for byte with the same members, ahead of any payload packed after it, and never writes a payload the sink already confirmed or rejected. The retry schedule keeps the members’ acknowledgements alive while they wait, and the buffer counts them as work the node still holds until they resolve. The retained payloads live with the buffer rather than the connector, so reopening a connector between attempts keeps them; only a failure that ends the attempt for good releases them, and their members then follow the error policy with every other unresolved row. A row sink names every member itself, so its retry writes only the rows it left unresolved; MongoDB’s per-document results shrink a retried bulk write this way.

A row sink divides its own writes, because only the connector can measure what it sends. The host projects each buffered carrier once and hands the sink every run of successive carriers of one source relay and concrete branch in one call, so a write never spans relays or branches. The typed plan passes the emitter’s BATCH limits to the connector, which narrows them to what its destination takes in one request and divides the run by one rule the contract crate owns: candidates in packing order of at most the row limit, a candidate whose exact measured size exceeds the byte limit halved and measured again with the rest returned to the front, and a single row that still exceeds it rejected alone. A row over MAX SIZE is a validation error of the encode operation; a row within it that the destination could never take is the destination’s external rejection of the row. Every request answers for the rows it carried, and a failed request leaves its rows and every later request’s unresolved for the host to retry, so a retry packs the same rows into the same requests again.

The database sinks divide the whole run. ClickHouse measures the JSONEachRow body of an insert, each row’s line encoded once and sent as a slice of one buffer. Postgres measures the one unnest statement every insert of the write executes and the text arrays it binds, exactly as the driver encodes them, and never lets that size exceed the largest protocol message the server reads, which bounds the Bind message that carries the arrays. MySQL measures each multi-row statement and every value’s binary-protocol encoding, and narrows the row limit to the rows whose placeholders fit the 65,535 one statement binds. MongoDB measures each inserted document with the _id the driver prepends, or each upsert’s filter and update documents, and rejects a row whose document exceeds the server’s 16 MiB document limit before any write, because the driver would refuse the whole write for it. A record-specific failure of a multi-row SQL write is isolated by writing its rows one at a time; a Postgres cardinality violation, which is how ON CONFLICT DO UPDATE refuses one insert that carries a key twice, is isolated the same way. MongoDB answers per document, so it needs no isolation pass.

Registered Bolero properties hold these rules against their owners, with measures that grow and shrink with a candidate: connector-row-request-division the division rule and both limits, emitter-batch-subdivision and emitter-batch-packing the candidates, seals, relay, branch and envelope grouping and the exact container bytes, codec-bounded-encodings and bounded-write-limits the exact sizes under MAX SIZE, emitter-row-preparations and emitter-retained-payloads the membership and byte-for-byte retry, and sqs-batch-requests, mqtt-publish-packets and postgres-insert-measures the connector-specific measures. See Property Testing And Fuzzing.

Iceberg is a row sink that stages each carrier of the run as its own file, which its commit publishes.

OTEL is a row request sink, which the host hands one carrier per preparation. Without BATCH it prepares the successfully mapped rows of the carrier as one Export request. With BATCH, its typed plan passes the emitter’s limits to the connector, which divides the successful positions by the same rule, measuring the exact uncompressed protobuf Export request, including resource and scope, before optional gzip or transport framing; a record whose request alone exceeds MAX SIZE is refused while preparing, as a validation error of the encode operation. The connector converts each row once and samples the log records’ observed_time_unix_nano from actual UTC once per preparation, then encodes each request once.

The retained boundary is that encoding: the exact protobuf bytes of the Export request and the positions of the rows it carries, kept in the emitter buffer beside the batch payloads and HTTP requests other sinks retain. It is the quantity MAX SIZE measures and the message the receiver decodes. Gzip and the gRPC or HTTP framing are applied to it on every attempt; both are deterministic functions of those bytes and of the client configuration, which cannot change while the emitter runs, so an attempt compresses the retained bytes exactly as the one before it did. Keeping compressed or framed bytes instead would tie the retained request to one transport attempt, and the gRPC client compresses a message itself. Over gRPC the connector sends the retained bytes through a pass-through codec rather than re-encoding a message.

The host hands the retained requests to the connector in the order they were prepared, ahead of any request prepared after them. An accepted request delivers every row it carries, a request the receiver refuses rejects every row it carries with one shared error reference, and neither is sent again. A request whose outcome the connector did not learn stays retained with its bytes and rows, and so does every request after it in that write. The next attempt, whether a retry, a force flush or a drain, sends it byte for byte, observed timestamps included, through whichever connector the emitter holds by then; the host reopens an OTEL connector after a failed publish, which is why the retained request lives with the buffer. Its rows are never mapped, converted or regrouped again. A receiver’s partial_success still acknowledges the whole request with a warning, because OTLP does not identify the rejected members.

An OTLP/gRPC export whose receiver never answered is retried: tonic reports a request timeout, a connection lost before the answer, or an answer it cannot read as a status carrying the local failure as its source, which no status a receiver sends has. An answered status is classified by its code: INVALID_ARGUMENT refuses the request, RESOURCE_EXHAUSTED and the codes the OTLP specification lists as retryable — CANCELLED, DEADLINE_EXCEEDED, ABORTED, OUT_OF_RANGE, UNAVAILABLE and DATA_LOSS — are retried no sooner than the receiver’s RetryInfo, and any other code fails the attempt as a configuration the endpoint cannot accept. OTLP/HTTP retries a connection failure, a timeout, a lost response, and HTTP 429 or 5xx, refuses on 400, and fails on any other status.

An HTTP emitter’s request fields are the host’s, not the connector’s. When the emitter admits a batch, the host evaluates one compiled program over each record’s original input, its finalized codec record and the batch’s materialized state, only for the rows route WHERE kept: METHOD and PATH are written into a write-only request namespace, and each write_header invocation is evaluated in written order. It validates each row’s method, then its target on the client’s origin, then each header write, rejects a row at the first field that fails, and buffers the rest with the fields they were admitted with, beside their Arrow rows, together with their original source records and the batch’s materialized state, which the message error of a later rejection reads. The request-field program must compile before the sink starts; a compile failure keeps the VM cause beneath the host’s emitter context. When a flush releases a row, the host encodes its body and retains the request, as a prepared payload with that one member, in the same buffer that retains batch payloads. Every attempt hands the connector the retained requests unchanged, ahead of any request prepared after them, so a retry repeats the request the destination may already hold. One connector publish call awaits at most one request across the emitter execution’s served sources and branches. It sends them in their handed-over order, each on a fresh HTTP/1.1 connection using the node resolver and the shared rustls trust and client-identity configuration. The connection closes after final headers; no unread body can be reused. Its one physical timeout_ms spans DNS, connect, TLS, send, interim headers and complete final headers. The connector resolves the target’s host through the node resolver within that timeout and tries the answers in order without dividing it among them. The connector sends no startup probe, follows no redirect, stores no response cookie, answers no authentication challenge with another request, and has no independent retry policy. The host alone schedules another application attempt.

The transport generates Host, Connection: close and, for a present body, Content-Length. It does not add Accept-Encoding or Content-Type. Application header writes can supply Accept, content type, authorization and cookie values. Without an application Accept, the transport adds Accept: */*.

Each interim and final response header block is checked for at most 128 fields and 64 KiB of name and value bytes; invalid final framing fails before the status is classified. Complete valid final 2xx headers deliver one request, without awaiting or interpreting its body. 408, 425, 429, 5xx, 401, 403, 407 and transport or header failures end the attempt with the current and later requests unresolved; the authentication statuses retain a distinct infrastructure reason. Other 3xx/4xx and 101 reject their one request with a structured external message error, then publication continues with the next request. The host applies delivered and rejected members, branches and acknowledgements and keeps unresolved prepared bytes for retry.

A failed attempt keeps its cause beneath the attempt’s own error: the resolver’s lookup failure, or the socket or TLS error. The connector attaches the description of that chain to the attempt’s publish failure, and the host reports it as the emitter’s transient error and runtime event. It names the status or the transport cause and never the evaluated target, a header value or a body. The connector keeps its client after a failed attempt, so the host’s retry sends the pending request without reopening it, and the failure stays reported until the request resolves. The host’s sent metrics count a delivered request’s codec record as payload, or nothing for a request without a body; the method, target and headers are request metadata.

When the final head of a retryable or authentication status carries exactly one Retry-After field, the connector reads it as RFC 9110 delay-seconds, whole digits only, or as an HTTP date in IMF-fixdate, RFC 850 or asctime form. It compares a date with actual UTC, read through the contract’s physical-time owner as the response arrives, and a date already past asks for no delay. It attaches the resulting delay to the attempt’s failure as the receiver-requested delay. Two such fields, any other text, a number beyond 64 bits, a date after 2262, or a delay that would end after 2262 attach nothing, and neither does an interim head or a delivered or rejected request. The host waits for the longer of this delay and its backoff on its monotonic clock, so the domain’s TIME RATE never shortens it.

For a sink that stages writes, the lifecycle reports what it holds for its next commit, either rows that wait for a domain-logical commit cadence or rows that reached its maximum commit size, together with a staged-message count, pending ACKs, and a commit operation. The host owns the cadence. It arms the cadence’s logical deadline at the first cadence check that finds rows staged, which follows the write that staged them, so rows that waited in the emitter buffer still wait the whole cadence after they are staged, and rows that later writes stage join that commit without moving it. The host includes the deadline in the emitter wake and commits once it is reached or the sink reports its size boundary. A retry and a drain force the commit without reading the domain clock, which a stopped domain no longer has. The host keeps retained ACKs alive during commit and retry, and reports sent metrics only after the commit reports publication. A failed commit retries on the emitter’s declared backoff until it succeeds or the stop deadline ends the attempt. See ACK Semantics And Effective Delivery for the user-visible delivery consequences.

sequenceDiagram
    participant Graph as Runtime graph
    participant Host as Emitter host
    participant Sink as Sink connector
    participant External as External system
    Graph->>Host: Arrow batch and attached ACKs
    Host->>Host: Buffer, encode or project, and flush
    Host->>Sink: One publish call per write
    Sink->>External: Write records or mapped rows
    Sink-->>Host: Delivered, rejected, and attempt failure
    opt Sink retains staged ACKs
        Host->>Sink: Commit on cadence, size boundary or drain
        Sink->>External: Publish staged data
        Sink-->>Host: Commit report and resolved ACKs
    end
    Host-->>Graph: Apply completion or failure

Integration-specific boundaries

  • Kafka source. The driver inspects topic partitions and reads or commits Kafka offsets. A rejection seeks each rejected partition the consumer is still assigned back to its earliest unacknowledged record. A consumer-group rebalance can move a partition to another group member while its batch is in flight, and Kafka cannot seek a partition the consumer no longer fetches. That partition needs no seek: whichever member is assigned it next, this consumer included, resumes it from the committed offset, which never passes an unacknowledged record. With OFFSET BY DOMAIN, the host supplies typed access to replicated next-offset state and a committed partition schedule. The leader observes partition topology and commits assignments; executing sources follow that schedule. Offset snapshots can lag a crash, so this mode remains at least once. Commits and start-point resets wait for the assigned state replicas within the native checkpoint bulk operation’s thirty-second budget. A missing acknowledgement still fails the commit and follows the host’s existing rejection and retry policy. Kafka ingestion defines the recovery details. Replica ACKs reach that installed offset state through a resolved, retired-on-ending route and the existing assignment publication. The host’s announcer retains its route and follows replica assignment changes without reading node-wide state or execution registries. The Kafka commit and acknowledgement boundary remains the state’s highest durable replica progress.

  • Pulsar client. The Pulsar source and sink build on Nervix’s fork of pulsar-rs, nervix-io/pulsar-rs, because the released crate discards CommandConnected. The fork keeps each connection’s announced maxMessageSize, refuses a message whose serialized metadata and payload exceed it before writing it, as the Java client does, and resolves a send the broker answers with SendError with that error’s server code and reason. The broker itself only closes the connection on a frame larger than the maximum plus 10 KiB of framing, which would fail every other message in flight on it.

  • Syslog stream listener. Each accepted TCP or TLS connection has one task that owns its frame buffer and reads into it directly, at most 8 KiB per read and never beyond the connection’s frame bound of max_message_size plus the eleven bytes of the longest octet count and its space. Framing moves a cursor through the buffer and copies each payload into the frame the intake queue carries; the bytes before the cursor are discarded once, when the next read needs room, so the bytes a read delivers are moved at most once however many frames they hold. The decoder finds a space or LF with memchr and remembers how far it has examined the frame at the cursor: an octet-counted frame keeps its parsed count until its payload is complete, and an unterminated non-transparent frame is searched for its LF only in the bytes a read added. An octet count, at most eleven bytes with its space, is read whole again when a read splits it. A frame split anywhere across reads yields the same payload and the same typed framing failure as one read holding the whole stream. Syslog ingestion defines the framing rules.

  • Syslog decoding, SQS bodies and OTEL identifiers. The SYSLOG codec checks header fields and scans STRUCTURED-DATA through byte classes of nervix-simd-kernels: the first byte outside printable US-ASCII ends a header check, and two forward scans find the byte that ends each SD-ID and PARAM-NAME and the ", \ or ] in each PARAM-VALUE, classifying each 64-byte block at most once. The codec resolves its schema’s fields against the fixed contract when it is compiled, so decoding appends each column by a typed field rather than by reading its name. The SQS sink takes a record’s body in one pass that validates UTF-8 with simdutf8 and classifies characters against the XML 1.0 Char production over the same bytes while they are cached, and turns the admitted bytes into the request body without validating them again; invalid UTF-8 outranks an excluded character, and either is a record rejection. Attribute values pass through the same character classifier. The OTEL sink decodes trace and span identifiers with faster-hex after checking their exact length and refuses all-zero identifiers. The registered otel-hex-ids property compares all bytes and acceptance with an independent per-digit scalar decoder for trace, span and parent-span keys, including lowercase, uppercase, mixed case, arbitrary UTF-8 text and the exact key and reason of every refusal.

  • Iceberg sink. It stages Arrow data locally on the node’s filesystem storage workers, prepares data files, and publishes a catalog update once its COMMIT EACH cadence, which the host measures from staging, ends or its staged files reach the maximum commit size. Staging does not complete an ACK; successful catalog commit does. The sink retains ACKs and its client while a failed commit is retried. Iceberg emission defines the external commit and duplicate limits.

  • RabbitMQ sink. The broker answers no NO_ACK message itself, so the sink ends a NO_ACK write with one round trip on its channel, which the broker answers only after it has taken every message written before it, and the answer delivers the write; a write costs that one round trip, not one per message. A broker that closes the channel names its reason, and Lapin hands it only to what was waiting on the channel at that moment, so the sink reads the reason from its connection’s events, once for every channel it loses. The size refusal leaves the connection open, and the sink opens its next channel on it; a message the broker had not confirmed ahead of the refused one may be in its queue, so the attempt then fails for the host’s retry. Any other loss is an infrastructure failure, and the host reopens the sink. RabbitMQ emission defines the public behavior.

  • Client source. Each client ingestor a node executes has one endpoint task that outlives a single execution of the ingestor. It owns the attached producers, the batches each queued, their round-robin admission into the execution’s one acknowledgement window, and every batch’s outcome. The execution’s admission worker validates a batch as one canonical Arrow IPC stream of the input schema, checking bounded IPC message and buffer declarations before Arrow decoding. It then tracks a new ACK root with the ingestor’s drain accounting, reads the quiesce state, and only then dispatches the batch through the ingestor’s filter and routes, so a quiesce either counts the batch or refuses it with nothing dispatched. The batch’s outcome is its ACK root’s resolution under the declared ACK timeout, which counts time without acknowledgement progress. An alteration that keeps the endpoint contract finds the same producers attached once the new execution is installed; a changed contract, a new domain generation, removal, relocation, and shutdown end them with the reason that applies. Before a model alteration closes a shared downstream relay gate, an intake-only hold suspends the affected ingestors across the cluster and force-flushes their buffered routes. Their admitted ACK roots drain through the open relay and sink. The full entity hold takes over suspension before the intake hold is released; a failed drain retains the committed execution and the admitted batches’ original outcome ownership. Ingestors defines the public behavior.

  • Pooled sinks. The connector owns the driver’s pool and borrowed connection. The host owns the lease on the node’s named client and the runtime wait while no connection is available. Database client pools defines the bounds.

  • Listening entities. A configured server-side listening port is bound on every live Nervix node, independent of leadership and placement. This includes Syslog and server endpoint listeners. The host owns their lifecycle across joins, restarts, elections, and shutdown; their transport implementations remain in their owning integration or server edge.

  • Named Syslog and WebSocket clients. Outbound Syslog UDP, TCP and TLS senders and WebSocket ws and wss sources resolve through the node’s asynchronous resolver for each new connection attempt. The node resolver owns DNS cache and TTL policy; the connectors keep their framing, upgrade, TLS, signaling and retry behavior. DNS and ordered address attempts share a physical connection budget. Syslog UDP binds a socket in each attempted address’s IP family and sends to the first address whose socket setup succeeds. TCP and TLS senders and WebSocket clients try successive answers within that budget. TLS verifies the configured name, and a WebSocket upgrade retains its original URL authority, path and query. The source host cancels a pending resume when shutdown or quiesce changes its lifecycle state. DNS and connection failures remain infrastructure outcomes for the host to retry; a resolved address alone never completes a Syslog record or an input acknowledgement.

  • WebSocket client source. The WebSocket connector crate supplies the outbound WEBSOCKETS CONNECTION source through SourceConnector and BrokerSourceConnector. It has no emitter or listening endpoint. Its plan carries resolved client configuration, the node resolver and an optional compiled signaling protocol. open prepares local transport and TLS state without resolving a name or connecting; resume resolves and connects, completes any signaling handshake, and only then reports readiness. Accepted data from a successful handshake is queued for polling before new socket frames. Text and binary frames become raw payload bytes for the host to decode; protocol control frames remain transport concerns. Suspension drops the connection while retaining queued payloads, and close also clears that queue. Transport failure requires another resume of the retained source. The host composer currently resolves the named signaling protocol and chooses buffered intake, a flush after each intake, and refusal with counting when unacknowledged unfolding cannot enter the executor. Holding its polling loop would also stop answering the peer’s pings. These transport requirements belong in a connector-owned contract declaration; the composer currently hard-codes them instead. The source ownership header names this boundary violation. The source’s position carries no external acknowledgement, and its acknowledge and reject operations are no-ops; reconnection supplies no durable replay guarantee. The host owns graph intake, routing, quiesce, admission and retry cadence. Server endpoint listeners use the server’s request-scoped source and share only the signaling engine with this outbound connector.

  • WebSocket signaling. The WebSocket crate owns the compiled signaling engine shared by client sources and server endpoint sessions. Its ordered send and wait clauses, acceptance policy, per-connection state and handshake timeout govern when frames become payload; the caller supplies the accepted-data sink, and the host still owns intake and routing. Domain activation resolves the endpoint’s VHOST and signaling reference first. The runtime binds the pinned protobuf resource, when present, and hands its send and wait message descriptors to the compiler. The compiled artifact retains the wire format, compiled jaq programs and timeout; each connection owns its handshake state. The compiler never chooses a graph route or an endpoint listener. A number keeps its value between a frame and the programs that read and write it: a protobuf double to its bits. The compiler currently also receives the configuration Model’s SignalingWireFormat and SignalingProtocolOnConnect, and parses the latter’s timeout text. This contradicts the connector engine’s typed-plan input boundary: the decision layer must convert those Model fields and the duration into a typed signaling plan before compilation. The crate and signaling module’s ownership headers name this violation; sharing the engine with endpoint sessions does not transfer Model conversion, resource resolution or routing into the connector. The accepted-data callback is currently the crate’s own SignalingDataSink interface. Its host service belongs in the shared connector contract; the signaling module’s header names this separate host-interface ownership violation. Registered conversion properties exercise complete messages with explicit scalar presence: double and float bits, full-width integers, booleans, strings, ordered repeated fields and absent defaults. The generated JSON omits empty repeated fields, which protobuf does not distinguish from absent ones. A separate malformed-frame property refuses invalid field tags, truncated fixed-width and length-delimited fields, oversized varints and incomplete packed float elements through the production decoder.

  • Server endpoint intake. HTTP ENDPOINT and WEBSOCKETS ENDPOINT ingestors share one server-owned intake; each head names the protocol its endpoint serves, and the registry refuses an endpoint of another TYPE before activation. The endpoint source binds one prepared intake lifetime to all of its configured routes before reporting readiness. Domain definitions and bound lifetimes share one immutable publication. HTTP resolves it once per request; a WebSocket retains its route and signaling protocol for the connection, including data accepted during signaling. Close or drop ends that exact lifetime before withdrawing it, so a retained route cannot attach to a replacement source. A request already holding an intake may finish. Configured routes without a live intake reject with HTTP 503, and after signaling an established WebSocket closes with 1013 on its next refused payload. Domain teardown removes only that domain’s definitions and ends its bound lifetimes, preserving other domains publishing the same host and path. The host continues to own quiesce, buffering, decoding, routing, flush cadence, and error reporting.

Failure and observation

The compiler failure-contract gate reads exact source-owned semantic outcome classifications for SQL mapped-column refusals and MongoDB value/document refusals. Their callers produce definitive record rejections with the record position and safe field path. Infrastructure and batch failures return contextual reports. Errors And Diagnostics owns the compiler classification contract; these annotations do not change acknowledgement or retry policy.

An ingestor opens all source instances before registration, so a failed source start leaves no running ingestor. An invalid emitter declaration or expression fails planning before its new execution plan is published. A sink initialization failure is reported as an emitter initialization error; the emitter remains unavailable and retries opening on its configured backoff. Invalid settings and missing external topics, queues, tables, namespaces, or other required entities surface as start errors: Nervix does not create them as a side effect. During execution, read and publish failures are classified separately from definitive record rejections. The host reports transient status and events, retries infrastructure failures, and preserves the configured message and general error policies. The general error policy receives the host’s typed report of a failure that ended a batch’s work and renders its whole chain once: the event and every negative acknowledgement carry it after the emitter’s name and domain, and the log records it beside them. A general error the connector reports itself reaches the same policy with the connector’s description. An emitter that does not start on a node reports an EmitterStartError naming the emitter, its domain and the step that failed, such as a client configuration that does not resolve, a codec that cannot hold a batch, or a program that does not compile. A swap whose old task does not stop keeps that task installed and running, and fails with the task’s own description of its drain failure, or with the stop failure when the task did not answer. A commit failure keeps staged ACKs pending and visible until retry or drain failure; it never turns staging into success. A forced ending loses in-memory batches and ACK state, leaving external redelivery to each source’s contract.

Connector-owned fallible helpers return contextual reports. Syslog configuration and frame decoding create a report at the failed parse, read, or framing check; the source adds its connection or lifecycle context before the host receives it. WebSocket signaling keeps its jaq, frame encoding, and transport causes beneath the compiled protocol or session failure, and reads its connect timeout through the vocabulary’s guarded duration parser, keeping its DurationTextError beneath the invalid-timeout failure. The server retains the Syslog plan and signaling compiler reports in its runtime startup errors. A connector may turn a report into the existing source or sink outcome only at that boundary, while preserving the typed cause and rendering only non-sensitive configuration or transport details.

The host’s source start keeps every connector report it composes an ingestor from. A client configuration that does not resolve, a connector plan that fails, such as a Syslog, Redis, WebSocket, Pulsar or SQS plan, an instance whose open fails, and a domain cadence that does not bind all stay beneath the ingestor’s initialize failure, which names the ingestor and its domain. Host-owned refusals, such as a missing node resolver, signaling protocol or endpoint, Kafka DOMAIN offsets this node does not own, or a delivery-mode duration that does not parse, are typed causes in the same place. Host decoding returns the codec’s report through the ingest group unchanged and keeps it beneath the intake’s decode failure, which a source loop reports with its whole chain; the host never renders a codec failure into text before that boundary. A context whose parser or driver error is its #[source] leaves that error out of its own message, because the report carries the source as the frame beneath it: a rendered chain names each cause once. A Syslog max_message_size or addr value that does not parse therefore reads as the key and value followed by the parser’s error, both at the source’s start and in the Syslog sink’s configuration diagnostic.

The endpoint source also reports the full ingest-group dispatch or flush chain in its runtime event and log. A refusal to admit unfolding remains retryable and its diagnostic retains the executor cause without treating the payload as decoded or rejected.

Paced-source resume and poll status, source suspend and close events, and cadence failures render the full report supplied by their owner. Kafka partition-watch failures similarly name the ingestor and domain before the full inspection report, both during its initial inspection and subsequent polls. A stopped clock or timestamp arithmetic failure therefore remains visible beneath the cadence context. Rendering keeps the source’s existing readiness, retry and acknowledgement decisions and includes only the non-sensitive details the typed errors own.

For OTEL, each selected row’s conversion report becomes that row’s existing invalid-record outcome, with its signal key as the affected field. Postgres, MySQL, and ClickHouse inspect the typed insert error before deciding whether to isolate a rejected row or fail the whole attempt. The whole-attempt report retains a transport driver or pool cause beneath SinkPublishError; a row rejection keeps the destination’s safe SQLSTATE, error code, or named rejection reason. These context changes do not alter request grouping, successful delivery, retries, or ACK ownership.

The host owns ingestor and emitter metric updates, transient status, and runtime events. Source and sink tasks retain one immutable error/retry publication, so successful operations read and clear only an actual failure. DESCRIBE observes error and retry together; retry state still counts toward a drain when the payload buffer is empty. Pooled Redis and SQL sinks retain one wait slot per sink and publish it only after a connection borrow returns Pending. Its guard clears on completion, error or cancellation; sink teardown removes its own registration. Confirmation attempts retain the emitter’s startup counter. Quiesced payload and client outcome metrics use children resolved with their owning task. See Data-Plane Concurrency for publication and lifetime rules. Source open, resume, suspend, and close transitions have lifecycle logs; publish, retry, and commit failures carry connector identity without sensitive payload values. For metric names and DESCRIBE fields, use Metrics And Observability. A client source’s endpoint publishes its producer, outstanding, and window counts after every change and counts every batch it answers by outcome and cause. The Ingestors and Emitters manuals document connector-specific status and delivery output; Shutdown And Recovery owns the drain boundary. This chapter does not redefine those output formats.

Adding an integration

  1. Declare each entity it serves in the vocabulary: one Model per entity with its capabilities, its delivery modes, and a typed name, checked by the entity’s own rule, for each external name its statement writes. The connector and entity together name the statement’s head, such as KAFKA TOPIC INGESTOR. Then add the connector’s grammar module, which owns each entity’s body, and its completion. Keep external driver configuration raw only where pass-through is its intentional contract.
  2. Validate schema, reference, branch, header, quiesce, delivery, and external contract rules in the registry. Convert the validated Model into one typed start-plan variant before execution; a source’s variant belongs to the registry’s ingestor planner.
  3. Add one crate under crates/connectors/ with its ownership header, driver dependencies, and source or sink contract implementation. A brokered sink that awaits its broker’s answers in MODE ACK does so through the contract’s ConfirmationWindow, never a window of its own. Add its composition mapping in the server; do not teach the contract or registry about the driver.
  4. Add unit coverage for transport behavior and feature scenarios through NSPL, including three-node execution and failure paths. Confirm the crate boundary and run the repository’s validation and connector tests.
  5. Update the Ingestors or Emitters manual, relevant client and metric documentation, and the NSPL skill routing when its user-facing guidance changes. Regenerate the book.

The connector split was functionally qualified across 46 integration feature files and 359 scenarios, including three-node examples, in Connectors 14’s evidence. That evidence supports the current source and sink behavior; it does not claim throughput parity. Its Kafka A/B measurements found lower bounded-pressure rates and a separate 16-partition commit failure that require owner investigation. An integration must preserve its own transport and delivery semantics rather than assuming a generic envelope or exactly-once external outcome.

HTTP Emitter Architecture

An HTTP emitter turns each eligible relay record into one outbound request. Its method, target, application headers, and optional codec body are evaluated for that record. Complete, valid final 2xx response headers are the external delivery boundary. The endpoint can still apply a request whose response Nervix never receives, so delivery is not an exactly-once external effect.

This chapter follows the implemented HTTP path across the language, decision layer, emitter host, and HTTP connector. The Emitters manual owns the public NSPL contract and field-level rules. The Connector Crates And The Connector Contract chapter owns the shared host and connector boundary; Control Plane, Domain Clock, Resource Versions And Bindings, and Shutdown And Recovery remain authoritative for their respective lifecycle, time, binding, and recovery rules. The HTTP emitter acceptance ledger records the public qualification evidence summarized below.

From NSPL to an active sink

The language parses TO HTTP and its ordered METHOD, PATH, MODE ACK RETRY POLICY, and body selection into semantic Models. The registry validates the complete candidate against the same domain’s TYPE HTTP client, codec, exact expression types, available scopes, sensitivity, source branches, and error route. The decision layer lowers the validated model into a typed execution plan. A running data-plane task receives that plan; it does not read a Model or reparse NSPL. The server composes the HTTP connector with the emitter host. These conversions and their installation boundary are described in Execution Plans.

The sink has one request/response mode, with an explicit retry policy and exactly one body choice:

TO HTTP <client>
  METHOD <string_expression>
  PATH <string_expression>
  MODE ACK RETRY POLICY BACKOFF <duration> MAX <duration>
  (ENCODE USING <codec> | WITHOUT BODY)

METHOD and PATH are structured expressions, not client settings. HTTP MODE ACK has no ACK window or ACK TIMEOUT; NO_ACK, parallel publication, and the optional emitter BATCH clause are unavailable. The emitter still requires a route-local FLUSH policy. COLLECT FOR may gather input records before execution, while FLUSH governs when prepared work is released. Neither merges several records into one HTTP body. The TYPE HTTP client’s polling method setting has no effect on the emitter’s required method expression.

A request with a codec body

Here outgoing and event_body_codec are already declared in the domain. The codec maps an event_body schema with event_id and payload into one JSON object. The example uses an operator-provisioned destination; an input with method PATCH and path /v1/events/42?notify=true sends that method and target. Its tenant stays outside the body.

CREATE CLIENT api TYPE HTTP CONFIG {
  'endpoint' = 'https://api.example.com',
  'timeout_ms' = 5000
};

CREATE ATTACHED EMITTER deliver_event
  FROM outgoing
  TO HTTP api
    METHOD input.request_method
    PATH input.request_path
    MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING event_body_codec
  INHERIT event_id, payload
  INVOKE write_header('Content-Type', 'application/json'),
         write_header('X-Tenant', input.tenant),
         write_header('X-Body-Event', output.event_id),
         write_header('Idempotency-Key', input.event_id)
  FLUSH EACH 100ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The client endpoint is an origin only: http:// or https://, a host, optional port, and no credentials, non-root path, query, or fragment. PATH supplies the origin-relative target and query. The client requires a positive, schedulable timeout_ms for every attempt. Creating or starting the emitter makes no destination probe and provisions nothing at the endpoint. See the complete public example for the schema, wire schema, codec, and relay declarations.

A request without a body

This alternative uses the same relay and client. WITHOUT BODY uses the source record for input, message, and bare fields. There is no output schema, encoded input copy, or dummy body. It allows route WHERE and header invocations, but not INHERIT, SET, or VALUES.

CREATE EMITTER delete_event
  FROM outgoing WHERE input.request_method = 'DELETE'
  TO HTTP api
    METHOD 'DELETE'
    PATH input.request_path
    MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
    WITHOUT BODY
  INVOKE write_header('Idempotency-Key', input.event_id)
  FLUSH IMMEDIATE
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

The receiver gets zero content octets. HTTP framing may omit Content-Length for this request; the transport supplies it when a body is present. GET and HEAD, in any ASCII case, require WITHOUT BODY, including when the method is computed at runtime. CONNECT and TRACE are unavailable in any ASCII case. Other valid methods retain their evaluated spelling.

Scopes, order, and sensitivity

Method, path, header-name, and header-value expressions must have exact, statically non-null STRING type. There is no implicit cast or null omission. A codec route can read original input, finalized output, the working message at its finalized-output stage, and declared materialized state. A bodyless route has input and source-valued message, but no output or partial_output schema. Bare fields follow message. Branch fields, relay-name qualifiers, and source-envelope header reads are unavailable. A route may consume multiple source relays with the same payload schema, keeping each relay and concrete branch’s collection separate. Materialized dependencies and message-error targets must be compatible with every source’s exact branch declaration.

For an admitted batch, the host resolves materialized dependencies and takes one domain execution snapshot. Each eligible record then passes through this sequence:

  1. Evaluate its source predicate.
  2. Construct and finalize the codec record, when there is a codec.
  3. Evaluate route WHERE. A filtered record evaluates no request field and sends nothing.
  4. Evaluate and validate METHOD, then PATH.
  5. Evaluate write_header invocations in written order.
  6. Release the record on its declared FLUSH boundary.
  7. Encode the finalized codec record, if present, and send the prepared request.

All these expressions use the batch’s execution and materialized-state snapshots. Preparation or encoding failure sends no part of that record’s request. An external sensitive value, whether in the method, target, header name or value, or codec body, requires explicit leak_sensitive(...). Headers read by an ingestor do not propagate through a relay unless copied into schema-backed fields. Expression Functions owns the public function contracts; Errors And Diagnostics owns the safe failure representation.

Origin, TLS, and resource versions

The connector uses the node’s configured resolver, verifies HTTPS trust and hostname, and can load a CA and a paired client certificate and key from the HTTP client’s resource mount. A client can, for example, bind already uploaded TLS files as follows:

CREATE CLIENT secure_api TYPE HTTP
  MOUNT api_tls VERSION 3
  CONFIG {
    'endpoint' = 'https://api.example.com',
    'timeout_ms' = 5000,
    'tls_ca_file' = '{{ api_tls }}/ca.pem',
    'tls_cert_file' = '{{ api_tls }}/client.pem',
    'tls_key_file' = '{{ api_tls }}/client.key'
  };

VERSION LATEST is also accepted at creation, but resolves to a completed number when the statement applies. The stored binding and runtime plan keep that number across restart and relocation. Uploading a newer version does not move the client. A client-definition change or resource rebind follows the normal configuration-entity domain pause; changing only the emitter’s client reference takes an entity pause. Validation checks the origin, timeout, and certificate/key pairing before activation, without contacting the endpoint. The Resources manual gives the upload and mount syntax; the resource-version chapter owns pinning and rebind behavior.

Request preparation and transport ownership

The emitter host owns intake from Arrow batches, per-source and per-branch collection, route programs, the original source row reference, the captured materialized state, flush cadence, prepared request retention, source membership, ACK leases, backpressure, retry scheduling, metrics, and drain. It projects existing Arrow columns into the codec and request-field programs; it does not create a row-map payload. The HTTP connector receives a typed, already prepared request containing the validated method, normalized target, application headers, and either exact codec bytes or no body. It owns HTTP connection and TLS handling, transport-generated fields, response parsing, Retry-After interpretation, and per-request outcomes. It sees neither graph branches nor upstream ACKs. The connector chapter’s HTTP sink boundary defines the shared contract and host loop in detail.

One HTTP request is prepared for each eligible source record, even when COLLECT FOR or FLUSH groups records. The host stores the request as a prepared payload with one source member, along with the original record and admitted state snapshot. The method, target, headers, and encoded bytes are frozen for local retries. Nondeterministic expressions and codecs do not run again. Retained encoded bodies count toward node memory pressure; FLUSH MAX BATCH SIZE measures logical Arrow bytes and is not an encoded HTTP body limit.

The connector sends prepared requests sequentially, with at most one awaiting final response headers per active emitter execution across its served relays and branches. Requests released by one flush preserve their publication order, and unresolved work stays ahead of later work. There is no total order across independent executions, relays, or branches, nor an ordering promise for effects an endpoint applies after a connection is lost. Each attempt uses a fresh HTTP/1.1 connection, closed after final headers, so an unread response body cannot become the next response. The client’s physical timeout covers DNS, connect, TLS, send, interim heads, and the complete final head; queue time and backoff do not consume the next attempt’s timeout.

The transport generates Host, Connection: close, and Content-Length for a present body. It adds Accept: */* only if the application did not write Accept. It adds no Accept-Encoding or Content-Type. It does not store response cookies, issue a second request for an authentication challenge, follow a redirect, or retry independently of the host. The codec’s bytes are the entire body: there is no added wrapper, newline, form encoding, or compression. Writing Content-Encoding describes bytes the codec already produced and does not transform them.

Validation and supported bounds

The registry rejects statically known invalid literals when the graph is validated. Dynamic values undergo the same checks per record before publication. A later write_header call replaces an earlier value for the same case-insensitive name, but an invalid or reserved earlier write still rejects the record. An empty header value is sent as empty, and a nonempty value cannot have leading or trailing space or horizontal tab. UTF-8 non-ASCII values are allowed. Header values cannot contain CR, LF, NUL, DEL, or other disallowed ASCII controls.

BoundarySupported limit or ruleFailure
MethodNonempty ASCII HTTP token, at most 64 bytes; CONNECT and TRACE excluded; GET and HEAD bodyless onlyLiteral: candidate rejected; dynamic: message error before send
TargetOrigin-relative, exactly one leading /; normalized path and query at most 8 KiB encodedLiteral: candidate rejected; dynamic: message error before send
Application headersAt most 128 after replacement and 32 KiB of UTF-8 name and value bytes in total; each invocation also has the 32 KiB boundInvalid literal: candidate rejected; dynamic violation: message error before send
Response headersAt most 128 fields and 64 KiB of field-name and value bytes in each interim or final blockAttempt remains unresolved and retries
AttemptOne request awaiting final headers per execution; positive, schedulable client timeout_msAttempt failure retains work; timeout is physical
BodyNo extra HTTP-specific fixed encoded-body size boundEncoded bytes remain under node memory pressure until resolved; endpoint 413 rejects the record

The target is parsed relative to the client’s origin and cannot change its scheme, host, or port. Absolute URLs, // authority targets, fragments, backslashes, whitespace, controls, *, malformed percent escapes, and targets normalized to a leading // fail before sending. URL normalization percent-encodes non-ASCII text and removes dot segments, while preserving %2F, repeated query keys, their order, a literal +, and an empty query after a trailing ?. For example, /v1/a/../events?tag=a&tag=b&q=a+b becomes /v1/events?tag=a&tag=b&q=a+b. The HTTP specification’s target table shows the boundary cases.

The transport owns Host, HTTP/2 pseudoheaders, Content-Length, Transfer-Encoding, Connection, Keep-Alive, Proxy-Connection, TE, Trailer, Upgrade, and Expect. write_header cannot set them. Authorization, Content-Type, Content-Encoding, Accept, Cookie, and application headers are writable. Headers are compared without ASCII case; their wire casing and order are not guaranteed.

Outcomes, retry, and acknowledgements

The connector classifies a complete valid final head. Interim heads never acknowledge a record. Response bodies and trailers do not enter graph data or decide delivery, and the connector does not wait for or buffer a whole response body. Invalid final framing fails the attempt even if the status line is 200.

Final response or exchangeResult
200–299, including 202 and 204Deliver exactly this source record when final headers are complete. 202 does not promise later endpoint processing.
408, 425, 429, or 500–599Retain the request and all later work; retry after the host’s wait.
401, 403, or 407Retain and retry, reporting an authentication or authorization infrastructure cause.
Other 300–499, or 101Reject only that record through ON MESSAGE ERROR, then continue after the policy completes. Redirect Location is never followed; 304 and 409 are not delivery.
DNS, connection, TLS, send, timeout, malformed response, oversized head, or loss before complete final headersRetain and retry as an infrastructure failure.

A delivered record leaves the pending set and is never included in a later local retry. A definitively rejected record follows its route’s message error policy and likewise leaves the pending set. Only unresolved prepared requests are sent again. A transient failure holds the current request and later records behind it, across the execution’s relays and branches. The HTTP connector keeps its client after a failed publish, allowing the host to retain the reported failure while the next attempt is in flight.

The first physical backoff is BACKOFF, doubles on repeated failures up to MAX, and resets after a flush completes. There is no attempt cap. On a retryable final status, exactly one valid Retry-After can extend the next wait: ASCII whole seconds, or an IMF-fixdate, RFC 850, or asctime HTTP date compared with actual UTC when received. A past date contributes zero. The host waits for the larger of this delay and its backoff, even if the server delay exceeds MAX. Missing, repeated, malformed, or unrepresentable values (including a delay ending after the supported year 2262 timestamp range) add no delay, and an interim head or a terminal response cannot change this classification. The connector’s physical-time boundary and Domain Clock distinguish actual UTC and monotonic waits from domain time. TIME RATE scales COLLECT FOR and FLUSH EACH and the expression snapshot; it never scales HTTP timeouts, backoff, or Retry-After. FLUSH IMMEDIATE uses the existing physical minimum batching window.

ATTACHED keeps each unresolved record’s upstream ACK lease until HTTP delivery or its message error policy resolves it. DETACHED acknowledges the source at relay fan-out, but still confirms HTTP responses, retries unresolved requests, applies backpressure, and routes terminal errors. Source recovery depends on that source’s own acknowledged-delivery contract. A lost final response may mean the endpoint already applied the request; retrying it can duplicate the external effect. Nervix supplies no idempotency key. The application can send a stable event identifier, as the Idempotency-Key invocation above does, and arrange endpoint-side deduplication. A generated value is stable during a local retry because preparation is retained; source replay after a restart may evaluate it again. ACK Semantics And Effective Delivery defines the wider source/sink combinations.

Errors and inspection

A request-field error rejects its record at the first failed field. Failed expressions retain code evaluation; invalid method, target, or header values use validation. Method and path errors use operation publish and name method or path. A header failure uses invoke and its zero-based invocation index. Codec failures use encode. A terminal response uses code external, operation publish, and its numeric status. These errors identify the emitter and safe operation or field without quoting the evaluated method, target, header value, credential, request body, or response body. A message-error route sees the original eligible input and the captured materialized-state snapshot; a codec route also offers the attempted record as an all-optional partial_output. The error stays in its source branch. A bodyless route has no partial_output. Errors And Diagnostics owns the typed propagation and public redaction contract.

SHOW CREATE EMITTER and formatting preserve the request expressions, ordered header invocations, body selection, and retry/flush policies. DESCRIBE EMITTER shows the client, configured method and path, body: codec or body: without body, batch: none, publishing mode, flush policy, and transient error. The failure stays visible while a request remains pending, including during its retry attempt, and clears when it is delivered. It carries a status or safe transport cause and the retry wait, never request values. Normal retryable failures remain pending regardless of ON GENERAL ERROR; that policy cannot turn them into delivery success.

The emitter’s messages_total sent count increases once per delivered source record, not per attempt or terminal rejection. Its bytes_total sent count uses the ordinary logical Arrow payload accounting for a codec record. A bodyless delivery adds zero output payload bytes. Method, target, headers, and response bytes are not payload bytes or metric-label values. See HTTP inspection and metrics for the public rendering.

Replacement, drain, and recovery

Changing the sink’s method, path, client reference, body selection, or retry mode takes an ENTITY_PAUSE; a FLUSH-only change is DYNAMIC. Changing source membership or a client definition has its existing DOMAIN_PAUSE contract. ALTER EMITTER ... SET TO HTTP restates the complete method, path, mode, and body selection. SET CLIENT, SET MODE, and SET ENCODE USING retain their current meanings. DROP ENCODE cannot select no body; use SET TO ... WITHOUT BODY. The whole retained construction and error scope must validate against that selection. For example, an emitter with INHERIT cannot switch to WITHOUT BODY while retaining that clause. Changing construction or header invocations requires a complete DROP and CREATE replacement inside one transaction. A bad candidate or refused commit leaves the active emitter in place.

For example, this replacement changes the first emitter’s method and path without replacing its codec construction or header invocations:

ALTER EMITTER deliver_event
  SET TO HTTP api
    METHOD 'POST'
    PATH '/v1/events'
    MODE ACK RETRY POLICY BACKOFF 250ms MAX 30s
    ENCODE USING event_body_codec;

SHOW CREATE EMITTER deliver_event;
DESCRIBE EMITTER deliver_event;

The retained INHERIT still initializes the codec body. A transition to WITHOUT BODY would require a replacement that also removes INHERIT, so the operator uses DROP and CREATE in one transaction for that change. For a destination that cannot drain, the supported operational path is STOP;, change the emitter while the domain is stopped, then START;, after deciding how the source will replay unresolved work.

An entity pause gates intake and drains already admitted requests under their original client, resource binding, destination, and prepared bytes. A failed drain leaves the mutation unapplied; it does not move pending requests to a new endpoint. DESCRIBE TRANSACTION reports the required scope and actual engagement under Transaction Quiescence And Impact Inspection. When a destination cannot be repaired in place, an operator can stop the domain, alter the stopped emitter, and start the domain again. That path discards volatile prepared attempts and relies on the source’s replay contract; it can repeat an effect the destination already applied. The Control Plane chapter owns mutation and drain decisions.

Graceful node shutdown stops intake, force-flushes buffered HTTP work, and waits for eligible requests only within the existing physical drain deadline. Expiry or forced ending cancels an in-flight attempt without marking it delivered. Prepared method, target, headers, body, retry position, and ACK state are volatile. An attached acknowledged source can redeliver after restart; an unacknowledged source cannot recover a record it has lost. The Shutdown And Recovery chapter owns stop phases, the former-owner fence, and the source recovery boundary; this chapter adds no alternate shutdown protocol.

Qualification and practical limits

The completed 14-criterion matrix names each public scenario and the receiver observation that closed it. Every HTTP feature case ran on one-node and three-node clusters. Each row below identifies its primary public evidence; the ledger gives the exact scenario names, example counts, and receiver controls.

CriterionPrimary evidenceObserved result
1. Constant and computed requestshttp_emitter.featureTwo records send distinct methods, targets, headers and exact codec bytes; a constant request and an encoding rejection are observed.
2. Absent body and method rulehttp_emitter.featureBodyless DELETE, GET and HEAD send zero content; literal and computed GET/HEAD with a codec are refused.
3. Scopes and filteringhttp_emitter.featureinput and finalized output produce different captured values; a filtered record evaluates no failing request field.
4. Configuration and sensitivityhttp_emitter.feature22 invalid statements are refused; an explicitly leaked method, path, header and body reach the receiver.
5. Header replacement and boundshttp_emitter.featureCase-insensitive replacement, empty and UTF-8 values succeed; 22 invalid, reserved or oversized writes reject without sending.
6. Target normalization and boundshttp_emitter.featureThe specified path table, non-ASCII query and repeated keys match; ten unsafe or oversized targets reject before send.
7. Response-head successhttp_emitter_responses.feature200, 202 and 204 deliver; stalled and 32 MiB bodies are abandoned; malformed or excessive interim and final heads retry.
8. Stable retry membershiphttp_emitter_retries.feature, http_emitter.featureThe delivered first record is not resent; the failed second request repeats byte for byte, including a generated header and body.
9. Infrastructure retry and timinghttp_emitter_responses.feature, http_emitter_transport.feature, http_emitter_retries.feature, http_emitter_inspection.featureEvery retryable status and connection cause remains inspectable while pending; measured physical backoff and Retry-After survive domain acceleration.
10. Terminal rejectionhttp_emitter_responses.feature, http_emitter_retries.feature101, redirects, 400, 404, 409 and 413 route safe status errors with original input, state and branch; Location receives no request.
11. TLS and mTLShttp_emitter_transport.featureMounted client identity succeeds; untrusted, wrong-host and missing-certificate handshakes acknowledge nothing.
12. Relay and branch isolationhttp_emitter.feature, http_emitter_transport.featureTwo relays and two branches retain their own request values and error branches behind collection.
13. Inspection and replacementhttp_emitter_lifecycle.feature, http_emitter.feature, tools/nspl_format.featureBoth body modes round-trip; failed ALTER and COMMIT preserve the active emitter; admitted requests drain to their original destination.
14. Drain and replayhttp_emitter_lifecycle.feature, http_emitter_retries.featureGraceful shutdown force-flushes; forced ending leaves a Kafka offset for replay; a lost response permits the same effect twice.

The receiver’s high-water mark stayed at one request awaiting a final head for one emitter serving two relays and two branches; a separate regression observed two for two concurrent clients. A stalled or 32 MiB response body did not delay the next record, and the receiver observed abandoned connections after timeout, stop, and forced drain. Shuttle checks covered cancelled attempts and unresolved membership. Attached Kafka offsets remained uncommitted while a request was pending. The emitter publishes no per-emitter memory figure, so this evidence does not establish a fixed per-emitter memory ceiling; retained encoded bodies are governed by node memory pressure.

After the receiver fixture update, all seven HTTP features ran together with 180 scenarios and 2,870 steps passing without a retry. The affected HTTP polling, shared emitter, and shutdown suites passed another 168 scenarios and 1,764 steps. The ledger records the exact commands, focused unit and Shuttle results, documentation build, validation, and an earlier saturated cluster run whose three-node setup commands met leadership changes. These results qualify the implemented behavior and its stated limits; the ledger remains the detailed evidence record.

Client Ingestors And Emitters

Authenticated applications participate in a Nervix graph through persisted, domain-owned CLIENT PRODUCER ingestors and TO CLIENT emitters. Producers submit native Arrow batches; consumers receive constructed Arrow output and explicitly acknowledge, retry, or reject each delivery attempt. An attached client-to-client path completes its input only after the application ACK. Endpoint configuration survives a restart. Payloads, attachments, delivery attempts and ACK state are volatile, so applications own replayable input and idempotent effects.

The native endpoints, automatic restoration, shared C binding, and Rust/Python paced drivers are implemented. This chapter connects their delivered contracts. The following chapters remain the owners of their respective rules:

ContractAuthoritative documentation
Source syntax, admission and source outcomesIngestors
Sink syntax, construction, batching and graph ACK boundaryEmitters
Public attachments, subscriptions and clock observationSessions
Rust handles and shared bindingRust Client Library and the Client Implementation Manual
FlatBuffers, transport, correlation, cancellation and restorationClient Session Protocol and the Client Implementation Manual
Model validation, typed revision construction and installationExecution Plans
Native source/sink composition and the common emitter hostConnector Contract
Authenticated forwarding and cross-node failuresCluster Interconnect
Failure classification and safe diagnostic contentErrors And Diagnostics
Generation, execution snapshots, timestamp admission and deadlinesDomain Clock
Mutable owners, publication, credit and attempt synchronizationData-Plane Concurrency
Typed identities, absence and validation boundariesTyped States
ALTER scopes, actual engagement, rollback and inspectionTransaction Quiescence
Planned drain, node shutdown and restart admissionShutdown And Recovery
Runnable application and its command-line optionsPaced Simulation Drivers

Ownership And Routing

The ingestor or emitter name is the attachment target. An application open neither creates a graph node nor adds a graph edge. CREATE CLIENT ... TYPE ... describes an external connector connection; native endpoints instead name an internal schema directly and use the existing session listeners. Starting a domain needs no connected application. With no consumer, an emitter retains bounded pending work and applies graph backpressure.

The language parses NSPL once into structured Models. The registry validates domain references, exact schemas, sensitivity, branch declarations, construction, policies and capabilities. Pure decisions lower those validated inputs into native source and sink plans in one execution revision. The data plane executes plans without reading Models or reparsing executable NSPL.

LayerResponsibility on this path
VocabularySchemas, domain generations, endpoint contracts, typed attachment/delivery identities, limits and outcomes
LanguageCLIENT PRODUCER INGESTOR and TO CLIENT grammar, semantic Models, canonical rendering and composed completion
DecisionsValidate the candidate graph and produce typed source, route, sink and message-error plans
Data planeOne ingestor endpoint/admission owner; concrete branch route tasks; relay ownership; emitter host and competing-consumer attempt owner
Control planeCommit configuration, install revisions, establish generations, coordinate scoped holds and ownership handoff
EdgesAuthenticate sessions, reserve attachment capacity, correlate requests, route control/data and forward to the scheduled owner
Application clientRetain desired handles and pending results, process output concurrently, decide replay and maintain application identities

A native gRPC session may enter through any live node. That serving node routes each open to the endpoint’s scheduled execution; producer, relay and emitter owners may all be different nodes. The console’s binary WebSocket uses the same endpoint operations and FlatBuffers contract, but console sessions are served by the leader. Public endpoint scenarios exercise both transports. The Rust library and its shared C binding connect through native gRPC; the console’s ordinary inspection flow does not open a consumer or claim output.

flowchart LR
    App[Application producer and consumer] <--> Session[Serving node session]
    Session <--> Ingestor[Scheduled ingestor owner]
    Ingestor --> Relay[Scheduled relay owner]
    Relay --> Emitter[Scheduled emitter owner]
    Emitter <--> Session

Client graph nodes bind no additional port. Session listeners belong to every live node and remain independent of placement and leadership. Native payloads and their ACKs follow the data plane; administrative commands retain their leader routing. A healthy native attachment continues through a leader change that leaves its execution owner and session intact. A console leader change ends the WebSocket session and is a session gap.

Concrete Branches And Forwarding

Each ingestor has one scheduled execution and a shared source ACK window. Its routes construct outgoing branch keys. A producer cannot choose a relay, inject a branch key as transport metadata, or create another execution by connecting again. Route buffering and downstream execution remain local to each concrete branch. Unbranched work has an absent branch key.

An emitter consumes all its configured source branches. Collection, flush, prepared payloads and ACK windows remain separate for each source relay and concrete branch. Equal keys from different source relays never merge. Consumers receive the constructed schema plus a source-relay name, optional opaque branch fingerprint, member count, execution snapshot, delivery identity and attempt reference. The fingerprint exposes no branch-key values. Copy a required branch value into a schema field earlier in the graph and export that field explicitly.

Remote producers use a shared, authenticated relay-class duplex link per serving/owning node pair. Before admitting a forwarded batch, the owner takes a source-window slot and sends Admitting. The serving node records possible admission and answers Clear; only then may the owner dispatch the batch. This costs one additional link round trip. If the owner or link is lost, the serving node can truthfully report batches it never cleared as NotAdmitted(ProducerEnded). Cleared, unresolved batches become OutcomeUnknown(OwnerLost). Losing the serving node instead leaves every sent batch without an outcome uncertain to the application as SessionLost.

A remote consumer has its own authenticated relay-class duplex stream. The owner sends attempt metadata and IPC bytes in ordered chunks of at most 1 MiB; the serving node checks the announced length and reassembles one delivery before answering the application read. Settlement and heartbeat writes proceed beside response reads. Both forwarding forms use two-second idle heartbeats and a ten-second peer-silence limit. The Interconnect owns setup budgets, peer identity, quotas and link-ending semantics.

Configure A Native Graph

This complete graph accepts application events, constructs a branch per tenant and exports the declared output. It requires a running Nervix cluster and authenticated session credentials; there is no external broker, queue, codec, resource upload or connector client to provision. The private_note value is deliberately exported through explicit leakage.

Save the block as application.nspl. The model definitions form one transaction. The CLI commands below create the domain, select it with --domain, load the graph, then start it in separate phases.

BEGIN;
CREATE SCHEMA application_event (
  event_id STRING,
  tenant STRING,
  value I64,
  private_note STRING SENSITIVE
);
CREATE SCHEMA application_output (
  event_id STRING,
  tenant STRING,
  value I64,
  private_note STRING
);
CREATE SCHEMA tenant_key (tenant STRING);
CREATE BRANCH by_tenant SCHEMA tenant_key TTL 10m;
CREATE RELAY application_events
  SCHEMA application_event BRANCHED BY by_tenant CAPACITY 16;

CREATE CLIENT PRODUCER INGESTOR submit_events
  FROM SCHEMA application_event
    MODE ACK PARALLEL MAX 8 ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 100ms MAX 2s
    ON QUIESCE SUSPEND
  TIMESTAMP NOW
  TO application_events
    INHERIT ALL
    BRANCHED BY by_tenant SET tenant = input.tenant
    FLUSH EACH 10ms MAX BATCH SIZE 1MiB
    ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;

CREATE ATTACHED EMITTER receive_events
  FROM application_events
  TO CLIENT SCHEMA application_output
    MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 100ms MAX 2s
  INHERIT event_id, tenant, value
  SET private_note = leak_sensitive(input.private_note)
  BATCH MAX MESSAGES 256 MAX SIZE 256KiB
  FLUSH EACH 10ms MAX BATCH SIZE 1MiB
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;
COMMIT;

Load it through the CLI, using the cluster’s actual credentials and server address when they differ from these examples:

nervix-cli --server http://127.0.0.1:47391 --username default \
  --password "$NERVIX_PASSWORD" --command "CREATE UNPACED DOMAIN application;"
nervix-cli --domain application --command "$(cat application.nspl)"
nervix-cli --domain application --command "START;"
nervix-cli --domain application --command "SHOW CREATE INGESTOR submit_events;"
nervix-cli --domain application --command "DESCRIBE EMITTER receive_events;"

Open application.receive_events with exactly application_output’s fields, start its read/process/ ACK loop, then open application.submit_events with exactly application_event’s fields and submit Arrow batches. Request, for example, eight batches and 1 MiB of credit for each handle. A single application runs the consumer loop concurrently with producer outcome waits. Waiting for attached producer completion before consuming its output waits on the application’s own ACK. The library examples show the operations; the paced drivers below supply complete executable programs.

sequenceDiagram
    participant P as Producer
    participant I as Ingestor
    participant R as Relay
    participant E as Attached emitter
    participant C as Consumer
    P->>I: Submit one Arrow batch
    I->>R: Construct and route its rows
    R->>E: Deliver each source/branch batch
    E->>C: Output plus current attempt reference
    C->>C: Apply idempotent application effect
    C->>E: ACK current attempt
    E-->>C: Confirmed
    E-->>R: Resolve attached ACK share
    R-->>I: Resolve source root when all shares finish
    I-->>P: Completed

Construction, State And Error Policies

The source schema describes input before construction; the sink schema describes output after construction. Transforming routes start empty, use permitted INHERIT and ordered SET, and initialize every required output. Optional fields finalize as typed nulls. Filters and route predicates retain their ordinary scopes. Client sources have no header or connector-metadata scope; client sinks have no codec, header invocation or direct VALUES mode.

Sensitivity is part of the public schema contract. Exporting a sensitive value requires explicit leak_sensitive(...) or permitted inheritance with LEAK SENSITIVE, even if the declared output field is sensitive. Client output carries the deliberately exported value; relay-subscription masking belongs to the observation interface. Expression Functions owns conversion and leakage semantics.

Ingestors cannot read materialized state. Emitters can declare ordered, node-wide dependencies after their source list and before TO. Each dependency names a materialized relay in the same domain with the exact branch declaration of every source. For example, the following additional configuration runs in the application graph above, selected with --domain application. Its typed default works before a branch has materialized state; another source may later populate tenant_limits.

BEGIN;
CREATE SCHEMA tenant_limit (maximum I64);
CREATE RELAY tenant_limits
  SCHEMA tenant_limit BRANCHED BY by_tenant
  WITH MATERIALIZED STATE LAST BY TIMESTAMP;
CREATE ATTACHED EMITTER receive_bounded_events
  FROM application_events
  USING MATERIALIZED STATE tenant_limits DEFAULT { maximum = 1000 }
  TO CLIENT SCHEMA application_output
    MODE ACK SEQUENTIAL ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 100ms MAX 2s
  INHERIT event_id, tenant, value
  SET private_note = leak_sensitive(input.private_note)
  WHERE input.value <= relay_state.tenant_limits.maximum
  BATCH MAX MESSAGES 256 MAX SIZE 256KiB
  FLUSH IMMEDIATE
  ON MESSAGE ERROR LOG
  ON GENERAL ERROR LOG;
COMMIT;

Open a consumer of receive_bounded_events before sending more input: this second attached emitter is an independent fan-out share. DEFAULT binds a complete typed constant record when state is absent. REQUIRED SKIP resolves missing-state input successfully; REQUIRED WAIT retains it in memory, holds its ACK and applies backpressure. Dependencies resolve in written order and restart from the first declaration after a wait. They never scan other branches. Whole-branch eviction ends its suspended work. Runtime Nodes and Relay own the complete state contract.

Every client emitter requires both BATCH MAX MESSAGES <1..65536> MAX SIZE <bytes> and an explicit FLUSH. BATCH bounds the encoded IPC stream; FLUSH ... MAX BATCH SIZE bounds logical Arrow value, offset and validity bytes, excluding spare buffer capacity. FLUSH EACH is branch-local domain time. FLUSH IMMEDIATE is the physical 100 microsecond minimum batching window, with the normal forced-flush exceptions; it is not a guarantee of one-row output. Optional input collection has its own per-source/per-branch logical cadence. Each ingestor route also requires FLUSH and ON MESSAGE ERROR; general errors remain node-wide.

A malformed native input is refused as a whole before any row enters the graph. After admission, row errors follow their route policy and do not make the batch an atomic transaction. On an ingestor route, IGNORE acknowledges the failed row, LOG negatively acknowledges it, and SEND TO acknowledges it once its constructed error record is published to the error relay. An attached error emitter is therefore outside that source ACK chain. Error records use the ordinary typed code, operation, affected fields, reference, occurrence time and non-sensitive message. Ingestor error targets are unbranched; other error targets preserve the failed execution’s exact branch. Error construction never recursively invokes the same policy.

Native Batches And Public APIs

A producer payload is one canonical Arrow IPC stream: schema message, exactly one record batch and end-of-stream marker, uncompressed, with no dictionary or extension encoding and no field metadata. Every column is present, in declared order, with Nervix’s exact Arrow representation, nullability and valid array lengths, nesting and buffer ranges. The node scans the stream before Arrow’s reader reads any of it: its framing, that its schema declares only field types Nervix carries, and that its record batch declares the field nodes and buffers that schema’s fields take, every buffer inside the message body. A field of another type, such as a dictionary-encoded or a view column, is refused as a schema mismatch, and a record batch at odds with its own schema as invalid data. Sensitivity flags are compared through the opened field description rather than invented Arrow metadata. A one-row payload is still a batch. There is no inference, coercion, numeric widening, timestamp parsing or partial admission to make a mismatched stream fit.

The output is likewise one canonical stream of the announced output schema. The client checks its row count against the delivery’s member count. DATETIME uses the existing Unix-nanosecond Arrow representation; strings, bytes and lists retain their exact representations and typed nulls. Read the schema mapping and manual rules P-3 and E-3 before writing IPC with another language’s Arrow tools. The generic wire crate carries opaque IPC bytes and does not require Arrow for an observation-only client.

Rust Handles

Opens name an explicit domain, endpoint, exact ordered SchemaField list and positive ClientProducerLimits or ClientConsumerLimits. They run outside transactions, validate committed running execution and reserve usable credit before success. The description includes schema, START generation, endpoint contract, attachment, policies, credit and batch limits. A later USE does not retarget a handle. Typed refusals leave no attachment or reservation.

Rust operationMeaning and ownership
Client::open_ingestorOpen a producer of a committed client ingestor
Producer::batch / ProducerBatch::from_record_batchValidate and write canonical IPC with the optional arrow feature
ProducerBatch::from_arrow_ipcRetain a stream written by application Arrow tooling
Producer::sendSubmit and wait for a terminal source outcome
Producer::submit / Producer::rejoinRetain a submission, then join its outcome without resending
pending_submissions / releaseInspect retained identities/results or release interest; unresolved credit remains owned until its outcome resolves
Client::subscribe_emitterOpen one competing worker of an emitter
EmitterConsumer::next_batchRead one attempt; abandoned reads remain retrievable by the next read of that attachment
EmitterDelivery::record_batchDecode native output with the optional arrow feature; decoding is not ACK
EmitterDelivery::decode_record_batchDecode retained delivery bytes under an emitter’s declared fields and a row count, as record_batch does for a live delivery
ack / retry / rejectSettle the attempt and inspect the typed confirmation
Handle description / connectionInspect the pinned contract and active/interrupted/restoring/reopen-required/closed state
Producer admission / endInspect the newest admission state and terminal or explicit-reopen reason
Handle close / dropFence restoration and release the attachment; dropping unfinished output does not acknowledge it

The client exchange keeps reading transport frames while the application waits for credit or performs work. Cancellation of send or rejoin leaves bounded pending submission state inspectable. Rejoining retrieves the original result. Closing proceeds from its own task even if the caller stops waiting. The Rust Client Library owns the supported signatures, errors and examples.

Shared C Binding And Buffer Lifetimes

C, C++, Python, Java and Ruby hosts use the existing Rust binding through nervix_client.h. It forwards to the same Rust handles and recovery owner. Its blocking calls belong on application threads, never a thread driving a Tokio runtime. Native data access is bulk column access or canonical IPC, without per-value language callbacks or row-map payloads.

Binding operationBuffer and reference contract
nx_fields_add / nx_fields_elementDescribe every field and list level, nullability and sensitivity; opens copy the description
nx_session_open_ingestor / nx_session_subscribe_emitterOpen with explicit domain, fields and credit; read a typed refusal through nx_error_open_refusal
nx_batch_builder_states, offsets, fixed, varlenCopy supplied column buffers before returning; the host may immediately reuse or free them
nx_producer_submit / nx_producer_submit_ipcRetain immutable IPC under a returned submission identity; IPC submission copies the host’s bytes
nx_producer_pending / rejoin / releaseList identities, take a terminal outcome and return credit, or release interest without cancelling effects
nx_consumer_nextReturn one retained nx_delivery; cancelled/expired reads remain with the consumer
nx_delivery_ipc / nx_delivery_batchBorrow IPC without copying; decode/cache the batch on first access and return a batch reference
nx_batch_states, offsets, fixed, varlenRead column levels in bulk; strings and bytes use pointer plus length and may contain NUL
nx_delivery_ack / retry / rejectSettle through the delivering attachment and await the server’s settlement result
Delivery/batch retain and releaseKeep borrowed views alive; releasing storage never settles a delivery
nx_producer_close, nx_consumer_close, handle freeEnd attachments and fence late restoration; an abandoned close continues cleanup

A field list or builder is used from one thread at a time. Producers, consumers, deliveries and batches support the header’s concurrent-use contract; delivery and batch references may be released on any thread. Each retained reference must be released exactly once. No borrowed pointer outlives the reference it was read from. Producer, consumer and delivery handles keep their session alive until released; retaining bytes does not retain the right to ACK an ended attempt.

A cancelled or expired C submission wait before the producer holds the batch submits nothing. After an identity was returned, cancelled/expired nx_producer_rejoin keeps that submission and its result with the producer: join it again. A cancelled or expired settlement may already have settled the attempt. Repeating it reads its retained result or a stale reference; it cannot undo the application’s effect. Manual B-12 through B-16 states these requirements, and the conformance ledger records the host probes and their limits.

Completion, Attempts And Replay

One submitted batch owns one ordinary source acknowledgement root. It receives exactly one terminal outcome:

OutcomeEstablished factApplication action
NotAdmittedNo row of this attempt entered the graphOnly Suspended and Busy retry automatically on declared physical backoff; other causes require an application decision
CompletedIts source root resolved successfully under the configured graph policiesRetire input according to those policies and the application’s durability requirements
ProcessingFailedAdmitted ACK work failed as rejected or timed out; some effects may have happenedRetain input and reconcile or deliberately replay with idempotent effects
OutcomeUnknownA terminal processing result cannot be established after possible admission/interruptionPreserve the event identity and input; never infer non-admission or silently resend

Completed is precise about the graph’s source root. Filtered rows, REQUIRED SKIP, explicit error handling and detached boundaries can resolve it without external delivery of the original row. Each acknowledging connector retains its own success boundary. Source ACK timeout measures physical acknowledgement inactivity; downstream progress/keepalives can extend the wait. A timed-out source result does not cancel admitted work, which may still finish afterwards.

Consumers of one emitter compete fairly for available work. One attempt belongs to one worker; another worker receives it only after revocation. Consecutive batches of one branch may go to different consumers. Sequential mode permits one unresolved batch per source relay/concrete branch. Parallel mode permits up to its declared count on that stream across the whole pool and allows effects and ACKs to complete out of order. Attaching more workers multiplies neither window. Separate emitters from the same relay provide independent fan-out, each with its own ACK share. There is no total order across sources or branches, or worker affinity.

Producers share the ingestor’s single source window. A temporarily refused batch is sent again after backoff behind batches sent in the meantime. Several outstanding submissions can therefore produce output in a different order from their first sends. Keep one producer batch outstanding when that order is required.

Reading, decoding, queuing or transporting output is not ACK. ack awaits Confirmed; retry retains work on declared physical backoff; reject follows the emitter’s message-error policy for every member. Rejection reasons are nonempty, at most 1024 UTF-8 bytes and non-sensitive. The application settles the entire batch. If it applied only some rows before failure, redelivery can repeat every row.

Retry, ACK timeout and consumer loss revoke the current reference before reassignment. The original IPC bytes, members, delivery identity and execution snapshot remain frozen while that emitter execution retains the batch; a replacement attempt has a fresh reference. Late ACKs cannot resolve a replacement. Repeated ACK is idempotent only while its bounded result remains; released or revoked references expire. Revocation cannot undo an application effect. Lost confirmation is SettlementUnknown in Rust or NX_ERROR_UNCERTAIN through the binding.

With ATTACHED, application ACK remains in the upstream chain. With DETACHED, upstream resolves at the normal earlier detach boundary, while the emitter still retains output, retries and applies local backpressure. The latter cannot establish that the application applied an effect. For production replay, persist an application outbox before submitting and atomically deduplicate or reconcile effects using stable event identifiers. A live delivery identity helps with attempt redelivery, but is not a durable event ID across owner loss or a new submission. Nervix provides no durable producer-result journal, consumer cursor, late-subscriber replay or exactly-once application transaction.

Credit, Memory And Transport Progress

Positive batch and byte limits are requested at open and reserved before success. A consumer’s byte grant must hold one batch at the emitter’s declared maximum. An unusable reservation is refused immediately rather than attaching an application that cannot complete its own graph work. Producer and consumer reservations are separate and cannot borrow from each other.

LimitCurrent bound and scope
Attachments32 producers and independently 32 consumers per session
Requested outstanding batch count1 through 1,024 per handle
Session credit bytes32 MiB for producers and independently 32 MiB for consumers; 64 MiB total reserved credit
Node credit bytes128 MiB for each direction; 256 MiB total reserved credit, charged at every serving/owning node retaining forwarded work
Retained emitter IPCA separate 128 MiB node work budget, including output waiting for a consumer
Native batch rowsAt most 65,536; emitter BATCH may set a smaller count
Session frame4 MiB by default, including the complete FlatBuffers envelope
Reassembled reply64 MiB by default, with each transfer part fitting the frame limit
Producer IPC submissionOne frame; the grant’s max_batch_bytes is the smaller of byte credit and frame payload allowance
Emitter IPC batchIts declared BATCH MAX SIZE, at most 32 MiB, and within the consumer’s grant; replies above one frame use bounded transfer parts

These are scoped credit and retained-IPC budgets, not a bound on whole-process memory. Relay, processor, Arrow construction, executor and transport allocations have their own bounds. Forwarded data may be retained on both nodes and is charged there independently. Producer submission framing reserves envelope room (currently 256 bytes); applications use the announced grant, not the nominal 4 MiB frame value. Transferable consumer replies do not enlarge the one-frame producer submission. No data is truncated to meet a bound. Invalid/oversized input is refused before admission; an output row that cannot fit alone follows ON MESSAGE ERROR.

Submitted work keeps credit through transport, graph processing and outcome observation. Reading an output does not return its application credit; settlement or attempt revocation does. Pending terminal results and parked reads remain bounded. A producer whose application stops observing results stops gaining room. A consumer with no room receives no additional attempt. Fair service operates among ready producers/consumers while respecting each stream’s window.

With no eligible consumers, prepared output fills the node work budget and the emitter stops taking more relay work. This is ordinary waiting/backpressure, not external success or a message error. It propagates to producers through the graph’s buffers and attached ACK roots. Unused ingress credit is fenced during quiesce; an in-transit batch is admitted before the fence or refused as not admitted, never hidden through the pause.

ACKs, producer outcomes, admission changes, lifecycle, command replies, keepalives and clock observations have control capacity beside data. The receive loop never awaits a submission’s graph ACK. Producer submissions use their credit rather than the ordinary request-count allowance; consumer reads occupy one of the session’s 64 ordinary in-flight requests and leave the ordered lane free. Keep that request capacity available for settlement and administration. The SDK continues reading while application credit is exhausted.

Priority applies before handing frames to an ordered transport. Frames already handed to HTTP/2 or WebSocket remain ordered, and a peer that stops reading can stall its connection until physical liveness ends it. Separate sessions may isolate throughput. Sending beyond producer credit is a protocol violation: the new batch is refused without graph admission and the offending attachment ends; preceding batches still receive their outcomes.

Domain Time And Fencing

Data handles pin the domain’s START generation. The optional, domain-keyed clock attachment is a separate observation interface with its own restoration and coalescing. Its frames travel on the session’s control lane, with one replaceable tick slot per attached domain. It has no user attachment name or generic data-stream handle. It observes committed time rather than granting authority to set it. Tick progress can skip; clock/data events establish neither a barrier nor a total order. Applications needing event time declare it in exported fields.

TIMESTAMP AT <field> preserves exact event time and uses the server’s ordinary paced admission. TIMESTAMP NOW samples server domain time at delivery, including after quiesce. It also passes through admission: SKEW smaller than PERIOD can reject arrivals between reached windows. The newest 256 reached centers bound admission; a delayed clock observation does not relax it. Expressions for an accepted execution unit share one snapshot, and emitter retries retain it.

Time ownerOperations
Domain logical timeEvent admission, FLUSH EACH, input collection and simulation cadence
Physical timeACK inactivity/timeouts, retry backoff, session/link liveness, caller/credit-wait deadlines, drain and shutdown budgets
Physical system batchingFLUSH IMMEDIATE minimum window

Slow consumers and domain pauses do not stop time. TIME RATE scales logical pacing, not a socket timeout or a drain allowance. A stopped, uninstalled or stale paced clock never becomes wall time as a fallback. Domain Clock owns this split.

FenceWhat it prevents
START generationWork or restoration silently crossing a stop/start lifetime
Endpoint contract and exact schema/policy/grant comparisonAutomatic acceptance of changed construction, routing, schema or acknowledgement behavior
Scheduled execution authority and process-start catch-up proofA restarted former owner admitting through stale recovered state
Session exchange, producer/consumer identity and attachmentDelayed operations reaching an attachment opened in its place
Stable delivery identity plus fresh attempt referenceA revoked ACK settling a new assignment
Exact quiesce coordination identity and leaseA stale coordinator releasing a different hold

An open succeeds only after current routing and capacity are installed. During process-start catch-up, an endpoint open is temporarily EndpointUnavailable; stale local missing/stopped state is not exposed as terminal before the barrier. A node unable to establish the required current open/admission authority refuses that operation. The startup proof is not a continuous quorum lease that revokes already-admitted runtime work after every connectivity loss.

ALTER, Failures And Restoration

Endpoint fingerprints describe their effective public contract. Ingestor fingerprints cover input, mode, timestamps, filters, routes, errors and referenced branch declarations. Emitter fingerprints cover their canonical contract and ordered output fields. Flush cadence is normalized out, so a flush-only change keeps attachments and pending delivery identities. Other effective contract changes require explicit application acceptance and a fresh open.

Buffered Ingestor ALTER

For an alteration that pauses an ingestor and gates a shared downstream relay, the coordinator:

  1. Engages an ingestor-only admission hold across the live nodes and stops new admission.
  2. Force-flushes existing route work and drains admitted source ACK roots while relay gates stay open. Partial batches from every concrete branch can reach their acknowledging sinks.
  3. Engages the full subgraph hold before releasing the intake hold. Both holds own suspension, so releasing intake cannot briefly reopen admission.
  4. Publishes the validated replacement only after its required drain and engagement succeed, then releases the exact holds.

The intake and full scopes have separate authenticated coordination identities and share one physical alteration deadline. Transaction inspection records each scope’s actual engagement, failure and release. Admitted submissions retain their original outcome and credit owners; consumers continue processing and ACKing them beside the ordered command lane. Intake-drain failure or full-hold rejection releases attempted holds and retains the committed contract and attachments. No failed alteration authorizes replay of unknown input.

A cancelled wait for a durably admitted command ends only the caller’s wait: execution and exact command recovery remain cluster-owned. Coordinator abandonment instead leaves receiver-owned release and lease cleanup responsible for its exact holds. Transaction Quiescence owns those coordination and persisted-impact rules. The paced demonstration uses the published buffered FLUSH EACH 100ms input route.

Events And Application Decisions

EventDelivered boundary and recovery
Quiesce or memory pressureSuspend admission, refuse unadmitted batches and keep admitted ACK work/control progressing
Failed ALTER or planned drainLeave the candidate unapplied, release owned holds and retain current contracts
Successful contract changeDrain under the old owner; end affected attachments; validate a fresh application open against the new contract
Planned relocationStop moved intake, force-flush/drain, commit handoff and end prior bindings as relocated; matching desired handles restore with fresh attachments
Consumer disconnect or forwarding-stream lossRevoke attempts; surviving owner retains unresolved bytes for reassignment after physical backoff
Producer disconnectDetach; queued uncleared work is unadmitted, while admitted work may finish without a connected caller
Owner lossLose its volatile payload/ACK state; preserve truthful not-admitted versus unknown classification and rely on upstream/application replay
Serving-node or session lossInterrupt handles; every sent unresolved submission is SessionLost; restore connectivity without resending
Native leader change with healthy execution/sessionContinue data attachments; command recovery follows its existing leader routing
STOP / new STARTEnd data attachments under the ended generation; new opens require explicit acceptance of the new generation
Endpoint or domain removalEnd affected handles; fresh opens refuse absent targets
Node shutdown or forced endingStop intake and follow the bounded drain/terminal contract; unresolved work remains failed or unknown, never fabricated completed
Explicit close/drop during restorationFence late open and release any attachment it created; the handle cannot resurrect

The client recovery owner retains desired producers and consumers. It can restore the same handle with a new wire attachment only while generation, endpoint contract, fields, policies and granted behavior still match. Temporary owner/capacity refusals retry on bounded physical backoff. Consumer reads expose interruption before restored output; previous delivery handles never send ACKs through the replacement exchange. Unresolved sent submissions remain unknown, and definitely unsent submissions retain their not-admitted boundary. Changed contracts/generations require a new application open. Clock restoration proceeds independently.

StatePersisted or retained across an owner/process loss?
Domain, schemas, endpoint Models and committed schedulesPersisted control-plane configuration
START mapping/generation and scheduling authorityPersisted under their owning lifecycle contracts
Materialized/processor/guest computation stateOnly the state its own architecture contract persists; it is not a client payload journal
Payloads, route buffers, source ACK roots and credit/result slotsIn memory; no durable client recovery history
Consumer queues, prepared deliveries, attempt references and ACK mapsIn memory; owner loss can require upstream replay
Application outbox, event IDs and effect deduplicationApplication-owned durability, independent of endpoint restoration

The cluster prerequisites for this delivery are resolved: recovered Raft-member endpoints now seed authenticated gossip contact for a restarted former bootstrap leader (Interconnect, Shutdown); a routed record reaching no runtime consumer fails its ACK (PR #504); and forwarded record acknowledgements whose receiver stops reporting resolve negatively rather than appearing successful (Interconnect). These fixes underpin the completed cluster recovery prerequisites. They do not make client payloads durable. Native owner-kill, owner-freeze and forwarding-node-kill scenarios separately verify the client clearance, unknown-outcome and restoration boundaries.

Run The Paced Drivers

The Paced Simulation Drivers publish the complete branched graph and Rust/shared-binding Python programs. In a repository checkout with a running cluster, load that graph and start its domain, then run either application:

nervix-cli --command \
  "CREATE PACED DOMAIN paced_simulation WITH PERIOD 100ms SKEW 100ms;"
nervix-cli --domain paced_simulation --command \
  "$(sed -n '/^BEGIN;/,$p' examples/paced-simulation/paced_simulation.nspl)"
nervix-cli --domain paced_simulation --command "START AT NOW TIME RATE 4.0;"
just paced-simulation --server http://127.0.0.1:47391 \
  --ticks 100 --sensors 3 --inspect-every 1s \
  --ledger /tmp/rust-input.jsonl --effects /tmp/rust-effects.jsonl

Alternatively, with Python 3.12 or later:

just paced-simulation-python --server http://127.0.0.1:47391 \
  --ticks 100 --sensors 3 --inspect-every 1s \
  --ledger /tmp/python-input.jsonl --effects /tmp/python-effects.jsonl

The file includes bootstrap and USE phases; the load command extracts its graph transaction and selects the domain through the CLI. Check the printed command dispositions before starting an application; ordinary --command currently prints a refusal without a nonzero exit status. The drivers read NERVIX_USERNAME and NERVIX_PASSWORD, or explicit credential options. The recipes build the required binary or shared library. Each application opens consumers before submitting, uses the clock helper’s projections for tick centers and admission, writes an input ledger and applies effects by stable reading ID before ACK. --timestamps now selects the comparison ingestor; --invalid-every 3 exercises the error route. The rejection-notice consumer is outside the original source ACK chain, so the drivers separately wait for those notices.

For a running instance, save this compatible ingestor change as alteration.nspl; it demonstrates same-generation reopening with buffered input:

BEGIN;
ALTER CLIENT PRODUCER INGESTOR simulated_readings
  SET FROM SCHEMA reading
    MODE ACK PARALLEL MAX 4 ACK TIMEOUT 30s
      RETRY POLICY BACKOFF 100ms MAX 2s
    ON QUIESCE SUSPEND;
COMMIT;
nervix-cli --domain paced_simulation --command "$(cat alteration.nspl)"

The drivers recheck endpoint state during clock/credit waits every 200 ms and retry an unavailable endpoint within their existing 30-second physical open budget. They report REOPENED with the same generation for accepted non-lifecycle replacements, request their configured credit again and validate exact fields. Old pending submissions keep their original producer and credit; replacement credit cannot retire them. Unusable schema, removed endpoint or insufficient consumer credit is a configuration error, including after planning ends. A consumer refusal interrupts final-outcome, rejection-notice and producer-close waits.

Only domain_stopped and generation_changed wait for another START, and --follow-generations explicitly enables that continuation. --replay deliberately resubmits ledger readings whose latest outcome was not completed, preserving ID/content only while their generation and event-time window remain usable. It also covers a planned tick refused during a contract change. No unknown input is automatically resent. Redelivery/replay can repeat output; the example effect store deduplicates it. The files are flushed but not made durable, so a production application must supply its own durable outbox/effect transaction.

Exit status 0 means submitted readings completed, 3 reports unresolved/not-admitted/failed readings, 2 reports configuration refusal, and 1 reports an application failure or its final wait deadline. SUMMARY and the report lines show outcomes, effects, duplicates, generations, credit waits, peak outstanding bytes and consumer membership. The driver chapter owns their complete option, report, stopping and replay contracts.

Inspection, Tuning And Troubleshooting

Inspecting endpoints is read-only and claims no work. Run these commands with nervix-cli --domain application --command '<one statement>', one invocation per statement, or in a REPL that has selected the domain:

SHOW CREATE INGESTOR submit_events;
SHOW CREATE EMITTER receive_events;
SHOW INGESTORS;
DESCRIBE INGESTOR submit_events;
DESCRIBE EMITTER receive_events;
DESCRIBE RELAY application_events;

The ingestor reports its schema, owner, admission, producers, forwarded producers and outstanding/ admitted batches and bytes. The emitter reports consumers, forwarding credit, retained IPC, incomplete application batches, retries, ACKs and rejections from its scheduled owner. Metrics use bounded domain/ingestor/emitter/outcome/cause labels, without session, delivery or event IDs. Gauges describe current volatile state; counters retain only their owning node’s documented lifetime. They cannot prove a particular application’s ACK or durable completion. Metrics And Observability owns the metric names and interpretation. Transaction inspection separately exposes intake/full engagement and release.

SymptomCheck and action
Open refusedRead the typed refusal: explicit domain, running endpoint kind, exact ordered fields, transaction binding and usable credit; retry only a temporary unavailable/capacity condition
Producer awaits creditObserve pending outcomes, consumer presence, admitted ACK window, relay pressure and materialized REQUIRED WAIT; keep consuming/ACKing concurrently
Output waits with no consumerOpen a consumer with room for the declared maximum; use additional competing workers for processing capacity or separate emitters for copies
Output has no expected branch fieldExport the required value through schema-backed construction; the opaque fingerprint is metadata
Invalid native streamCheck canonical schema, exact types/nullability, one batch, row/byte bounds, no compression/dictionaries/extensions/field metadata and valid column buffers
Source processing failedRead its rejected/timeout cause and route error policy; partial effects may already exist
Stale ACK or uncertain settlementMatch the current attachment/attempt; reconcile idempotent effects instead of treating a replacement reference as the old one
Paced admission rejects rowsInspect reached centers, generation, exact event time and skew; a clock observation cannot extend the server’s window
ALTER cannot drainContinue admitted outcome reads and application ACKs; inspect its actual scope/failure and shared physical deadline before accepting a replacement
Unknown outcome after lossPreserve application input and ID, establish current generation/contract and deliberately reconcile/replay

Choose producer credit for the largest canonical input and required overlap, within session/node budgets. Choose consumer bytes to fit the declared maximum IPC stream and account for all other handles on that session. Set BATCH MAX SIZE with headroom for schema/IPC overhead and unusually large rows; FLUSH MAX BATCH SIZE measures a different size. A larger ACK window permits more unresolved work and out-of-order effects; a sequential stream or one outstanding producer batch provides its narrower order. Increasing consumers cannot exceed per-stream windows or byte budgets. Tune logical flush/collection for latency and boundary cost, and physical ACK timeout for network and application processing. Use independent sessions when transport contention matters. Diagnose with endpoint state, bounded codes and counts rather than logging sensitive payloads or arbitrary application rejection text.

Acceptance And Evidence Boundaries

The repository’s public scenarios establish the following current behavior. Runtime outlines use one and three nodes and gRPC/WebSocket where applicable; placement-specific cases select the production sticky scheduler. The links name executable evidence, not a claim that every failure interleaving has been explored.

ContractPublic acceptance source
Exact native input, transforming branches, shared producer window, over-credit refusal, cancelled result waits and control progressclient_ingestors.feature
Branch/source isolation, attached and detached ACKs, pooled consumers, independent emitter fan-out, retry/reject/timeout, sensitive export, oversized output and credit restorationclient_emitters.feature
Real owner kill/freeze, forwarding-node kill, cleared versus uncleared batches and fresh restorationclient_ingestor_process_faults.feature
Bulk C ABI and C/C++/Python/Java/Ruby round trip, lifetimes, deadlines, credit and session gapsclient_conformance.feature and the client conformance ledger
Runnable Rust/Python pacing, generation separation, current-window admission, saturation, non-owner entry, clock-authority movement and deliberate replaypaced_simulation.feature

The buffered ALTER outlines are named An alteration drains buffered branches on a shared relay before replacing its client contract and A rejected <failure> releases buffered client admission and retains its committed contract. They check shared-relay branch isolation, explicit transaction completion, separate persisted intake/full impact attempts, successful drain and both failure stages through gRPC/WebSocket on one/three nodes.

The paced driver outlines include:

  • The <driver> driver drains buffered routes and reopens its consumers and producer within one START generation on <cluster_size> nodes.
  • The <driver> driver retains a refused tick across a producer contract change and explicitly replays it on <cluster_size> nodes.
  • The <driver> driver fails clearly when reopening a <change_kind> <endpoint> on <cluster_size> nodes, covering endpoint removal, schema and credit mismatch.
  • The <driver> driver reports a delayed refusal while awaiting its final outcome on <cluster_size> nodes.

The conformance ledger distinguishes live shared-binding endpoint tests from independent Go/TypeScript corpus readers. It records that ACK-confirmation loss is injected by a binding unit test rather than timed in a live probe, and that those probes do not qualify TLS. Rust compile_fail doctests are paired with compiling examples of current imports/signatures in normal tests and CI. They qualify specific Rust API restrictions; C ABI ownership, runtime behavior and client conformance keep their own evidence.

Registered Bolero properties run ordinary randomized/corpus checks on every PR. Sanitizer CI runs only for a pull request carrying fuzz; unlabeled, scheduled and workflow-dispatch runs expect a sanitizer skip. That skip proves no sanitizer execution or coverage. Explicit local just fuzz and just fuzz-all remain available. Property Testing And Fuzzing owns the inventory, bounded campaigns, exact replay and failure retention.

Shuttle explores interleavings of production credit, admission, attempt and close owners. Loom addresses registered memory-ordering claims; Turmoil addresses the supported simulated-network boundary; real-process/Chaos cases address actual crashes. Deloxide diagnoses active cycles only among its tracked blocking locks, with async waits, atomics, network waits and Python locks outside that observation. No one evidence form replaces the others, and native model/diagnostic coverage is kept separate from ordinary coverage.

Domain Clock

The domain clock is the time boundary between Nervix’s control plane and data plane. The control plane commits what time means for a domain; every data-plane node installs that definition and uses a capability bound to the exact domain lifecycle generation. Expression engines, processors, and connectors receive time from that capability instead of choosing a clock themselves.

This chapter describes the internal architecture. See Domains And Time for the NSPL surface and operator-facing behavior. Explicit transactional START and STOP establish or revoke generations at ordered step boundaries; Transaction Quiescence And Impact Inspection owns how those lifecycle steps affect later planned scopes and the retained impact report.

The central rule is that a paced clock is a committed mapping, not a stream of ticks. Each node projects its own current UTC observation through the same mapping. Tick progress records which logical boundary the current authority has reached, but it never replaces the mapping or advances a node’s readable time.

Replicated And Node-Local State

The complete clock contract is split across replicated control-plane state and bounded node-local state:

StateMeaningOwnership and durability
Domain pacePACED with a positive PERIOD and nonnegative SKEW, or UNPACEDPart of the replicated domain configuration
Lifecycle generationThe domain’s start_version, advanced by every committed STARTReplicated with the domain lifecycle record
Paced mappingPhysical UTC anchor, logical origin, and positive finite time rateReplicated for a running paced generation
Authority fenceMonotonic authority revision and an optional node identity, including its process incarnationReplicated separately for each paced domain
ProgressGeneration, authority revision and identity, plus the latest produced tickTransient and replaceable; sent over the interconnect and never persisted
Installed clockMissing, stopped, uninstalled, installed-unpaced, or installed-paced stateNode-local runtime state derived from the committed revision
Last read and observed progressThe node’s nondecreasing read watermark and newest accepted tick observationNode-local in-memory state

The lifecycle generation and authority revision fence different changes. A new START increments the generation and establishes a new mapping. Assigning, moving, or revoking the producer increments the authority revision without changing the mapping or generation. Including the node incarnation in the authority identity prevents a restarted process with the same node name from acting as the preceding process.

An unpaced domain still binds a domain-and-generation clock capability, but its source is actual UTC. It has no paced mapping, authority, tick production, or admission window.

Paced-Time Projection

A paced mapping contains:

  • wall_started_at: the UTC instant sampled when the generation was established
  • logical_start: the logical instant requested by the domain start
  • time_rate: the positive finite ratio of logical time to physical elapsed time

For a current UTC observation wall_now, the vocabulary model computes:

elapsed     = max(0, wall_now - wall_started_at)
logical_now = logical_start + floor(elapsed * time_rate)

Rounding down prevents a read from claiming a logical nanosecond that has not been reached. A UTC observation before the physical anchor contributes zero elapsed time. The runtime then raises the node’s read watermark for the installed generation to the projection and returns the watermark, so a local clock adjustment cannot make normal reads move backward.

The inverse operation converts a future logical target into a physical duration:

wall_wait = ceil((target_logical - current_logical) / time_rate)

A positive result is at least one nanosecond. Rounding up prevents a logical deadline from firing early. Both operations use checked duration and timestamp arithmetic. Origins, projections, and tick boundaries must remain within signed Unix-nanosecond timestamps; periods and skew must fit in unsigned 64-bit nanosecond durations. A value that exceeds those bounds produces a typed error.

TIME RATE affects only the relationship between elapsed physical time and logical time. It does not scale PERIOD, SKEW, preserved source timestamps, or a duration after that duration has been classified as physical policy.

Establishing A Generation

RESTORE ... RESUME activates the archived start generation and latest start point after complete state publication. A paced restore installs its committed wall/logical mapping and time rate verbatim; elapsed downtime advances its projection. The leader selects a new authority from this cluster’s live voter incarnations. Archived authority and frontier never establish that authority or reset the mapping. A paced archive missing its mapping is refused before restore admission. Default restoration leaves the domain stopped with no installed mapping; a subsequent START establishes a new generation by the procedure below. See Backup And Restore.

A direct or transactional START reaches the same replicated lifecycle transition. A transaction resolves the concrete start, paced mapping, and initial authority while its complete commit plan is admitted, then persists that decision with the frozen step. Restart and leadership recovery execute that stored decision against its captured control-plane inputs instead of sampling a new anchor or selecting a different authority.

  1. The command handler reads the current domain and samples UTC once for the physical anchor.
  2. It resolves the requested start into a concrete logical origin and rate. START AT NOW becomes a concrete timestamp before it is committed; START AT <timestamp> preserves the supplied logical origin. A resume uses readable current domain time when it is available and otherwise resolves from the sampled UTC at rate one.
  3. A paced start selects one live voter incarnation as its initial authority. The command fails if no eligible authority exists.
  4. One consensus command marks the domain running, increments its lifecycle generation, stores the mapping, and advances the authority fence. An unpaced start increments the generation without storing a paced mapping or authority.
  5. Every node applies the resulting control-plane revision to its local domain-clock lifecycle before materializing executable work from that revision.
  6. The selected authority produces progress once its own node has installed the mapping and authority fence. It delivers progress to each live peer after that peer advertises that it installed the domain clocks of the required revision; neither waits for graph activation.

The mapping is therefore available before a graph task can bind its clock. A node joining later installs the existing mapping, logical origin, rate, generation, and authority fence. Its join time does not become a new clock anchor.

STOP commits the inverse lifecycle boundary. It marks the domain stopped, removes the active mapping, and advances the authority fence to an unassigned state in the same transition. The runtime clears accepted progress for the generation and wakes bound waiters, which observe a typed stopped or stale-generation error. A later START creates another generation.

A producer of a client ingestor is bound to the generation it attached under, which its open reply reports. STOP ends it, and so does an execution installed under a later generation, both as domain stopped; a producer never carries batches from one generation into the next.

The internal pause used while altering a running model does not establish a generation. It keeps the mapping and authority active while ingestion and generators are withheld, so logical time and already-armed deadlines continue through the quiesce cycle.

Authority Selection And Reconciliation

The current consensus leader reconciles authority ownership whenever domain state, Raft topology, or effective node availability changes. Candidate identities are the intersection of effectively available node incarnations and current Raft voters. Effective availability retains an established incarnation through a gossip loss while its latest application-health observation remains within the node-unavailability interval and has not marked it unavailable. If that observation becomes stale, the node follows gossip’s liveness verdict. Duplicate observations for one node name collapse to the newest incarnation.

Selection is deterministic. Candidates are ordered by node name, the domain name is hashed, and the hash selects one position in that ordered set. Every leader presented with the same domain and candidate set therefore proposes the same owner, while domains can distribute across the voter set.

The replicated reconciliation is a compare-and-set operation. It changes the authority only if the domain still has the expected lifecycle generation, is still paced and active, and still has the expected authority fence. This stops work computed by a preceding leader or topology view from overwriting newer state. Every successful reassignment or revocation increments the revision.

Leadership transfer alone does not alter the mapping or authority. Losing the authority’s exact node incarnation causes the leader to select and commit another eligible incarnation. If none is eligible, the authority becomes unassigned and the paced clock is unavailable for execution until an owner is committed again; the mapping itself remains replicated.

A reconciliation proposal that does not commit is proposed again 200 milliseconds later, for as long as the node remains leader, without waiting for another change in domain state, Raft topology or availability. A newly elected leader refuses writes until a quorum acknowledges its leadership, so the first proposal it makes on taking over can be refused. When the lost authority also held consensus leadership, the survivors first elect a leader, and that leader’s reconciliation commits the replacement on a later attempt rather than when some unrelated state next changes.

Application-health probes finish their bounded attempts despite concurrent gossip updates. This allows continuous failures to exclude a stopped authority even after its advertised endpoint has changed; a stale healthy observation must not leave it indefinitely eligible for clock production.

On each node, a producer task exists only when all of these values agree with committed runtime state:

  • domain name
  • lifecycle generation
  • mapping and period
  • authority revision
  • full authority node identity and incarnation

An obsolete producer is cancelled and retired before that node starts a successor for the same domain. The old task retains its immutable fence while it exits, so even an in-flight delivery cannot impersonate the replacement authority.

Every node installs a committed reassignment ahead of its graph installation. The node’s domain clock owner applies each committed revision directly: when a domain’s installed generation, status, configuration, start point and mapping already equal the committed ones, it installs the committed authority fence at once, which starts or retires the local producer and fences received progress. Graph installation can wait on a node that left, for example while a forced failover drains a relay toward it, and it does not hold back the clock takeover on the replacement authority or on any receiver. Every other lifecycle change, such as a new generation, a stop, or an added or removed domain, remains with graph installation, which installs a revision’s lifecycles before its work. Each node records the committed revision whose lifecycle each domain holds and never replaces it with an older revision’s, so a graph installation that completes after a newer fence was installed cannot restore the superseded authority.

After reassignment, the replacement authority reconstructs the frontier from the unchanged mapping; each receiver’s own installation of the fence controls when it can receive progress.

Tick Production And Progress Delivery

Tick identifiers are one-based. Tick 1 is the logical origin, and tick n is at:

logical_start + (n - 1) * period

The authority projects current UTC through the committed mapping and determines the latest due tick by direct arithmetic. If scheduling delay spans several periods, it emits only that latest due boundary and continues with the first future boundary. It never loops through or queues every missed tick. A newly assigned authority can consequently reconstruct the current frontier without persisting the previous producer’s counter. After an actual emission, the authority waits at least one period divided by the rate in physical time before emitting another tick. This keeps consecutive observations spaced correctly when tick 1 was delayed after START; a late next boundary is coalesced to the newest due id as usual.

Each progress report carries the lifecycle generation, authority revision, full authority identity, tick id, logical boundary, authority UTC observation, and period. The authority applies the report to its own runtime directly. Remote delivery uses the typed domain_clock_progress HTTP/2 management request described by Cluster Interconnect.

For each installed remote node and domain, the producer owns one delivery loop with at most one request in flight and one replaceable pending report. Publishing another tick overwrites the pending value, so a fast clock or slow peer cannot create an unbounded queue. A node-wide progress subquota bounds concurrent attempts independently of health, relay admission, cancellation, and terminal outcomes.

Targets must be live and must advertise that their domain clocks match the committed runtime revision the producer started from, or a later one. Each node’s domain clock owner follows every committed domain and schedule change, and it advertises a runtime revision once every domain clock the node holds, authority fence included, equals that revision’s committed lifecycle. It advertises neither graph preparation nor activation, so a target whose graph installation waits still receives progress. A lifecycle change that only graph installation installs, such as a new generation of another domain, holds the advertisement until graph installation installs it. A new or reconnected target receives the producer’s newest retained report when its advertisement reaches the required revision. Each request has a two-second physical deadline. Failure waits on a 200-millisecond physical backoff and then retries the newest report. Cancelling the authority stops an in-flight request and its backoff.

A process stop request does not cancel clock authority reconciliation, progress production, progress delivery, or local installation. Those services remain active during application drain support so work admitted before listener shutdown can still take execution snapshots and wait on logical deadlines, and so a committed ownership handoff can install the schedule and clock state it needs. Terminal teardown cancels them only after drain support completes or explicitly reports abandonment. See Shutdown And Recovery for the phases these services span.

The receiver first gets the reporting node name from the mutually authenticated interconnect. It accepts progress only when the domain already exists and is not stopped, and the report matches the committed generation, authority revision, full authority identity, authenticated node name, and a nonzero tick id. A report is newer only when its tick id advances and its authority UTC observation does not precede the retained one. Duplicate, reordered, delayed, and superseded reports are ignored.

The receiver publishes the newest accepted tick id, logical boundary, and authority UTC observation through a per-domain watch. The compare and replacement are serialized by the watch so concurrent deliveries cannot replace newer progress with an older report. Generation changes and stops clear it with send_replace; a late observer subscribes before reading and sees the current value at once. Progress does not use the report’s logical timestamp, UTC timestamp, or period to replace the installed mapping. The unit response therefore means that the receiver evaluated the report against its current fence; it does not mean that the report established time. Progress can never create a missing domain.

Local Installation And Bound Capabilities

Each runtime node keeps one shared lifecycle allocation per domain, and graph tasks hold thin clock handles bound to its domain and lifecycle generation. Control-plane synchronization publishes each installation change into that allocation by atomically replacing the complete installation, then notifies logical waiters. This makes a lifecycle change visible to every existing handle and gives logical waiters one notification source to observe. The same immutable publication contains its pause state, lifecycle generation and last start point. Ingest groups read pause/admission and time from one publication. Kafka domain-offset polls and filtered subscriptions retain the lifecycle allocation; a subscription reads its current installed generation even when it opened before START. Generators, processors and materialized relay tasks retain their generation-bound clock. These reads do not resolve the runtime’s domain or execution registry again. A paused paced domain keeps its installed mapping while publishing pause, and a later generation still invalidates a clock bound to its predecessor.

A pause-only publication does not notify installation observers when it keeps the installed mapping unchanged; intake sees pause through the lifecycle publication while execution keeps its existing logical waiters and mapping.

The local installation states have explicit meanings:

InstallationRead result
MissingThe domain is absent from this runtime
StoppedThe generation exists but cannot execute domain work
UninstalledA paced active generation lacks either its mapping or assigned authority
Installed unpacedReads actual UTC through the domain-bound capability
Installed pacedProjects actual UTC through the committed mapping, period, and skew

Binding an active clock fails for missing, stopped, or uninstalled state. A handle that was bound before a later START fails with a stale-generation result. Reads and logical waits revalidate the shared installation; no failure path substitutes wall time for a paced clock. Passive graph state for a stopped domain may retain a generation-bound handle for ownership purposes, but attempts to read it still fail as stopped.

Ownership handoff and forced recovery of a passive WASM revision transfer its validated durable checkpoint inventory without guest callbacks or clock reads. Guest restoration waits for START and its active clock generation; an unreadable stopped clock does not invalidate the saved state.

Reads take no lock. A read loads the published installation, validates the handle’s generation and the installation state against it, projects actual UTC through the source that installation holds, and raises the read watermark published with it using one atomic maximum. A concurrent read therefore observes either the complete preceding installation or the complete replacement, and a replacement never alters an installation that an in-flight read already holds. Synchronization derives each replacement from the installation it replaces and publishes it only while that installation is still current; synchronizing an unchanged installation publishes nothing and wakes no waiter.

Each read watermark covers one uninterrupted installation of a generation. A replacement that keeps the same generation installed shares its predecessor’s watermark, so reads of that generation do not decrease even when they race the replacement. Any other replacement starts a new watermark. A read that loaded an earlier generation can therefore raise only that generation’s watermark and cannot clamp reads of a later generation, and a generation that becomes uninstalled starts from a new watermark when its mapping and authority are installed again.

Applying a cluster revision synchronizes all domain lifecycles before applying its schedule. A joining node therefore cannot instantiate work and then discover that its clock mapping is absent. Removing a domain marks the shared lifecycle missing before node-local state is discarded.

Session Attachment

A client session follows a domain’s clock with ATTACH DOMAIN CLOCK and stops following it with DETACH DOMAIN CLOCK. The attachment observes the installation and the accepted tick progress of the node serving the session. It adds no interconnect traffic: every node serves its sessions from its own installation and accepted progress, and no node advertises its attachments. Nothing about an attachment is persisted. Domain Clock Attachment states the public contract, Client Session Protocol how the attachment travels in the protocol, and the Client Implementation Manual what every client implementation must do with it. This section owns how an installation and accepted progress become what an attached client observes.

The Observer

The runtime exposes an observer of one domain’s lifecycle publication and progress watch. It subscribes to both notifications before its first read, so an installation or a tick published after a read always wakes it. It maps each installation to the public observed clock, a vocabulary model shared by the server and the Rust client:

InstallationObserved clock
MissingNone: the attachment ends
StoppedStopped, with its generation
UninstalledUninstalled, with its generation and no mapping
Installed unpacedUnpaced, with its generation
Installed pacedPaced, with its generation, period, skew, and committed mapping

An observed clock carries no authority identity or revision. An authority move that keeps the installation therefore publishes nothing to an attached client.

The observer reads a tick only while the installation is paced and of that tick’s generation. It adds the serving node’s own logical reading, taken as the tick frame is built: one snapshot of the installed generation, read through the same watermarked read that execution uses. The observer retains the progress watch’s sender while the attachment lives, so removing the domain closes neither wait before the lifecycle publishes missing and wakes delivery to send the end frame.

Attachments And Their Delivery

A session’s attachments belong to its ordered lane. The lane holds them in one map keyed by domain, so a session follows each domain clock at most once, and runs one delivery task per attachment. Attach and detach run on that lane in order with the session’s commands, and both are refused while a transaction is open.

An attach first waits until the node has installed the committed domains since it started, or until the session ends. Before that installation every domain is missing from the node’s runtime, so the node cannot tell a domain the cluster lacks from one it has not installed yet. Waiting keeps a node that is still starting, as after a restart, from refusing a committed domain as not found. The runtime counts its installations through a watch and advances the count only after an installation has put every domain in place, so the lookup that follows the wait finds each domain that installation holds. A domain the lookup misses is one the installed committed state lacks, and the reply refuses it as not found. The wait has no bound of its own. The attach is admitted before it waits, so a cancellation of the request does not end the wait, and the session’s later ordered requests wait behind it; short of the installation, only the end of the session ends it.

The attach reply carries the observation read when the observer was created. Delivery starts only once that reply is queued, remembering the observation the reply carried. A reply that was not queued, because the request was cancelled, the session ended or the reply did not fit, announced nothing, and nothing is delivered after it. The task first sends any accepted tick of the reply’s generation, if one exists. On each wake it reads the newest installation and queues a state frame on the session’s control lane only when the observation differs from the one the client last received. Replacing an installation with an equal one, an authority move within a generation and the alteration pause therefore send nothing, though newly accepted progress still wakes delivery. A state frame waits for room on the control lane; changes published meanwhile collapse into the newest installation read after it is queued.

Before each tick the task reads the installation again and sends a changed state frame first, withdrawing a tick it has not handed to transport yet. A tick holds one replaceable control-lane slot: newer progress replaces its contents until transport takes it, including while the lane is full. Each attached domain therefore has at most one pending tick, and a slow client receives the newest accepted id instead of a backlog. A state or tick frame that does not fit the session’s frame limit is not queued: delivery logs a warning and waits for the next change. An end frame that does not fit is logged the same way, and the attachment ends without it.

Detach stops the delivery task and waits for it to end before queueing its reply. When the observer reports the domain missing, the task marks the attachment ending, queues the end frame with reason DomainRemoved, and ends. Because the mark precedes the frame, a request the client sends after reading the frame finds the attachment ending: an attach releases it and is served as a new attachment, and a detach reports it not attached. The end of the session stops every delivery task without a frame.

Delivery Order

For each attached domain, a client can rely on this order:

  1. The attach reply precedes every frame about its attachment.
  2. A tick follows the attach reply or state frame that reported its generation as paced. No tick of an earlier generation follows a newer state, and an unpaced, stopped or uninstalled clock is followed by no tick until a state frame reports it paced.
  3. Within one generation, delivered tick ids increase. They may skip, because the authority coalesces missed periods and a replaced tick slot keeps only the newest.
  4. The end frame is the last frame of its attachment.
  5. No frame about the domain follows the detach reply.

Frames about different domains share the control lane but have no order a client may rely on.

What An Attached Client Observes

EventWhat the serving node sends the session
STARTA state frame with the new generation: paced with its period, skew and mapping, or unpaced. Ticks of the new generation follow once the node accepts them; an unpaced domain sends none
STOPA stopped state frame for the generation, and no further ticks
Automatic pause while altering a running modelNothing: the installation and mapping are unchanged, and ticks continue
Authority committed unassignedAn uninstalled state frame for the same generation, and no ticks. Once an authority is committed again, a paced state frame repeats the generation and its unchanged mapping, and ticks resume with larger ids
Authority moved directly to another voter incarnationNo state frame: the mapping is unchanged. Ticks continue from the replacement once it produces them and the serving node has installed its fence, and their ids may skip
Consensus leader changeNothing, unless authority reconciliation also commits one of the authority changes above
A committed revision the serving node installs no longer holds the domainOne end frame with reason DomainRemoved, which ends the attachment
The serving node stops, or the connection is lostNothing more: the session ends without an end frame, and its attachments end with it
An attach reaches a node restarted before its first installationThe reply waits for that installation, so it never reports a committed domain as not found

A client keeps following a clock across a lost session by attaching to it again. The Rust client turns the end of a session into an interruption of every clock that session held, keeping each clock’s latest state but not its tick. It attaches the followed clocks again on its next session before publishing that session to the client’s other requests, and so before any transaction it attaches there. It reports the clock a restored attachment carries as an observation when the attachment was interrupted or the clock changed; the changes in between are not reported. When the new session answers the attach while the previous session still holds the attachment, as after a leader redirect, the attachment moves without an interruption: a clock that stays paced in the same generation keeps its newest tick, so the clock a caller reads is never older than a tick event it has already taken, and a tick of the new session is reported only when it is newer. A restoration the new session refuses is reported and sent again on that session after one second, a wait that doubles with each refusal up to thirty seconds, for as long as the client follows the clock and the session stays open; each new session starts again from one second. A restoration left unanswered past the client’s request timeout ends that session instead, like any request that outlives the timeout, and the next session attaches the clock again; a caller already waiting for the next clock event is woken and opens that session. A restoration refused as not found ends the attachment with reason DomainRemoved.

A detach and a restoration of the same clock never cross. The Rust client registers a detach before it sends it, and no restoration decides to attach the clock while a detach of it is in flight: a restoration that would has its attach wait until the detach has ended, and sends it only if the client still follows the clock. A restoration attach decided before the detach is sent first, and the detach waits for it, so it reaches the session behind that attach and releases what the attach restored. Once the detach is answered, no session holds the clock on the client’s behalf, and the client follows it only if a later attach of the caller’s own attaches it again.

Guarantees And Non-Guarantees

An attachment guarantees the delivery order above and that every state it reports is a complete installation the serving node published. It does not provide the following:

  • Replay. Ticks are replaceable, so a slow client receives the newest accepted tick, never a backlog, and installation changes published while a state frame waits collapse into the newest. A new attachment starts from the current installation and the newest accepted tick. Nothing reports the changes a lost session missed.
  • Persistence. Attachments are session-local hot state. A session end, a node stop or a restart discards them on the server, and a client restores one only by attaching again.
  • A distributed order. Each node serves its sessions from its own installation and accepted progress. Sessions served by different nodes can observe the same change or tick at different moments, and a node that has not accepted a tick or installed a change yet reports it later. No frame says anything about another node’s state.
  • A disciplined clock. The serving node’s logical reading in a tick frame is that node’s own projection of its UTC, so it carries that host’s offset multiplied by TIME RATE, and it is older than the frame’s arrival by the delivery latency. Synchronized host UTC, which the cluster requires of every node, is a precondition for the serving node’s readings too. A client whose UTC is not disciplined like the cluster’s projects logical instants and admission centers shifted by its own offset multiplied by the rate, and its physical waits end early or late by the offset itself; it can anchor itself on the serving node’s reading instead.

Client Libraries

The Rust client keeps, for each followed domain, the clock the attach reply or the latest state frame carried and the newest tick of that clock’s generation, and answers logical time at a UTC instant, the physical wait until a logical target and the admission window with the vocabulary’s clock arithmetic, the arithmetic the ingestor’s admission uses. Its clock events are coalesced per domain, so a caller that reads none never holds the session’s reader up: the newest state and tick of a domain replace older unread ones, and an interruption or an end replaces the observations before it.

The shared C binding gives hosts a separate retained nx_clock_event handle for later state changes, ticks, interruptions, refused restorations, and ends through nx_session_next_clock_event. Its typed accessors expose the domain, generation, state and paced mapping, tick, or end reason. Through nx_session_domain_clock a host reads the latest followed clock itself as a retained nx_domain_clock, so a host attaching to an already paced clock reads the generation and mapping the attach reply carried before it uses the first tick, and projects logical time, waits and admission with the Rust client’s arithmetic instead of reimplementing it.

Execution-Time Snapshots

The clock is sampled once when a unit of domain work is accepted. The resulting execution snapshot contains the validated lifecycle generation and one timestamp. The runtime passes that snapshot to VM, Roto, and WASM execution instead of giving those engines a context-free time source.

A function the VM calls out to receives that time as part of the call. The VM’s FunctionInjector::inject_with_context takes the snapshot’s now as a Timestamp beside the function, its arguments, the selected rows, the source span and the rows’ prior errors. Roto’s UdfExecutor implements it, and a domain UDF’s now() returns the injected time, so a UDF runs at the snapshot of the unit that calls it. Paired doctests beside the trait and beside UdfExecutor hold this signature: a call with a Timestamp compiles, while a call that passes a raw integer or omits the time does not.

One snapshot is shared by all expressions in that unit, including construction, routing, deduplication, ordering, correlation, windows, inferencer mappings, emitter filters, VALUES, and subscription predicates. This prevents two expressions in one operation from observing different logical instants. A paced domain may place its logical time before the Unix epoch, and expressions consume the snapshot as it is: now() returns it, while uuid_v7(), whose timestamp field starts at the epoch, fails each message that evaluates it rather than encoding a different instant. Message-error handling retains the failing operation’s snapshot. Work that begins later, such as a processor flush, scheduled callback, or message released from materialized REQUIRED WAIT, receives a fresh snapshot for that execution.

A coordinated WASM state reset does not change the domain lifecycle generation, mapping, authority, or logical frontier. Preparing a fresh guest and saving its initial state each use a snapshot from the currently installed domain clock. The snapshot belongs only to that initialization operation; it is not inherited from the branch instance being replaced. Publication cancels the old branch instance and all timeout handles it owned. A fresh initialization may request its own timeouts, but no deadline armed by the replaced instance can fire in the new state lifetime even though domain logical time continued across the reset. See Coordinated Reset.

Checkpoint and reset inspection samples existing state without taking a domain execution snapshot or advancing the logical frontier. A reported checkpoint revision and reset generation describe durability and lifetime identity, not domain time; neither may establish or alter the clock used by a later guest callback.

An NSPL reset uses this same clock contract. Repeating its durable command reference resumes the same guest-state lifetime replacement without changing the domain clock mapping.

Generated records use the snapshot assigned to their generating operation. Buffered emitter batches and their retries retain the snapshot from acceptance, while external observation fields whose contract is actual UTC obtain that value at the shared source-host intake boundary or their connector boundary. An Iceberg emitter’s COMMIT EACH is not measured from that retained snapshot: the host reads a fresh snapshot at the first cadence check after a flush stages rows and arms the commit’s logical deadline from it, and a retry or drain forces the commit without a clock read.

An HTTP emitter’s prepared method, target, headers, and optional body retain the execution snapshot of the admitted record through retries and an entity-pause drain. Changing the emitter does not re-evaluate an admitted request under the replacement. Attempt timeouts, retry backoff, an HTTP-date Retry-After, and shutdown or drain deadlines remain physical waits; a domain’s TIME RATE does not shorten them.

A native client emitter similarly freezes the Arrow IPC bytes, source members, delivery identity and execution snapshot when the batch is prepared. Consumer retry or ACK timeout gives that same batch a new attempt reference without re-running expressions at a later domain time. Its ACK deadline, retry backoff, interconnect heartbeat and peer-silence deadline are physical waits.

Admission Windows

Paced ingestion obtains its execution time and admission window from one clock read. Given the installed logical origin origin, period period, skew skew, and projected now, the reached frontier is:

frontier = floor((now - origin) / period)

The eligible centers are the newest 256 positions ending at that frontier. Before a full history exists, retention clamps at position zero; afterwards the first retained position is frontier - 255. An event is admitted when its timestamp is within the inclusive skew distance of any retained center.

The window is reconstructed in constant space from the mapping. It does not scan or depend on received progress history. Delayed progress therefore cannot change admission, and tolerance past the latest center cannot make a future center eligible. TIMESTAMP NOW uses the same snapshot that created the window. TIMESTAMP AT <field> preserves the decoded event timestamp before testing it against that window.

An ingest group resolves its event timestamp column once. Admission compares signed nanosecond values against the first and last reached centers in lanes, then tests the exact period remainder for values between them. The period reduction is prepared once for that window. A bitmap selects accepted Arrow rows and their timestamp and ACK sidecars together. Rejected rows retain their own ACKs and follow each output route’s message-error policy with code validation and operation admit; a rejected timestamp cannot discard another row of the group. A missing declared timestamp is also rejected for that row. The single-timestamp admission check has the same inclusive bounds.

SIMD Kernels describes the buffer arithmetic, exact period reduction, bitmap results, and scalar/level qualification. The clock owns the eligible centers and ingestion snapshot; the kernel consumes their already-resolved bounds and never reads time or establishes a frontier.

Unpaced ingestion has no admission window. Its clock snapshot still supplies delivery time, while an explicit event timestamp or connector-owned source timestamp remains preserved source time.

A client ingestor takes one ingestion snapshot for each batch its admission worker dispatches, after the batch is validated and its acknowledgement root is tracked. TIMESTAMP NOW gives every row of the batch that snapshot, and TIMESTAMP AT <field> preserves each row’s own field; paced admission then tests each row against the window exactly as for any other ingestor. A client batch carries no connector-owned source timestamp, so a paced domain requires its client ingestors to declare a timestamp. The batch’s ACK TIMEOUT and its producer’s retry backoff are physical monotonic bounds; neither waits on nor stretches domain logical time.

Logical And Physical Deadlines

A logical deadline carries its domain, lifecycle generation, and target logical timestamp. Waiting for it follows a loop:

  1. Read and revalidate the bound clock.
  2. Return a fresh execution snapshot if the logical target is already due.
  3. Convert the remaining logical delta to a physical duration using the installed mapping.
  4. Arm a process-monotonic deadline for that duration.
  5. Wake on the deadline, lifecycle notification, or cancellation, then evaluate the logical predicate again.

The monotonic timer is an implementation mechanism for waiting; the due predicate remains in the domain’s logical coordinate. Lifecycle changes are checked after every wake. Cancellation is a separate typed outcome rather than a clock read.

Reset coordination and its ninety-second initial-checkpoint deadline are physical, monotonic bounds. They do not wait for, pause, or re-anchor logical time. A reset that fails before generation publication restores the old branch instance with its existing logical deadlines. After generation publication, recovery completes the fresh lifetime and never recreates the old instance or its timers.

Recurring domain cadence is anchored to its initial logical schedule. The source host waits for each HTTP or Prometheus polling occurrence and passes its scheduled logical instant into the connector’s poll operation; connector crates never bind or wait on a domain clock. HTTP polling and generator cadence begin immediately, while Prometheus polling begins after one interval. When work misses multiple occurrences, the cadence returns the newest due instant once and advances directly to the first future boundary. Consumers that need both meanings keep the scheduled due instant separate from the fresh execution snapshot taken when work actually runs.

Connector Crates And The Connector Contract defines the paced source’s host and connector responsibilities; this chapter owns the cadence’s time mapping and missed-occurrence behavior.

The architecture keeps four time classes distinct:

Time classInternal use
Domain logical timeExpressions, explicit domain cadence, collection and flush cadence, TTL, retention, window completion, and guest-requested timeouts
Preserved source timeExternal event timestamps, broker metadata, and window membership inputs
Physical monotonic timeNetwork deadlines, retry and backoff, acknowledgements, cancellation, shutdown, drain, state checkpoint deadlines, safety timeouts, and physical batching minima
Actual UTCPaced projection input, unpaced domain reads, administrative records, security validity, explicitly external observation fields, and the comparison of a server-supplied HTTP date, such as Retry-After, with the present

Logical and physical deadlines are different types, and neither converts to the other. A LogicalDeadline carries its domain, lifecycle generation and target logical timestamp, and only a bound DomainClock creates one. The clock’s wait_until(deadline, &CancellationToken) runs the loop above. It returns the reached deadline with a fresh execution snapshot, or a typed DomainClockWaitError: the clock became unavailable, including a deadline of another domain or generation, the wait was cancelled, or the physical deadline could not be scheduled. A PhysicalDeadline holds a private process-monotonic instant. The connector contract’s PhysicalDeadlineCapability creates one with after(timeout) and waits for it with wait_until(deadline), and only the owners the repository clock-boundary check declares may take that capability. Paired doctests beside the runtime’s clock capabilities, which build with the server’s testing capability, hold these signatures: each wait compiles with its own deadline type, and handing either wait the other type fails to compile. A rejected snippet is evidence about that snippet, not proof that no other path mixes the two classes. The clock-boundary check, the Shuttle checks of logical waiters and the public scenarios remain the evidence for the time classes.

Actual UTC enters the data plane through a dedicated boundary, and expression engines cannot read it directly. That boundary and the capability that arms physical deadlines belong to the connector contract, which the runtime and every connector crate share, so a connector stamps arrival time through the same owner the runtime uses. Repository validation checks these ownership boundaries across the workspace, so a new runtime or connector path must choose its time class explicitly.

Physical monotonic time is measured with the timers and instants of the primitive boundary, nervix_primitives::time, which selects them for the build’s execution mode and grants no clock permission: it reads no actual UTC, arms no physical deadline on a caller’s behalf, and a logical deadline armed on its timer stays logical, as described above. Its timers follow the clock of the runtime that polls them, so an ordinary test on a paused runtime checks a physical deadline’s elapsed behavior exactly, and a simulated interconnect host waits in simulated time. Shuttle does not model time: under Shuttle a timeout never measures its deadline, and a check decides whether it wins. HTTP request deadlines and Retry-After therefore stay physical, flush cadence keeps the domain clock, and no check establishes progress by waiting.

The web console’s clock display is an external observer: its clock-display module reads browser UTC to project an attached paced mapping for the screen. That projection does not enter a node’s read watermark, alter tick progress, or supply domain time to execution. The repository clock-boundary check lists this module among the owners that may read actual UTC directly, and the console’s clock projection samples browser UTC only through it. The check rejects the Rust UTC reads it recognizes elsewhere in the console; it does not recognize the browser’s own Date.now(), which the console reads for display-only freshness of its dataflow graph and its reconnect countdown.

The source check masks cfg(test) module bodies through the shared Rust scanner, including modules with source contracts or additional attributes. It preserves line coordinates and product code after each fixture module. A harness layer contract alone grants no clock permission to product code.

Recovery And Distributed Guarantees

The paced mapping, lifecycle generation, and authority fence recover from consensus state. Tick progress, delivery loops, local read watermarks, and process-monotonic deadlines do not. After a restart, nodes install the committed mapping and the leader reconciles an authority for the current live voter incarnations. The producer recomputes the latest due boundary from the mapping.

This produces the following failure behavior:

EventResult
Leader transferThe committed mapping and authority remain unchanged unless the effective candidate set also requires reconciliation
Authority loss or restartConsensus advances the authority revision and assigns another live voter incarnation; the mapping and lifecycle generation stay fixed. When the authority also led consensus, the survivors elect a leader first. Every node installs the new fence ahead of its graph installation
Node join or reconnectThe node installs the committed generation before execution and receives the newest retained progress once it advertises its installed domain clocks
Session attach before a restarted node installsThe attach waits for the node’s first installation of the committed domains, so it never reports a committed domain as missing
Delayed old progressGeneration, revision, identity, and authenticated-peer checks discard it
STOP followed by STARTStop revokes the authority; start increments the generation and commits a new mapping and fence
Mapping or tick arithmetic overflowProjection, deadline conversion, boundary, and tick-id operations report typed clock errors instead of wrapping or changing anchors
Missing mapping or authorityPaced access fails as uninstalled; it never falls back to actual UTC

The authority’s projection, cadence, tick-boundary and rate-conversion failures render the full typed report in both the runtime event and the corresponding warning. Timestamp overflow includes the timestamp arithmetic cause beneath the clock operation. Clock attachment and progress-apply events, source cadence errors and message-error flush-clock failures preserve their supplied contexts in the same way, including the unavailable lifetime beneath a failed deadline wait.

All nodes project from their local UTC observations. The shared mapping and per-node nondecrease rule keep one generation coherent, but they do not make simultaneous reads on different hosts identical and do not create a distributed total order. Operators must synchronize and monitor host UTC. Host offset appears in each node’s projection and is multiplied by TIME RATE; event SKEW is an admission tolerance rather than a clock-synchronization budget.

Evidence

The paired doctests in this chapter check specific current API restrictions: a logical wait takes a logical deadline, a physical wait a physical one, and a function the VM calls out to receives its execution time. They check signatures, not behavior. Clock generations, attachment ordering, the attach’s wait and paced simulation are runtime obligations, held by checks that run the production owners:

  • The Shuttle checks of the clock lifecycle, which Data-Plane Concurrency lists, explore nondecreasing reads of one generation, refusal of a replaced generation, publication order, each wakeup of a logical waiter, the attachment’s delivery order across ticks, unassignment, reassignment and a generation change, and an attach waiting for the node’s first installation.
  • Public Cucumber outlines over one and three nodes cover the attachment contract, a client pacing simulated events by its attached clock, the CLI following a clock through its generations, refusing a missing domain and following a clock across a cluster restart, the console’s clock display, and every client runtime’s conformance probe reading an attached clock. Single scenarios cover the CLI restoring a clock after a transport loss and a reconnected console restoring its attachment.
  • Registered Bolero properties keep the complete values of clock periods, skews and rates, of the attach and detach statements, and of the clock requests, replies and frames. Mutations of a current clock frame decode deterministically or fail within the verification budgets.

The Bolero properties run as ordinary randomized and corpus tests on every pull request. Their sanitizer-backed libFuzzer campaigns run in CI only for a pull request labeled fuzz; every other event deliberately skips them, and a skip supplies no fuzz evidence. Property Testing And Fuzzing owns that policy.

Cluster Interconnect

The cluster interconnect is the authenticated transport between Nervix nodes. It carries cluster membership, consensus traffic, control-plane requests, domain-clock progress, relay batches, replicated runtime state, resources, and snapshots. It is present on every live node and remains available through leader changes, placement changes, elections, and recovery.

The interconnect owns peer authentication, transport connections, wire framing, traffic isolation, bounded decoding, deadlines, and the delivery protocol for remote relay batches. The operation that uses it still owns the meaning of a request, the state it changes, and any durable recovery policy. In particular, a transport response is not automatically a statement that runtime work completed. Relay delivery exposes separate receipt, admission, and downstream-completion boundaries so callers can distinguish those outcomes.

For a transaction, a timed-out remote gate response may mean that engagement happened. The control plane retains that uncertainty and any later recovery scope in the impact report; Transaction Quiescence And Impact Inspection defines those outcomes. This chapter owns the transport deadline and failure signal.

Errors And Diagnostics explains how local typed reports and remote failure classes reach their callers and public edges. This chapter owns their wire representation and transport failure semantics.

Clients never use this transport. Client Session Protocol owns the public client boundary: its listeners, authentication, FlatBuffers frames, request correlation, command dispositions, and client recovery. The interconnect carries only node-to-node traffic, including the subscription fan-out that feeds a client’s Row frames.

Source-local compiler contracts distinguish peer/slot installation from recurring stream, frame, admission and acknowledgement operations. Retained admission records and placement progress name their bounded protocol key and transition bound. Transport selection reads immutable target and connection publications. Delivery generations own remote ACK rows; authenticated peer owners own relay protocol collections under short, peer-scoped transition guards. A retained slot admits one worker through an atomic claim; established operations read that claim. The compiler’s contracts do not establish wire delivery or concurrency guarantees; those remain the protocols and checks described here. Data-Plane Concurrency owns the compiler authoring contract.

Simulation Boundary

The transport also runs, unchanged, inside a seeded Turmoil network simulation. That simulation is a test harness outside product ownership, and Deterministic Interconnect Simulation owns it: its build mode, the fault model, supervision, replay and failure records, the scenario matrix, the commands and CI budget, and the limits of what it establishes. Execution Primitives owns the backend and target selection that make those seams build consistently across the dependency graph. The interconnect owns only the seams the simulation plugs into. Each seam has one production behavior, which is what every node uses:

  • Sockets and name resolution. The TCP listener and outbound streams are the sockets of the primitive boundary, nervix_primitives::net, which are Tokio’s operating-system sockets, and peer names resolve through the node’s own resolver, described in Peer Name Resolution. In the dedicated turmoil test build the boundary selects Turmoil’s simulated TCP, and the peer resolver answers from the simulated DNS table through the lookup the boundary offers in that build alone, never constructing the node resolver; TLS, HTTP/2, the envelope codec, and Arrow IPC above them are the same code in every build.
  • Certificate time. Each credential bundle carries the one UTC clock its certificates are judged by, as described in Peer Identity And Authentication. Production bundles use the system clock.
  • Identity entropy. The process epoch and relay grant identifiers are drawn from the transport’s configured entropy, which in production is the operating system’s secure random source.
  • Deadlines. Every transport deadline is an instant of the boundary’s clock, nervix_primitives::time, whose timers are Tokio’s, so it follows the clock of the runtime it runs on: connection setup, request and progress timeouts, reconnect backoff, relay grant lifetimes, and the drain deadline derived from certificate expiry.
  • CPU work. Encoding and decoding run through nervix-execution, which submits each admitted CPU job through the boundary’s CPU-job mechanism. In the Turmoil build that mechanism runs the job as a task on the simulated scheduler, under the same admission, charge, and cancellation policy.

The transport’s concurrent maps use per-process hash seeds. Where a walk over one of them causes effects, the walk runs in the key’s semantic order instead of map order: retiring removed or departed peer targets, cancelling a node’s or every connection slot, re-establishing preconnected slots after a credential replacement, and sending relay progress reports. Walks that only count, take a maximum, or remove entries independently of one another keep map order, because it cannot change their result.

The Shuttle and Turmoil scheduler modes cannot be selected together; that build fails to compile with a diagnostic naming both. The normal runtime dependency graph contains neither.

Listener And Peer Topology

Each node exposes one TCP listener for all node-to-node traffic. Every accepted connection uses mutually authenticated TLS 1.3 and HTTP/2. The listener is independent of Raft leadership and graph placement, so every live node can receive every class of interconnect operation. TLS provides confidentiality and integrity as well as peer authentication.

Cluster discovery supplies the current node identity, incarnation, and advertised endpoint for each peer. A bootstrap seed, configured or recovered, is the exception: the initiating node knows the endpoint before it knows the remote node identifier, then obtains and authenticates that identifier from the peer certificate. Once discovered, a peer is addressed by its authenticated identity rather than by an unverified endpoint claim.

On restart, the node also uses the peer endpoints in its recovered Raft membership as gossip seeds, excluding its own endpoint. These are contact hints, not current discovery advertisements: the bootstrap exchange authenticates the answering node, and gossip then replaces the hint with that node’s current incarnation and advertised endpoint. This lets a former bootstrap node contact survivors even when its deployment has no configured bootstrap host. A node with recovered peer endpoints can also start when its configured bootstrap host does not resolve: it logs that lookup failure and contacts the peers whose recovered endpoints do resolve.

A peer advertises three endpoints independently: its interconnect endpoint as a host and port, and its client and web-console endpoints as URLs. Discovery converges field by field, so each one is either available, meaning the peer has published a value this node accepts, or unavailable. An advertisement that has not arrived yet and one this node cannot read are the same unavailable state, carry no reason, and are replaced by whatever a later round publishes. An unavailable endpoint is never filled in from a default, another field, or an earlier value, so no consumer can rebuild an address the peer has not advertised.

Discovery also carries whether the advertised process incarnation has begun terminating. That state belongs to the incarnation rather than the stable node identifier: it keeps the process available to finish existing ownership handoffs and consensus work, but removes it from new placement destinations. A restarted process has a new incarnation and does not inherit the advertisement.

The failure detector retains dead process identities separately from the live peer view. Explicit Raft member removal uses the newest observed live or dead identity to fence the stopped process; dead identities alone never make a peer eligible for membership admission. Scheduling can retain an already established health target while gossip liveness lapses, as described below. Explicit member removal is checked against the mutation owner’s effective availability, including that retained health target. Gossip absence can precede effective unavailability. Removal qualification establishes the target on both surviving voters and observes effective unavailability through each public membership warning before issuing the mutation, so either voter can own the leader’s check. The warning uses the effective availability snapshot, while gossip visibility is reported separately. Its reason names application health when that owner excludes the peer, or Chitchat when gossip excludes it after the retained health observation has aged out.

Connections are directed. Both nodes in a pair build their own outbound connections because some operations, including relay acknowledgements and cluster events, travel back over the receiver’s outbound management connection. A single connection never changes traffic class after it has been bound.

The standard pool layout is:

PoolPrimary trafficOutbound connections per peerStreams per connectionReadiness
ManagementMembership, health, consensus control, clock progress, relay progress and outcomes164Preconnected
CommandsControl-plane and runtime requests132Preconnected
ReplicationConsensus log entries, runtime-state descriptions and acknowledgements, ownership handoff control18Preconnected
RelayArrow record batches264 eachPreconnected
BulkResources, runtime checkpoints and snapshots, consensus snapshots14On demand

A peer is transport-ready only after all five preconnected outbound connections are live. Bulk traffic does not delay readiness; its connection is opened when needed. The two relay connections allow unrelated relay channels to make progress independently while preserving ordering within each logical channel.

The built-in topology limit is 64 peers. A node admits at most 768 incoming and outgoing connections in total and performs at most 32 connection handshakes concurrently. Those limits cover the six possible connections in each direction for every peer: five preconnected connections plus the on-demand bulk connection.

Peer Name Resolution

A node resolves every host name the interconnect dials through the node’s resolver. Name Resolution owns that resolver: its configuration options, the order in which it answers literal addresses, hosts-file names and DNS names, its cache and TTL bounds, its limit on concurrent lookups, its failures, and what it does not implement of the operating system’s name service. This section describes where and when the interconnect asks it, and what a failed lookup means for a peer.

Where The Interconnect Resolves

At startup a node resolves its own advertised interconnect endpoint, whose first address becomes its gossip identity address, and its configured bootstrap endpoint, every address of which becomes a gossip seed. Each lookup has the connection setup timeout, five seconds. Failure to resolve the node’s own endpoint fails startup with failed to start cluster membership. A configured bootstrap endpoint must resolve when the node has no recovered Raft peer endpoints; otherwise its lookup failure is logged at warn and skipped. An invalid configured bootstrap endpoint still fails startup. The node also resolves the recovered Raft members’ advertised endpoints in parallel. Their successful answers become additional gossip seeds; an unavailable recovered endpoint is logged at warn and skipped so it does not prevent the node from starting or using another reachable member. These startup answers are not looked up again: gossip keeps dialling the seed addresses they produced, and the gossip identity address stays the one resolved at startup. Once gossip discovers a live peer, the interconnect replaces the recovery hint with that peer’s current advertised endpoint.

A discovered peer is registered at the interconnect endpoint it advertised, and every attempt to open one of its pool connections resolves that endpoint again, inside the attempt’s connection setup deadline. The attempt dials the resolved addresses in order, giving each an equal share of the time that remains, so an address that refuses or never answers leaves time for the next one. The connection budget and ordered address-attempt policy are owned by nervix-dns and shared with outbound connector transports; interconnect retains its socket and peer failure classification. The first address that accepts carries the TLS handshake, HTTP/2 and the connection hello, inside the same deadline; a failure there fails the attempt. The advertised host stays the TLS server name, which the peer’s certificate must name, and the authority of every request on the connection; a literal IPv6 host is written in brackets there. A bootstrap exchange is the one exception: it dials the exact seed address it was given, and the node it authenticates is dialled there until discovery publishes the node’s own endpoint.

Pool connections are keyed by the advertised endpoint rather than by an address, so a changed answer or an expired TTL never retires an established connection. Only the next connection attempt, after a connection ends, uses the new answer once the resolver’s cached one has expired. A changed advertised host or port is a different endpoint and retires the old one, as described in Connection And Credential Lifecycle.

The connection setup deadline bounds each lookup, and the pool slot’s reconnect backoff, described in Connection And Credential Lifecycle, is the only retry around it, so DNS retries never multiply the transport’s own. A failed lookup is a connection setup failure: the slot retries with its backoff, the attempt is counted with reason resolution in nervix_interconnect_connection_failures_total, and the failure is logged at debug as interconnect pool connection failed with the peer’s node, endpoint, pool class and slot. Only outbound pool connections are counted; the startup lookups and the bootstrap exchange are not. A peer whose name does not resolve stays a health target, and its probes fail as they would for a peer that cannot be reached.

Peer Identity And Authentication

Every node certificate is signed by the configured cluster CA and supports both the TLS client and server roles. It contains exactly one canonical identity URI:

nervix://cluster/<cluster-id>/node/<node-id>

The certificate also contains a DNS or IP subject alternative name for the node’s advertised endpoint. Local credentials are rejected at startup unless their cluster identifier, node identifier, endpoint name, validity period, and key all agree. A remote connection is accepted only when all of the following are true:

  • its certificate chains to the configured cluster CA
  • its identity URI names the same cluster
  • its node identifier is the expected peer identifier, once that identifier is known
  • its endpoint subject alternative name matches the advertised endpoint being contacted
  • its certificate is currently valid
  • TLS negotiates the h2 application protocol

“Currently valid” is judged by the UTC clock of the local credential bundle. Production bundles use the process wall clock. Rustls verification on both sides of the handshake and the transport’s own validity and expiry checks read that one clock, so they cannot disagree about a certificate.

The first HTTP/2 exchange binds the connection to its authenticated node, traffic class, advertised endpoint, current process epoch, and wire-contract fingerprint. The receiver cross-checks those claims against the TLS identity and the connection slot it is accepting. This prevents a valid peer from relabeling a connection as a different node or traffic class.

Three identities serve different purposes:

  • The certificate node identifier is the stable authenticated identity of a node.
  • The discovery incarnation and endpoint generation identify the current cluster presence and advertised address of that node. The incarnation also names the run of a node that registered a relay admission or record acknowledgement, as Acknowledgement Registrations describes.
  • The process epoch identifies one running interconnect process and fences in-memory delivery state across restarts. It is drawn from the transport’s entropy when the transport binds, which in production is the operating system’s secure random source.

Replacing an endpoint, restarting a process, and rotating a certificate therefore have distinct meanings even when the stable node identifier does not change.

Coordination operations use a typed identity composed of the authenticated coordinator node, its current process epoch, and a process-local sequence. The sequence begins independently in every process; the node and process epoch make equal sequence values distinct across concurrent leaders and restarts. For every coordination request, the receiver verifies the node and process epoch against the bound connection before the application handler can observe the request or open a coordinated response stream. A process therefore cannot issue or replay an identity that belongs to another node or to an earlier run of the same node.

Entity-gate engagement binds that identity to one canonical domain, relay set, affected-entity set, and purpose. A retry succeeds only for the same identity and complete scope; using the identity for a different scope is rejected. Drain status and release carry the same identity, so a delayed message from another coordinator or process incarnation cannot observe or remove the hold. Planned ownership-handoff capture, preparation, confirmation, activation, and discard also carry this identity. Prepared handoff state persists it in the sole current stored shape and checks it again before activation or cleanup after a restart. The handoff’s committed schedule-transition ID still names the schedule change; it does not authorize coordination traffic.

A model alteration that includes an ingestor and shared downstream relay uses two entity-gate operations. An intake-only scope, with no relay gates, first suspends the affected ingestors on every node and drains their admitted work. The full subgraph operation then engages before that intake operation is released. Each operation has its own authenticated coordination identity and exact scope, and both use the same alteration deadline. Status, retries, receiver-owned release, and lease expiry retain the normal identity checks throughout this overlap. The coordinator does not change an existing operation’s scope to advance the drain.

A coordinated WASM guest-state reset uses the same authenticated coordination identity and adds its typed reset scope to the entity-gate purpose. Engagement publishes a branch-selective relay fence: one concrete fingerprint, the explicit unbranched instance, or all concrete branches. A retry must match the complete domain, relay and entity set, purpose, and scope. It cannot widen a one-branch hold or release another reset’s hold.

An operator’s NSPL reset enters through the ordinary typed session command protocol and ordered transaction planner. The transaction step retains the command execution reference and exact scope; the leader uses that reference when invoking this same coordinator request. A reconnect or a new leader resumes the recorded step with the same identity. No separate public reset wire request or JSON command path is introduced.

The management pool carries one typed coordinator request, which a node sends to the leader for a reset that starts outside the ordered transaction path, such as a guest’s request for a new lifetime of its own branch. It carries the stable execution reference and exact reset target to the leader, which runs the single control-plane operation. Its lower-level runtime requests have three actions. Prepare reaches only the scheduled processor owner and creates fresh guest state while retaining enough stopped branch state to abort before publication. ActivateCommittedSchedule reaches every gate participant, applying the committed schedule locally to replicas before the owner writes and replicates its initial checkpoint. This ordered activation does not join the ordinary cluster-wide runtime-revision barrier: an owner whose initial checkpoint failed is the node that keeps that barrier incomplete. The background runtime applicator still owns the node’s prepared and ready revision observations. Abort is valid only before publication and restores the retained branch tasks. Responses carry the complete classified failure chain. A transport success proves only that the receiver performed the requested action; the Raft-backed Publishing and Ready schedule phases remain the durability and usability boundaries.

One more management request belongs to the same operation. A processor that declares ON REJECTED STATE RESET raises a refused guest-state lifetime from the node that owns the branch, and that node forwards it to the current leader as a typed recovery request naming the domain, the processor, the refused branch or the explicit unbranched instance, the generation whose snapshot was refused, and which of the two guest verdicts it gave. It carries no reset reference: the leader derives one from that identity, so a request that is retried, forwarded again after a leadership change, or raised by a new owner drives the very same coordinated reset instead of a second one. A leader that finds the branch already past the reported generation answers that the lifetime is gone rather than resetting the one that replaced it. The response carries the classified failure chain and is not itself the durability boundary; the recovery’s spent attempt and the reset’s schedule phases are.

The coordinator records every destination as an attempted participant before sending its prepare request. A timeout, cancellation, or missing response after the destination persisted the request therefore remains explicit cleanup work. A participant that refuses capture, preparation, forced recovery preparation, confirmation, activation, or reconciliation answers with a rejection whose text is its complete failure chain rather than only the outermost failure, so the coordinator reports, for example, a destination WASM guest’s classified restore failure with its stage, branch, module, and saved state revision. Discard is exact and idempotent over the coordination identity, transition ID, domain, and entity; a delayed discard for one operation cannot remove a replacement prepared by another operation.

The current leader reconciles durable preparations after leadership or live-incarnation changes. It first commits a consensus barrier and sends its log position with the reconciliation request on the replication pool. Each participant applies through that position before consulting its local committed schedule. An exact ownership transition in that schedule preserves its preparation even when the original coordinator or destination process has gone. An uncommitted preparation remains only while the original coordinator process and both bound participant incarnations are live and the exact ownership-handoff gate is still held; every other preparation is discarded durably. A second pass after the gate deadline reclaims work that was active during the first pass.

Ownership-handoff engagement answers after admission is fenced and its force flush is requested. The coordinator then queries the exact operation’s drain status. A participant publishes its capture freeze before returning a drained status, and capture requires that coordination identity’s freeze in addition to its held gate. Pending status retains the work counts used by timeout diagnostics. Each engagement request keeps the two-second reply bound; a typed timeout retries the same identity and complete scope within the original preparation deadline. A retry observes the existing receiver-owned operation and lease. Other request failures keep their existing classification.

After a receiver admits a new gate engagement or release, a receiver-owned task finishes that state transition even if the requesting connection disappears. Coordinator loss therefore cannot strand an operation in a partially engaged state or cancel cleanup after the receiver accepted it.

Each receiver lease is a distinct in-memory instance even when an identical request is re-engaged after release. Its deadline task may remove only that exact instance. An earlier deadline can therefore neither release nor erase a replacement lease occupying the same logical operation key.

A stopping node moves its own scheduled work with one typed commands-pool request to the current leader, stopping_node_drain. It names no node and carries one of two actions: drain, which cordons the sender, carries its remaining drain budget, and moves its scheduled work through planned ownership handoffs, and release, which clears the cordon that drain set. The leader acts for the node the authenticated connection belongs to, so the certificate that admitted the connection is the whole authorization: a node can drain and uncordon only itself, and no user credential takes part. The answer is completed or failed, each with the leader’s account of the action for the stopping node’s log, or not the leader, which leaves the sender to ask the leader it observes next. A node that does not lead when the request arrives changes nothing. A leader that loses leadership before completing a drain also answers not the leader, logs its interrupted report, and preserves any moves already committed. The next leader plans the remaining moves from the current schedule, within the sender’s remaining budget. A leader that loses its leadership while it writes a release also answers not the leader, because clearing a cordon again through the next leader is harmless. The leader runs the action in a service task of its own, so a drain that has begun finishes, and releases what it holds, even when the sender’s deadline abandons the request first. For a drain that deadline is what remains of the sender’s drain timeout; the leader also uses the budget carried by the drain action to bound each unit’s gate, state preparation, and activation waits, leaving a window for later units and the sender’s in-place drain. For a release it is the bound Releasing The Drain Cordon states. Topology Cases owns when a node sends it.

Wire Contract And Payloads

All nodes in a running cluster use one current wire contract. A fixed fingerprint covers the set of supported operations and their encoded shapes. A fingerprint mismatch rejects connection setup; there is no version negotiation or alternate decoding path. A wire-contract change therefore requires a coordinated cluster stop and start with all nodes on the same version. The subscription-interest visibility request includes the advertisement version, and its current wire fingerprint fences that request shape during connection setup.

The fingerprint also covers fixed-width 64-bit counts in Models, transaction commands and records, and WASM inspection results. These fields use the vocabulary’s CountAsU64 adapter and checked native decoding; an unrepresentable count fails decoding. Window processor state has current runtime-state kind tag 8, and its bulk snapshot codec validates the NVXWIN64 frame signature before decoding histogram delayed-removal bucket indices in the same count representation.

Control records use bounded rkyv archives. The receiver validates an archive, including its shape and nesting depth, before exposing it to an operation handler. Encoded and decoded memory is charged to the traffic class before decoding begins. Unknown operations, a pool mismatch, malformed archives, and values above the operation limit fail at the transport boundary.

Validation checks an archive’s shape. Semantic values such as typed names are checked when the record is read back, so a record can be refused after part of it was read. The shared archive reader owns each completed list or fixed-array element while reading the next, and releases the initialized prefix and the list, boxed, or shared allocation if a later element is refused. A relay grant request whose acknowledgement registrations include a registrar that is no node’s name is refused with everything already read freed. The operation handler receives no part of that request.

Control-operation responses preserve a typed failure class and subject across the wire. A receiver can distinguish a node that rejects ownership, an unavailable subject, a subject that is not ready, and an operation that ran and failed without parsing display text. Only the final class carries an operator-facing reason; callers decide retry and relocation from the class and subject.

Relay metadata uses the same validated control encoding, while relay bodies remain Arrow IPC from the source relay to the destination runtime. Bulk operations transfer opaque byte chunks and let the owning resource, snapshot, or state protocol interpret the stream.

Before the receiver gives a relay Arrow stream to Arrow’s reader, the shared IPC framing owner checks every message’s continuation marker, metadata, declared body and column buffers against the received bytes. A malformed stream reports ArrowBodyError::Framing before the reader can allocate or slice from an unchecked length. A reader panic on malformed metadata that passes framing is reported as ArrowBodyError::Decode.

The primary payload limits are:

PayloadMaximum size
Management event64 KiB
Command1 MiB
Replication batch2 MiB
Encoded relay batch32 MiB
Decoded relay batch32 MiB
Relay decode scratch space16 MiB
Bulk transfer chunk64 KiB
Snapshot section8 MiB

A receiver also holds an Arrow body to its own framing before it decodes it. The body is one canonical IPC stream: every message opens with the continuation marker, and the end-of-stream marker ends the body. Every length the stream declares must lie within the bytes the body carries: each message’s metadata, its body, and each column buffer inside that body. A body that is framed otherwise is refused as misframed, so a declared length never sizes an allocation the body does not back, and a column buffer is never sliced outside its message. The same scan refuses, as undecodable, a body whose schema declares a field type Nervix does not carry, a dictionary encoding among them, or whose record batch is at odds with that schema. It opens every Arrow section of a sealed snapshot, a checkpoint or a backup archive.

HTTP/2 flow control adds another bound. Each stream begins with a 64 KiB receive window, each connection begins with a 256 KiB receive window, and request headers are limited to 16 KiB. A receiver releases more HTTP/2 credit only as it accepts and accounts for more data. Large streamed objects can therefore cross the cluster without either endpoint holding the whole object in memory.

Traffic And Resource Isolation

An operation’s type fixes its pool, admission quota, response type, and deadline policy. Callers do not select a less restricted pool at the call site. This preserves traffic isolation even when a busy subsystem issues many requests.

The management connection reserves its 64 outbound stream slots as follows:

UseReserved slots
Shared management operations32
Discovery4
Application liveness8
Progress reports and relay status8
Runtime and relay admission4
Relay cancellation4
Relay terminal outcomes4

The replication pool reserves two of its eight slots for the active ordered consensus-append stream and a replacement while the previous generation closes. The other six are shared replication slots. The bulk pool reserves two slots for resources, one for snapshots, and one for other bulk work. Commands and relay bodies use the shared capacity of their dedicated pools.

With the standard node capacity, typed requests also have independent node-wide admission quotas, applied separately to incoming and outgoing work across all peers and connections:

Request useStandard admissions per direction
Shared1,024
Consensus append8
Resource transfer8
Snapshot transfer8
Discovery8
Application liveness32
Domain-clock progress8
Runtime and relay admission8
Relay cancellation4
Relay terminal outcomes4

These reservations mean that ordinary management traffic cannot consume the capacity required to resolve already-started relay work or determine whether a peer is healthy.

Leasing an established stream allocates nothing and acquires no shared discovery map. An immutable persistent target table selects the peer’s fixed pool arrays. Each slot retains its cancellation lifetime, one atomic worker claim and an ArcSwapOption containing the current authenticated connection. A lease retains that exact connection and takes its existing pool/subquota reservation. Only a first use can win the worker claim, including on-demand bulk. The worker clears its connection publication on loss and publishes a fresh connection after authenticated reconnect. The cold connection registry remains available for registration, statistics and exact teardown; predecessor teardown removes only its own allocation.

Endpoint or TLS replacement cancels the preceding slots before publishing new fixed arrays. Peer retirement therefore cannot expose a replacement connection through a stale slot. A dial-address update for an unchanged endpoint preserves slot identity and affects only the next connection attempt. Such an update retains the fixed arrays’ shared owner under its new target wrapper, so a concurrent peer withdrawal still withdraws that exact pool lifetime. A recreated pool has a distinct owner, even at the same endpoint, and survives a predecessor’s withdrawal. The fixed array scan retains round-robin selection and each class’s existing capacities.

An operation that finds every stream of its class and subquota to a peer leased waits for one to be released, within its request deadline. Each peer keeps one wakeup for each class and subquota, and a released stream wakes a waiter of exactly its own class and subquota. A single wakeup shared by every waiter could reach one that cannot use the released slot, while the waiter that can use it slept on until its deadline. The waiter registers for that wakeup, and for any change of the peer’s connections, before it checks the pools a second time, so a release between its two checks still wakes it. The wakeups belong to the peer rather than to one of its targets, so a waiter of a target that was replaced meanwhile still hears a release of the replacement.

A connection stops granting stream leases the moment it begins to drain, whether it is retiring or the transport is shutting down. A lease checks for the drain only after it holds its slot, so it either returns that slot at once or was granted before the drain began and is one the drain waits for. The drain therefore completes only after every leased slot has returned, and the connection grants no further lease while it closes.

Memory and CPU execution are isolated by class:

ClassMemory budgetCPU execution class
Management8 MiBControl
Commands and replication24 MiBControl
Relay192 MiBData
Bulk32 MiBBulk

The total interconnect memory budget is 256 MiB. Reservations cannot borrow from another class. The relay budget holds two worst-case operations, each consisting of a 32 MiB encoded body, a 32 MiB decoded batch, and 16 MiB of decode scratch space. The commands and replication budget holds the four decoded replication batches a follower keeps resident beside one batch being encoded, so a follower whose receive window is full can still produce the answer that releases it. Startup validates the relationships among operation limits and these budgets, including the paired work that must fit for independent operations to keep making progress.

Stream slots are isolated per connection, and therefore per peer. Admission quotas, memory budgets, and CPU wait queues are isolated per class and shared by every peer of the node. A slow or stalled peer therefore holds at most the stream slots of its own connections. Once it holds the 32 shared management streams of a connection, a further shared operation to that peer waits for a slot until its own deadline, while liveness, cancellation, and discovery still use the reserved streams of the same connection. Every connection to another peer keeps all of its slots. A stream whose reader stops reading holds one HTTP/2 stream window of response bytes: flow control stops the producer once that window is spent, and five seconds without progress resets the stream and returns its admission and memory. A relay batch the receiving application holds without admitting keeps the reservation of its exact body, the largest decoded batch, and decode scratch. Another channel still receives grants and admission beside it, and cancelling the held batch returns the whole reservation once the application drops the batch.

A CPU class admits each job separately: one of its workers runs, a bounded number wait, and the next job is refused at once instead of joining an unbounded queue. A typed request runs several jobs of its class in turn, encoding its payload and envelope and later decoding the response. While a burst keeps the queue full, a request that was admitted for one job can therefore be refused at its next one. A request refused at its first encode fails with RequestError::Encode, and one refused at a later stage fails with RequestError::Transport. Every refused job holds no memory charge, and every other class keeps admitting independently.

The stalled-peer simulation checks these bounds with exact values: a stalled peer and a healthy peer share one hub, and every observation of the hub’s pools, streams, admissions, worker queues, and memory budgets stays within its configured bound until teardown releases them.

Exchange Forms

The interconnect provides five exchange forms. Each keeps transport mechanics separate from the operation’s domain semantics.

  1. Bounded event. The sender transmits one validated event and receives an empty success response after the receiver accepts that event. Cluster events and asynchronous acknowledgements use this form.
  2. Typed request and response. The request type declares its operation name, response type, traffic class, admission subquota, deadline, and live-target requirement. Remote typed errors are kept distinct from transport failures. Application health and domain-clock progress use this form so their reserved quotas and typed outcomes apply independently of generic events.
  3. Streamed response. The receiver declares the exact byte length, then sends a flow-controlled sequence of chunks. The reader rejects early end, extra bytes, and a stalled chunk. Dropping the reader cancels the stream and releases its reservations. The producer’s opening callback and each chunk carry a local StreamHandlerError report; an admission, storage, or encoding cause stays beneath that handler context until the response reaches the wire boundary.
  4. Ordered duplex stream. Each direction sends length-prefixed, validated frames in order and may close independently. Opening the stream is bounded by the operation’s declared setup deadline. An idle established stream is valid; the owning protocol sets deadlines for answers it is awaiting. The initiator’s sender reports when the peer’s flow control last accepted its bytes, so that protocol can tell a slow answer from a peer that accepts nothing. Receiving is cancel-safe on both ends. While a frame is being written, each nonzero flow-control grant restarts the five-second write progress bound. A frame has no total transfer deadline while bytes keep advancing; zero-capacity notifications do not restart the bound. Expiry resets that stream with the typed transport progress timeout. A frame is decoded under the pool’s memory and CPU admission after it leaves the stream, and a receive its caller abandons, because a timer or command it selects against won, leaves that decoding to the next receive, which finishes the frame before it reads another. No abandoned receive loses a frame or reorders one. A stream has one codec, fixed by its operation’s pool class: the opening message, the initiator’s frames and the responder’s answers are all encoded and decoded through it, under that class’s memory and CPU admission and within its payload limit, so what one end encodes is exactly what the other decodes. A frame that would grow past the limit fails to encode, as a typed failure of the end sending it, before any of its bytes is sent. Consensus append traffic, client producer links and client consumer streams use this form.
  5. Relay delivery. A management-plane grant reserves receiver capacity before an Arrow body is sent, followed by explicit runtime admission and optional downstream record acknowledgements.

A typed request separates the operation’s own outcome from the transport’s. A handler that cannot produce its value answers with one of four classes, and the asking node acts on the class rather than on any text. Rejected means the answering node does not serve that subject at all, so a caller working from a stale schedule resolves the owner again instead of retrying the same node. Unavailable means the subject does not exist there. Not ready means it exists but cannot answer yet, so the identical request can succeed later. Failed means the operation ran on the answering node and lost.

Every class names the subject it refused: a domain, one entity of a domain, one kind of runtime state held for an entity, or one subscriber’s interest in a relay. The caller therefore reports what failed without retaining the request it sent, and it never has to parse a message to decide what to do. Only the failed class carries the answering node’s own description, because a failure inside another node’s subsystem is opaque to the caller and that text exists for the operator reading it. State snapshot exchange, dataflow node status, domain and entity drain status, entity gating, metric description, relay, hash map and ingestor description, hash map queries, and subscription-interest visibility all answer in these terms.

Each handler registration publishes a complete replacement dispatch table, so an arriving operation finds its handler without taking a lock and registrations that race each other all take effect. Registrations racing for one operation name within an exchange form publish it exactly once, and every other one is rejected as already registered. Discovery likewise publishes the complete live-node set at once, and the live-target check a typed request makes reads that set without a lock. A request waiting for its target to leave registers for membership changes before it reads that set, so a change published between the read and the wait still ends the wait. Until discovery publishes its first set, every target counts as live so that bootstrap discovery can reach its peers.

Connection setup has a five-second deadline covering TCP, TLS, HTTP/2, and connection binding. The default bounded request deadline is ten seconds and includes time waiting for an admission or stream slot. An operation can declare a tighter or longer semantic deadline; application health uses one second, domain-clock progress uses two seconds, and resource and state stream openings use longer operation deadlines. Once a streaming body is moving, five seconds without progress fails that transfer. Queueing never creates an unbounded extension to a declared deadline.

Remote Relay Delivery

The relay protocol connects the owner-local fan-out described in Relay to a concrete runtime branch on another node. It preserves the Arrow batch as a columnar payload and carries only bounded metadata beside it.

A logical outgoing channel is identified by the authenticated destination node, payload role, domain, destination relay, and concrete branch. Different channels may run concurrently. Within one channel, the sender preserves FIFO order and permits at most one batch to wait for runtime admission. Each sender channel has a unique incarnation and a monotonically increasing sequence number.

For fan-out, the source encodes an Arrow batch once and shares those immutable encoded bytes across destinations and transport retries. Delivery metadata, admission state, and attached record acknowledgements remain specific to each destination channel.

A delivery proceeds as follows:

  1. The sender leases a relay-body stream before requesting receiver capacity. This prevents a saturated body pool from holding a remote reservation that it cannot use.
  2. The sender requests a grant over the reserved management admission quota. The request identifies the delivery process, channel, sequence, payload length, branch, relay, and acknowledgement shape.
  3. The receiver verifies the sender process epoch and delivery order. Before issuing a grant, it reserves the encoded body, maximum decoded batch and scratch memory, an incoming queue item, and capacity for the eventual terminal admission outcome.
  4. The grant remains valid for five seconds. The sender transmits the exact Arrow IPC body over the relay pool and names the grant and both process epochs.
  5. The receiver reads exactly the granted length under the reservation, records body receipt, and places the work in its bounded application queue. The HTTP/2 body response confirms receipt by the receiving process only.
  6. The application lane validates the body’s Arrow IPC stream, which must hold a schema message of the field types Nervix carries, one uncompressed record batch that declares the field nodes and buffers that schema’s fields take with every buffer inside its body, and the end-of-stream marker, and then the exact schema, metadata, branch, and record-acknowledgement count, then resolves the configured concrete runtime branch. Work for one channel remains ordered, while other channels continue independently. A payload that fails this validation never reaches the branch. The receiver keeps the failure as a report of RuntimeError::DecodeRemoteRelay with the typed RemoteRelayDecodeError beneath it, which names what the payload got wrong and keeps the decoder’s own failure beneath that, and answers the payload’s admission registration negatively with the whole chain rendered as the reason. A payload that carries no admission registration is refused the same way, with no registration to answer.
  7. When the concrete runtime branch accepts the batch, the receiver sends a terminal admission outcome over the reserved management capacity. The sender can then release the channel for the next batch. A batch that carries record acknowledgements is admitted only once the receiver has charged its acknowledgement watches; while the relay budget has no room for them, the receiver holds the batch back unadmitted, as Bounded Correlation And Peer Owners describes.
  8. If the send includes record acknowledgements, later acknowledgement events report downstream processing completion. Detached sends and subscription fan-out end at admission.

These steps expose three intentionally different outcomes:

  • Body received means the complete Arrow body reached the receiving process.
  • Admitted means the intended concrete runtime branch accepted the batch.
  • Acknowledged means the downstream work represented by an attached record acknowledgement completed.

While admission or an attached acknowledgement is unresolved, the receiver sends coalesced progress events through the reserved management quota. The sender treats five seconds without progress as a stalled exchange and bounds the total admission wait at five minutes. Once the delivery is admitted, the sender waits for an attached record acknowledgement only while the receiver keeps reporting it, and fails one the receiver reports nothing about for fifteen seconds, as Acknowledgement Registrations describes. Progress keeps a live attempt from being mistaken for a disconnected one; it does not change the delivery outcome. For an attached acknowledgement, progress also carries a monotonic sequence and whether all of its remaining handoff shares are parked, on REQUIRED WAIT or in a window that retains their rows. Each upstream node parks or reactivates its own attached share in sequence order, so a domain drain excludes a parked chain across relay hops. The eventual terminal acknowledgement still resolves every share; parking does not acknowledge the source or persist an acknowledgement. Admission progress carries no parked state.

Acknowledgement Registrations

The sender correlates the runtime admission of a delivery and every attached record acknowledgement it waits on through a registration it places in the delivery. A registration names the waiting entry by a number and names the run of the sending node that registered it: the node and the discovery incarnation of that run. Every process numbers its registrations from one, so the number alone repeats across restarts of a node, and the run is what keeps the registrations of different runs apart.

The receiver returns every progress event and terminal outcome to the node the registration names, carrying the whole registration back with it. The sending node resolves an entry only when the registration names its current run. A receiver can still be resolving what an earlier run registered after that run ended: a record acknowledgement whose downstream work completes after the sending node restarted is the common case. Such an outcome is rejected and logged at debug. It never resolves the admission or record acknowledgement the current run holds under the same number, so a success reported for an earlier run can never acknowledge a record, and so commit it at its source, before its own delivery completed. The earlier run’s entries ended with its process, and its sources redeliver the records they had not committed.

A receiver issues a grant only when the admission registration names the authenticated sending node. Admission bookkeeping on both ends is keyed by the peer and the whole registration, so a terminal outcome addressed to an earlier run neither completes nor retires the admission a later run registered under the same number.

Record Acknowledgements The Receiver Stops Reporting

A receiver reports every record acknowledgement it holds to the node that registered it. While the downstream work the acknowledgement stands for continues, it sends a report 100 milliseconds after its previous one was delivered or given up, and when the work completes it sends the terminal outcome once. Each report and the outcome is one bounded event that the receiver retries for up to five seconds and then gives up. Once the outcome is given up, or the receiver’s run ends, nothing reports that acknowledgement again.

The registering node therefore waits for an acknowledgement only while the receiver keeps reporting it. From the moment the delivery that carries the acknowledgement is admitted, a sweep once a second counts the passes in which the receiver reported nothing about it, and fails the acknowledgement once fifteen seconds of such passes have gone by. The bound outlasts two consecutive reports that each exhaust their five-second deadline, so a receiver that is still working on the record is not mistaken for one that stopped. Before admission, the delivery guard resolves cancelled work and the sweep bounds an abandoned registration at the five-minute total admission wait. The sweep counts its own passes rather than elapsed time, so a registering node whose own execution stalled, such as a paused container, does not fail acknowledgements whose reports it could not receive meanwhile.

A failed acknowledgement resolves negatively exactly once. The sweep and report transitions use the exact delivery generation’s guard, so a report that arrives first keeps the acknowledgement pending, and a terminal outcome or the delivery’s own failure that resolves it first leaves the sweep nothing to fail. The source attempt fails with it and redelivers the record along the current routes, so a sink that already completed the record can receive it again. A report or outcome that arrives after its acknowledgement failed finds no entry and is rejected at debug.

Every node on a record’s path applies the same bound. A relay owner that routed a record to the node of an attached consumer reports the record alive to the source’s node for as long as the record’s acknowledgement tree is unresolved. Without the bound, a terminal outcome lost between the consumer’s node and the relay owner would keep the source’s acknowledgement alive, and its ACK TIMEOUT from ever passing, for the rest of the relay owner’s run. With it, the relay owner fails the acknowledgement fifteen seconds after the consumer’s node fell silent, stops reporting the record, and the source’s retry takes over. Each sweep that failed acknowledgements logs, at warn, one line per receiver with the number it failed.

Bounded Correlation And Peer Owners

The sender has 8,192 delivery positions and a separate 8,192 admission positions. One delivery owns all its record rows, with independent outcomes; filling delivery capacity therefore leaves room to register its runtime admission. The opaque acknowledgement number encodes a position, generation and row. Resolution validates all three, together with the registrar’s full discovery identity. An exhausted generation is sealed permanently. A delayed report, terminal reply or cleanup cannot change a replacement occupying the same position. Unused positions are claimed on demand; only retired positions enter the bounded free queues. Shutdown seals fresh claims before scanning positions that were ever claimed, so a concurrent registrar either occupies a position shutdown visits or finds it closed. The once-a-second silence sweep scans the same claimed positions and no others: a share whose first claim races a sweep is counted from that sweep or from the first one after its registration, and a node that has forwarded nothing sweeps nothing.

Record storage and receiver ACK watches are charged to the relay memory budget. One task per admitted batch multiplexes its row watches, with a fixed charge of 1 KiB per row plus 4 KiB for the task; a wide frame therefore does not allocate one task per acknowledgement. Pending rows report progress every 100 milliseconds, below the registrar’s fifteen-second silence bound even when two reports each exhaust their dispatch deadline. The same cadence keeps local emitter and message-error acknowledgements alive while a connector request is pending, giving a one-second source ACK TIMEOUT multiple chances to observe progress. Refusing record storage is typed and occurs before the delivery is sent. Cancellation before admission resolves every held share negatively and returns its position. Completion of the last record returns the delivery storage and its charge.

The receiver charges a batch’s watches before it decodes the batch and before runtime admission. While the relay budget has no room for them, typically because the watches of earlier batches are still reporting their rows, the receiver holds the batch back instead of refusing it. The batch stays unadmitted, so the transport keeps reporting it alive and its sender keeps waiting within the five-second progress and five-minute total admission bounds; the sender’s channel carries nothing else meanwhile, which pushes back on the source that fills it. The held batch keeps the reservation of its grant. Every 10 milliseconds the receiver reads the budget and takes the charge once it fits. It never waits in the budget’s queue, because a place there takes every byte the class releases until it is satisfied, which would keep the node’s other relay work, including forwarding the rows whose acknowledgements end the watches it waits for, behind it. A held batch counts no refusal. The hold ends as soon as the sender cancels the delivery, its peer ends, or the runtime stops. Only watches larger than the whole budget, or a hold still without room when the five-minute bound has passed, are refused, with the typed admission cause beneath the runtime’s RemoteAckAdmission. Each watch returns its row’s share as soon as it reports the row’s terminal outcome, gives that outcome up, or ends because the registrar run left or changed, so room returns at the rate rows are reported rather than a whole batch at a time; the task’s share returns when the batch’s last watch ends or the runtime shuts down. The membership writer publishes immutable process identities; a watcher gives initial discovery five seconds and stops once a previously observed registrar is absent. Terminal delivery retains the five-second event deadline described above; this does not promise suppression of replay duplicates after lost outcomes.

An authenticated connection retains its peer’s protocol owner. That owner alone mutates ordinary grant, attempt, channel, admission and outbound-correlation collections. Each synchronous guard is scoped to that peer, is released before transport waits, and follows peer then record lock order. Runtime admission and cancellation use one irreversible atomic verdict. Peer removal or epoch replacement cancels unadmitted records and releases transport item and terminal permits, including when runtime still borrows an admitted intake. Its admitted verdict remains valid. Decoded metadata keeps its memory charge until its last borrower releases it.

With incoming queue capacity Q, each peer holds at most Q grants, attempts, active channels and admission records, at most 2Q channel watermarks, and at most 2Q outbound attempts and admission correlations. New unrelated channels are refused when retained watermarks occupy their budget; they are never evicted early to admit a replay. Lost terminal replies remain reconcilable. A sixty-second sweep reclaims protocol state after ten minutes without progress or reconciliation; live admission reports renew retained records. Authentication before gossip membership permits discovery, while relay operations wait for live membership. An owner still awaiting membership ends after ten minutes and closes its bound intake connections. Routing publication has at most the configured peer limit; normal frame and ACK operations use retained owners rather than a node-wide shared-map guard.

Ordering, Retry, And Reconciliation

A delivery identity combines the sender process epoch, receiver process epoch, channel incarnation, and channel sequence. The receiver also records the expected content for that identity. Repeating the same identity with different content is rejected.

After a connection loss, the sender first reconciles with the same receiver process. If the body was already received or the batch was admitted, the receiver returns that known state and the sender does not resend the body. Sequence watermarks reject reordering and duplicate enqueue. Inactive sender channels rotate after five minutes. A receiver keeps a channel’s watermark while a batch granted on that channel is unresolved, and for at least ten minutes after the watermark was last recorded or consulted by a grant, status, or cancellation request, so ordinary reconnects can still reconcile. A consultation refreshes retention under that peer’s protocol guard, together with its sequence decision. Independent peers have independent guards.

The relay reconciliation and receiver-restart simulations check this boundary through the production authenticated connection. They lose the reply after an Arrow batch reaches the receiver, reconnect to the same process or restart it, and require reconciliation within this retention contract and an indeterminate result against a new process epoch.

Each attempt carries the channel and admission identities it was granted under. Once the receiver has delivered an attempt’s terminal outcome, it retires that same attempt: it advances the channel watermark and releases the attempt, its channel occupancy, and its admission without rebuilding either identity or looking the admission up again.

Relay attempts, grants, watermarks, admission state, and record-acknowledgement owners are in-memory hot-path state. A receiver process-epoch change therefore makes an unresolved delivery indeterminate. The transport does not replay it automatically against the new process. If the source’s policy calls for another attempt, that attempt opens a new channel incarnation and is a new delivery identity.

Cancellation And Branch Changes

Cancellation and runtime admission compete through one atomic transition. A cancellation that wins before admission permanently fences that delivery identity, including when cancellation reaches the receiver before the original grant request. If admission has already won, cancellation returns the known admitted result and does not undo work handed to the runtime.

Removing or evicting a concrete branch cancels its unadmitted channel generation and releases its reserved work. If the branch appears again, it uses a fresh channel incarnation and sequence. This keeps a previous branch lifetime from being confused with the new runtime instance.

Consumers That Leave The Receiver

The relay owner also fences the start of each buffered batch’s fan-out against its local schedule swap. It acquires a dispatch permit before reading the local and remote consumer sets, and keeps that permit through fan-out, attachment of the consumer ACK shares, and resolution of the owner’s share. A swap closes the gate and waits for existing permits before changing those sets. If the gate is already closed when a buffered batch starts fan-out, the owner fails its record acknowledgements and the source retries after the schedule changes. It cannot wait for the gate while holding that buffered batch: the swap’s drain counts the batch, so such a wait would prevent the swap from finishing. In particular, an attached sibling on the old destination cannot acknowledge a batch while another attached consumer moves to a new destination before the owner selected routes for that batch.

The receiver decides admission before it hands the batch to the runtime consumers of the relay, and it hands the batch to the consumers that run on the node at that moment. An owner routes an attached batch to a node because its schedule places an attached consumer of the relay there. That consumer can leave the node after the owner routed the batch: a forced recovery moves every runtime node off a node that application health or gossip judged unavailable, including one that is still running, and the node stops the moved consumer when it applies the published schedule. The gap between admission and dispatch can be long: the receiver sends the terminal admission outcome to the owner before it dispatches the batch, and that send can take up to its five-second deadline while the owner is unreachable.

A routed batch that carries record acknowledgements and finds no attached consumer of its relay on the receiving node fails those acknowledgements. Its admission stands, so the transport does not send it again, but the owner receives a failed record acknowledgement and fails its source attempt. The source then redelivers the record along the owner’s current routes. The receiver never reports an attached record acknowledged when no attached consumer took the batch, so an attached record cannot be committed at its source without a consumer having completed it. A batch without record acknowledgements, such as a detached send, completes at admission as before.

Membership, Consensus, And Bulk Transfer

Cluster membership gossip uses management discovery capacity. It discovers topology and incarnations but does not replace application health checks. Gossip payloads remain below the management-event bound, so discovery cannot allocate an arbitrary wire message. A node that cannot take an exchange answers with a typed refusal rather than text: the message exceeds the gossip bound, the sending node could not be registered as an outbound peer, or its gossip receiver has shut down. Chitchat hands each outgoing datagram to a four-message queue for that destination. One worker per destination drives its interconnect requests in order, under the one-second request deadline; an unreachable peer therefore cannot hold the gossip loop while it receives from or sends to healthy peers. A full destination queue drops its newest datagram, and the next gossip round retries. Closing the transport cancels queued work and exchanges in flight.

Each node’s Chitchat live set is its own failure-detector estimate. An isolated peer can remain listed live until its missing heartbeats are observed, even after its links stop carrying requests; application probes provide the separate signal that eventually makes its work eligible for failover.

Chitchat continues to select known dead peers for exchanges during its 24-hour dead-node retention period. When application health has retired one of those peers from the outbound pool, a gossip exchange reinstalls its known route before sending. The replacement connection still authenticates the peer’s node identity and advertised endpoint. This lets a node with no configured bootstrap seed contact retained peers again after a partition heals; the returning exchange restores gossip membership, after which application health and Raft reconciliation use the current advertisements.

Admission to consensus membership requires an available interconnect endpoint. A discovered node without one is not an admission candidate, so it is neither added as a learner nor promoted to voter, and it becomes eligible on the round that publishes an endpoint this node accepts. The client and web-console advertisements are independent of admission: a node joins, votes, and leads with either of them unavailable. Membership replaces the recorded address of an existing member when its advertised endpoint changes. A returning voter remains a voter, while a learner still waits to catch up before promotion. The recorded address is a startup contact hint; live Raft traffic uses authenticated discovery by node identity. A node removed from membership stays out until it returns with a newer incarnation.

A redirect to the leader names only the advertised endpoints discovery has established. A client redirected during an election that has not yet observed the new leader’s client endpoint receives the leader identity without a redirect target rather than a guessed address, and retries until an endpoint appears. Leader Discovery, Redirect, And Reconnect defines how clients follow it.

Terminal teardown closes the gossip exchange path before it asks the gossip loop to stop. Closing the path first cancels destination workers and their queued or in-flight requests, so a peer that is itself stopping cannot hold teardown until an exchange deadline.

Session subscription interest also propagates through gossip. The key encoding is private to the cluster layer: whenever the live-node state watcher changes, each node rebuilds an immutable index from domain and relay to the interested node incarnations and advertisement versions and publishes it through ArcSwap. A relay owner loads that snapshot and performs borrowed domain and relay lookups, so per-batch remote fan-out neither formats a gossip key nor waits on the gossip mutex. Subscription creation waits for the exact subscriber incarnation and at least the current interest key’s gossip version to appear in every live node’s published index before it reports success. The creating subscription holds its lease while capturing that version and waiting for visibility. After withdrawal and reopening, an advertisement from before the withdrawal cannot satisfy this handshake, even when the subscriber node has not restarted. A withdrawal disappears from fan-out when the next gossip state snapshot is published.

A node advertises interest in a relay exactly while at least one of its session subscriptions holds a lease on it. Every subscription takes one lease before it attaches and releases it exactly once, when it is withdrawn, abandoned before it was announced, or ended by its relay, so any number of subscriptions from any number of sessions share one advertisement and the last release withdraws it. Each write of the advertisement reads the lease count while it holds the gossip lock that orders the writes, so the last write always matches the count: a release that finishes late cannot withdraw the interest of a subscription that attached after it. The count per relay is exported as nervix_session_subscriptions. Row Subscriptions defines what the subscriber’s node does with the batches this fan-out delivers.

Consensus separates traffic according to the progress it protects:

  • heartbeats, pre-votes, votes, leadership notifications, linearizable runtime-admission reads, and other small control exchanges use management capacity
  • each leader-to-follower log uses one ordered duplex stream on the replication pool
  • runtime-state descriptions, catalog listings, acknowledgements and ownership handoff control use the remaining replication capacity; checkpoint bodies use the bulk pool’s Snapshot subquota
  • consensus snapshots use the snapshot reservation on the bulk pool

The ordered append stream can keep multiple batches in flight while preserving follower order. Its consensus-level window bounds a follower to 16 outstanding batches and 16 MiB of unacknowledged log data. Heartbeats and elections remain on management capacity, so a full append window does not block leadership traffic.

The consensus adapter obtains its first append request before opening the duplex transport. This lets the caller’s retry and cancellation gate pace a setup attempt even when the transport refuses it immediately because its target left or its quota is full. The transport still reports that typed failure without a retry loop of its own. Ended input opens no connection. Leading empty requests use the management heartbeat operation, including a finite probe that ends before data is ready; its answer preserves the previous-log match, conflict or higher vote. The first nonempty request opens replication and is submitted once after the follower accepts the stream headers. Empty commit updates following that data remain ordered on the same duplex stream. The five-second setup bound starts after the caller gate; the management probe retains its heartbeat-derived deadline.

Elections begin with a pre-vote exchange. A voter asks whether a quorum would accept its next term before it persists that term or becomes a candidate. A restarted voter whose log is stale, or whose discovery connections are not ready yet, therefore cannot advance the term and tear down the healthy leader’s replication streams. Pre-vote requests use their own typed management operation and apply the same authenticated-origin check as vote requests.

A follower bounds the same stream from its own side. It keeps at most four decoded batches resident at once, each charged to the commands and replication budget from the moment it is decoded until its Raft core answers it, which happens only once the batch has been appended durably. A follower that reaches the bound stops reading frames, so the leader’s flow-control window closes and it stops sending rather than growing the follower’s memory. Decoded batches belonging to a stream the leader has already torn down are released with that stream instead of staying queued behind it.

The append stream opens under its five-second setup deadline. A follower answers a batch only after appending it durably, so the leader does not time out individual answers. While a batch is outstanding, it ends the stream only after five seconds in which no answer arrived and the follower’s flow control accepted none of the leader’s bytes. That bound is independent of the heartbeat interval, which still sets the deadline of each heartbeat.

A process restart does not admit ownership-sensitive runtime execution from its recovered local state. The restarting node sends raft_runtime_admission_read to the leader through the reserved management admission capacity. The leader performs a strict Raft ReadIndex, which confirms its authority with a quorum and returns the inclusive committed log boundary for the read. A follower then waits until its own state machine has applied that exact log ID before it reads the coherent domain, clock-authority, and schedule state used to install runtime execution. The application discards any runtime-state snapshot taken before this proof.

Admission retries on transport, leadership, and quorum failures without depending on a domain or schedule notification. Consensus, discovery, administration, shutdown, the interconnect listener, and configured application listeners continue running during the wait. Runtime routes remain absent, so listening connectors cannot hand payloads to recovered graph execution. The proof is process-start admission only; connectivity loss after admission does not revoke execution.

Resource archives and runtime-state snapshots use streamed bulk responses with an exact declared length. Consensus snapshots use bounded begin, chunk, and finish operations on the same isolated bulk class. Receivers stage and validate the owning artifact while releasing HTTP/2 credit chunk by chunk. The whole transfer may exceed the 32 MiB bulk-memory budget because only bounded chunks and the active decoded section are resident at once. Resource Versions And Bindings defines when a node fetches a resource archive and how it verifies and records the fetched version.

Backup state capture uses typed drain, capture, inventory, and fetch operations. The leader reads each node’s admitted work through a management-class drain request and requests a separate confirming force-flush round after all nodes appear quiet. The leader sends a management-class capture request with its coordination identity and applied cut revision to each live node, then reads each node’s management-class inventory of staged sections. A capture request’s deadline is the remaining budget of the domain’s cut rather than a fixed request deadline, because an owner stages its share of the domain’s state before it answers; a WITHOUT PAUSE capture takes the default cut budget. A receiver waits up to five seconds for its state machine to apply that revision, installs its current runtime plan, and rechecks the sending leader before it captures. A closed applied-state authority or a catch-up deadline refuses the capture; a follower that is still applying the cut does not produce an archive from earlier state. A captured section is fetched over a snapshot-subquota bulk response: the inventory declares its path, length, content kind and digest, and the leader stages and verifies the stream before adding it to the archive. The owner keeps a staged section only for the coordinator process that requested it and releases expired stages. The fetch stream authenticates that process identity before its handler can consume the stage; a partitioned or cancelled fetch leaves any unconsumed stage available until expiry. An owner streams each branch lifecycle and Kafka offset section from the cut’s database snapshot into its staged file under a restore_metadata conversion charge and a 64 KiB bulk buffer, so the inventory declares a section’s exact length and digest without the owner holding the section whole. It also checks a WASM save’s scheduled generation before opening that save, then streams its raw bytes from the same snapshot into a staged section under an independent restore_metadata charge and three fixed 64 KiB bulk buffers. The stored read supplies the descriptor’s revision. An interruption fails the capture before its inventory is published, and the coordinator can retry with a new cut. Materialized inventories distinguish archive descriptors, scalar identity groups and Arrow column groups. Their bounded sections are staged from a fresh assignment-qualified capture and fetched through the same authenticated stream; the capture never transfers an entire container as one bulk response allocation. The leader verifies each length and digest before assembly. An opening refused solely for request capacity is retried within one 30-second opening deadline; other failures end the fetch. The captured stage remains unconsumed on an admission refusal. Completed backup and materialized-state response streams release their Snapshot request and connection-stream permits before local file verification or Arrow decoding.

The coordinator streams native lifecycle and Kafka metadata into quota-owned files before installation. It retains the verified description’s separate restore_metadata preparation charge through conversion, while buffered file I/O reserves 2 MiB of bulk memory. Complete encoded records are never copied into a local request or retained for remote transmission. Native checkpoints and guest saves share the file source and bounded chunk path. See Backup And Restore for preparation admission and its limits.

Restore state installation uses the snapshot bulk subquota after the stopped-domain schedule is published. The leader admits a replicated installation authority carrying its identity and term, the restore execution, mutation lease revision and installation generation. Every request carries that authority. A receiver waits for its generation to apply and authenticates the sending leader. A begin request declares placement, length and digest; chunks are ordered and at most 64 KiB; finish verifies the staged file, then reads it directly inside a filesystem storage job that reserves 2 MiB and stages bounded checkpoint chunks into an invisible installation namespace. It retains the upload’s disk-quota owner through that job. There is no full checkpoint buffer or nested staged-reader reservation. Incomplete transfers expire under the node’s staging quota. Materialized restoration first converts archive-owned identities and exact-schema Arrow sections to a staged native sealed file. That file uses these same begin/chunk/finish requests, preserving its revision and branch generation and establishing its ownership fence in the new cluster. For a stopped domain, owner capture selects materialized checkpoint readers from the same database snapshot as the other captured state. Each reader retains its selected namespace and chunks until bounded identity conversion and raw Arrow staging finish. Running and paused domains use fresh generation capture under their assignment barriers. The capture inventory carries both through the existing materialized section kinds and Snapshot stream contract. Stored capture never activates the domain.

Large materialized containers never use the encoded metadata install request.

After all checkpoints are staged, the leader sends each target node a publish request with the complete checkpoint and byte counts. The receiver reserves a fixed 2 MiB, validates receipts, headers and chunk digests one checkpoint at a time, synchronizes the generation data, and commits and synchronizes one active-generation pointer. It then deletes obsolete keys in bounded batches. The inventory size and total payload bytes do not determine the publication reservation. An empty inventory clears unassigned nodes and implements configuration-only restoration. Local and remote mutations revalidate the exact authority under the applied-state read guard, held through the storage mutation and clearing of runtime handles. Publication of a new authority or release of the replicated start gate requires the corresponding write guard, so a delayed coordinator cannot mutate after its successor completes installation. The store also retains the published authority and inventory to reject lower or competing generations. An exact retry must carry the same counts and repeats durability and cleanup before acknowledging completion. A failure after pointer commit can leave that complete generation selected with the start gate closed; it cannot authorize START or clear handles before durable completion. The domain’s replicated start gate is released only after all nodes acknowledge publication. RESUME activates its archived lifecycle in that same completion effect; delayed coordinators are refused once the domain is running. The transfer authority remains independent of the archived domain start generation and the relay checkpoint revision. The seeded transport checks stream six 6 MiB sections, above the default 32 MiB bulk budget, through capture fetch and ordered state installation after link repair. A second check restarts the receiving process after the first 64 KiB install chunk: its replacement refuses an incomplete finish and publishes only after a complete new 36 MiB transfer. The handlers retain counts and digests; these checks qualify the authenticated transport, while the public restore scenarios and storage checks qualify native containers, atomic publication and activation. Their 300-second simulated completion deadline follows two intentional request deadlines and allows the 64 KiB fetch and install chunks to traverse a seeded link at its 100 ms maximum latency. The enclosing 320-second simulated horizon, 400,000-step cap and 90-second wall cap still fail a transfer that does not complete.

The registered backup-capture-wire property checks complete drain, capture, inventory and fetch messages through the production payload codec under each request’s declared class limit. It covers every drain action, all ten counters at zero, their full-width maximum and independent generated values, all twelve inventory section kinds, ordered and empty inventories, arbitrary digests, bounded Unicode paths, and capture/drain/inventory success and refusal responses. The fetch response remains an opaque byte stream. backup-capture-wire-malformed separately checks typed refusal or complete stable re-encoding of damaged and arbitrary message bytes, and release of management and bulk reservations. restore-installation-wire covers every installation action and response, with all eight runtime state kinds branched and unbranched. The server’s backup-section-mapping property checks the total, injective content mapping and distinct validated archive paths. These properties supply wire and mapping evidence; the transport simulations and public scenarios retain the authentication, fencing and recovery evidence described above. See Property Testing And Fuzzing for their domains and commands.

Capture fetch and state installation share the peer connection’s one reserved snapshot stream slot. The archive staging phase finishes and releases its fetch stream before installation starts; an installer cannot retain a fetch stream while awaiting another snapshot request to that peer. Staging and publication run on the admitted filesystem worker class. Authority is checked inside the storage job after admission, so waiting for a worker cannot preserve an expired installation right. Durable synchronization does not run on the async reactor.

A runtime-state placement names exactly the state it addresses: the domain, entity, state kind, and concrete branch; for every kind of state except branch-aggregated metrics and Kafka domain offsets, the fingerprint of the schemas the state is laid out by; and for WASM processor guest state the generation the committed schedule names for that branch. Branch-aggregated metrics and Kafka domain offsets depend on no schema, so their placements carry no fingerprint and stay current across every schema change of their entity. A backup archive separately records the Kafka ingestor’s schema fingerprint and checks it against the restore target before installing offsets. A checkpoint carries no identity of its own: a synchronization reply, a handoff checkpoint, and a forced-recovery preparation each carry it beside the placement that names it. A node answers a synchronization request, and acts on a checkpoint announcement or a handoff checkpoint, only while the placement is current on that node, so an owner never serves, and a replica never installs, state written under a replaced schema fingerprint or guest state of a generation that has been replaced. A node that has not applied a schedule naming the entity has no fingerprint to place its schema-bound state under, and refuses to place it rather than address the state under an assumed identity. A replication acknowledgement counts only toward the placement it names, so an acknowledgement for a replaced generation never satisfies the replica quorum of the current one. The schedule fingerprint an ownership handoff or forced recovery is bound to covers those schema fingerprints and generations, so a preparation staged against an earlier schema or generation cannot activate after a later one is committed. The control plane computes the exact fingerprint of the committed schedule before runtime installation and carries it in the complete typed execution revision used for activation and reconciliation; a node does not reconstruct it from a second schedule view. WASM handoff and forced-recovery preparation on a passive revision validate and retain the complete checkpoint inventory without running guest callbacks or reading the stopped domain clock. The guest validates its saved bytes when START installs the active revision, so stopped time is not classified as missing checkpoint state. Window state also binds to its current window model. A model replacement with unchanged schemas therefore addresses a different checkpoint and cannot install rows accumulated under the preceding window definition.

The same rule fences a coordinated reset. Once its Publishing schedule is committed, every runtime-state request for the replaced WASM generation is stale even while the new initial checkpoint is still being made durable. Replicas that were offline install the committed schedule before accepting state, then synchronize only the new placement. A reset does not delete old bytes through an unbounded cluster sweep; generation-addressed reads make them unreachable immediately, and the existing bounded state-store retention removes them locally.

The leader forwards coordinated reset requests with a typed reason, so a guest request, operator request, transaction effect, and rejected-snapshot recovery keep their provenance across nodes. The existing remote describe exchange returns typed checkpoint facts from the execution owner: generation, revisions, stage, and required and confirmed replica counts. The receiver combines them with its scheduled binding, reset, and recovery facts, accepting only checkpoints of the schedule’s current generation. This read does not request synchronization, take ownership, or change a checkpoint’s completion state. A stored checkpoint whose previous replica boundary is unknown is reported as such rather than treated as newly confirmed.

A replica acknowledges a branch-state checkpoint — WASM guest state, deduplicator and window state, and the branch lifecycle that names the branches — only after it has written the checkpoint to its own stable storage and synchronized it, never on receipt. A replica that already holds the announced revision, or a newer one, synchronizes and acknowledges what it holds again, so an acknowledgement lost in transit is replaced by the next announcement instead of stranding the owner. A node without stable storage acknowledges nothing. The owner of a WASM processor branch releases the source acknowledgements a guest checkpoint covers only once every replica the committed schedule assigns has acknowledged that checkpoint’s revision; a replica that is unreachable, lagging, or failing to install stops those acknowledgements from being released rather than letting them through, and the checkpoint fails after its ninety-second deadline. The owner announces a WASM processor’s new branch to its replicas as soon as the branch appears. Every catch-up round of a replica synchronizes the owner’s branch lifecycle before it installs any branch checkpoint, and the replica refuses a checkpoint of a branch that lifecycle does not name, as for a branch the owner has evicted. WASM State And Recovery defines the checkpoint these acknowledgements complete.

A replica catches the branch-keyed entities it replicates up in rounds, one replica task for each entity, through two replication-class request kinds and, when a checkpoint advanced, a bulk stream. A state synchronization request names one placement and the revision the replica holds of it. The owner answers through the actual state handle published when that placement was installed: with the newer checkpoint’s revision, length and BLAKE3 digest, or with nothing when it is current. The owner reads storage only when it holds no live state for the placement.

Branch-aggregate metrics retain the exact encoded bytes first selected for each dirty revision; elapsed times are captured once. Describing, streaming and persisting that revision therefore agree byte for byte, and restoration retains the incoming bytes until the next metric update. The replica opens a Snapshot-subquota bulk stream for the described revision; the owner refuses a revision it no longer holds. Chunks are at most the configured bulk chunk size (64 KiB by default). Each side admits the whole captured or received allocation against restore_metadata, up to a 256 MiB checkpoint transfer bound. The replica checks the stream’s declared length, received length and digest before the checkpoint can reach its assignment-fenced installation and stable-storage acknowledgement. Debug events name the placement, revision and declared length when streaming and after verification, without logging checkpoint bytes. A branch checkpoint listing request names the entity’s branch lifecycle placement and the cursor the replica’s previous listing returned. The owner answers with the next changes of its catalog of the entity’s branch checkpoints, in the order they happened and at most 256 of them: each branch whose checkpoint changed, with the state that checkpoint belongs to and its newest revision, and each branch whose state went away, followed by the cursor after them and whether more changes follow. The listing restarts from the catalog’s beginning when the replica has no cursor, when its cursor belongs to another catalog, such as one a replaced or restarted owner kept, and when it is older than the oldest removal the catalog kept. An owner that holds no branch state of the entity answers that it holds none. Both requests are answered only while the answering node is assigned the placement. Admission reads the retained entity assignment from the immutable routing publication; state and catalog selection never reads the execution, identity or replicated-state registries. Route withdrawal ends the exact state handle before replacement, while an already admitted request may finish on the state it borrowed. A round synchronizes the lifecycle, reads the catalog’s changes, and requests only the checkpoints of the branches that changed or were announced, so a round in which no branch changed sends two requests however many branches the entity has.

Checkpoint descriptions and catalog listings each use their operation’s five-second deadline, including admission, connection capacity, the answer and decoding. A selected checkpoint’s bulk stream has a sixty-second progress deadline. The one-second replication poll interval schedules the next idle round; it does not shorten an in-flight request’s deadline. A branch-aggregated replica uses the same checkpoint operation deadline. Polls and announcements retry a failed exchange, while the owner’s checkpoint completion deadline remains independent and may fail if a replica cannot confirm in time. Replica request diagnostics retain the typed transport cause beneath the target and placement context, so a timeout can be distinguished from capacity, connection and framing failures without inspecting checkpoint bytes.

The owner of a placement offers its newest checkpoint to the replicas the committed schedule assigns, and repeats the offer every 100 milliseconds to each replica that has not acknowledged that revision, until every one of them has, the node stops being the placement’s primary, the placement’s state goes away, or the node stops. A newer checkpoint taken meanwhile raises the revision on offer instead of starting a second offer. Each announcer retains the installed route and reads its primary and replica set from the same assignment slot used by checkpoint execution. Removal, a replaced identity or loss of primary ownership ends its next step. Terminal runtime teardown cancels a pending announcement dispatch or retry wait after domain drain, then joins the announcers before withdrawing their routes. These messages are availability hints; the replica’s periodic synchronization supplies a hint cancelled during shutdown. The owner records the highest revision each replica acknowledged, so an acknowledgement delivered after a newer one never lowers it, and a Kafka offset commit waiting for its replica quorum, like a WASM checkpoint waiting for its replicas, completes on the acknowledgement that satisfies it rather than at its deadline. Only a node that holds a placement’s state announces it: an ownership handoff announces the final checkpoints it captured from live state, and the placements it fills from its storage or with an empty checkpoint reach replicas through their own synchronization. A replica acts on an announcement by waking the task that keeps its copy of that placement current, which keeps the announcement until it next waits when it is busy; an announcement of a placement the replica holds no state for wakes nothing. The announcements of a branch-keyed entity’s lifecycle and branch checkpoints wake the entity’s one replica task, which takes the newest announcement of each at the start of its next round and fetches or acknowledges each announced revision then. The task also runs a round every replication poll interval, so a checkpoint whose announcement was lost reaches the replica within one interval.

The owner publishes an empty final window checkpoint when it evicts a concrete window branch. A replica that installs that revision replaces the evicted branch’s rows and sketch panes with the empty state. The branch lifecycle checkpoint records an incarnation for each concrete branch; restoring a window checkpoint with a different incarnation starts an empty window. Reusing a branch key after eviction therefore cannot attach a prior lifetime’s retained rows, even when the earlier checkpoint remains on a replica. Window checkpoints carry a sealed container with separate bounded Arrow sections for retained input and aggregate arguments, plus bounded typed sections for delayed histogram removals. Its revision, row count, and branch incarnation are checked across the sections before restoration; the ownership handoff and replica installation fences still govern whether the checkpoint can be installed. A section that exceeds its bulk limit or disagrees with the container fails to open. Sealing also refuses a container that cannot fit the available bulk memory reservation, instead of waiting for a reservation larger than that budget.

Materialized relay snapshots seal to quota-owned files one bounded identity/Arrow group at a time. Descriptions retain the file’s exact length, digest and revision; fetch responses open an independent reader and release each bounded chunk before the next. The sealed cache retains no whole-container byte allocation. Local periodic persistence writes the same container as 64 KiB segments, synchronizes data before replacing the header, and retains the namespace selected before executor admission. Neither path requires a reservation proportional to container length. The synchronous ownership-handoff metadata boundary still admits its resident checkpoint entry against the bulk budget; exceeding that admission is a typed checkpoint refusal.

Kafka offset replica catch-up first asks describe_kafka_offsets on the Commands pool whether the owner’s revision advanced. Its typed answer distinguishes an unchanged revision, a newer revision, and the shared remote-operation refusal classes. An unchanged checkpoint is neither encoded nor transferred. A newer checkpoint uses sync_kafka_offsets on Bulk with the Snapshot subquota and a thirty-second progress deadline, independently of the replica polling cadence. The owner retains the admitted offset topology and reads its conservative revision before the offset slots, encodes the current native checkpoint directly into a quota-owned staging file, and sends a forty-byte revision/digest header followed by chunks of at most 64 KiB. The declared response length bounds the complete native payload. Encoding scratch and native conversion use the separate restore_metadata admission; bounded file I/O and transport use Bulk. Neither the replication message limit nor the bulk memory ceiling bounds the whole checkpoint’s length. The receiver stages bounded chunks, releases the response stream at EOF, verifies exact length and digest, and decodes current archived entries directly into a new table under admitted CPU work. Cancellation is checked between file blocks and native entries. Only complete conversion reaches the replica’s assignment-token installation barrier; a promoted or replaced assignment rejects a delayed transfer. The replica acknowledges its installed revision afterward. Kafka commit and reset waits use the native bulk operation’s thirty-second budget. The owner therefore accepts a replica that completes this checkpoint after a small Commands response budget; an absent acknowledgement still ends the commit at the operation deadline. Transfer and admission failures preserve their typed causes in the replica diagnostic and retry on a later round. Every successful poll also repeats the replica’s held revision when the checkpoint is unchanged, so a lost acknowledgement does not strand the owner’s quorum wait or require another transfer. The replica checks its installation assignment before reporting that revision; promotion or replacement fences a retained poll’s acknowledgement as well as its delayed installation. Debug checkpoint events identify encoding, receive and installation boundaries by placement and revision, with declared byte length once known. Their timestamps distinguish encoding, transfer and conversion delays without logging checkpoint payloads or partition offsets.

Ownership handoff capture returns the same checkpoint descriptions as a bounded control response. The destination fetches each exact revision from the source over the Snapshot bulk subquota before validating its state and publishing the prepared handoff. A failed, truncated, superseded or digest-mismatched fetch fails preparation without installing a partial checkpoint. The source serves a handoff fetch from the exact checkpoint capture persisted in its state store, or from the retained committed WASM checkpoint. An ordinary replica fetch selects the published state handle. A committed WASM save is already on stable storage before capture, even when a newer published save is waiting for replicas. Capture describes and releases each WASM branch save before reading the next branch, so its control inventory retains descriptors rather than every guest save allocation. Other state kinds complete their existing persistence steps before description. No checkpoint body enters a replication-class message. Preparation retains its verified checkpoint set until activation, so the destination’s total held bytes still grow with the number and size of the entity’s branches.

Runtime-state synchronization replies and materialized-snapshot descriptions carry the shared typed remote-operation failure envelope. Rejection, absence, temporary unreadiness, and execution failure remain distinct across the node boundary, and the requester keeps that classification in its local replication or snapshot-exchange error. Only an execution failure includes the serving node’s opaque diagnostic text. A materialized snapshot is streamed only after a successful typed description identifies its exact length, digest, revision, fence, and branch generation; the placement that the request names supplies its schema fingerprint.

Runtime-state message properties use the production bounded encoder and decoder on each request’s Commands, Replication or Bulk class. The runtime-state-replication-wire target checks complete request and response values, including every state kind, schema and WASM generation, model kind, checkpoint read kind, ordered listing, full-width counters, checkpoint length and digest, and each remote-operation refusal and subject. Branch floats are compared by their bits, including signed zero and nested collection values. runtime-checkpoint-listing-owner additionally checks the complete runtime owner listing across its semantic conversion and the replication response codec. Its current branch-key domain is nonempty, has distinct canonical field names, valid datetime text and finite floats; datetime spelling is canonicalized by the runtime owner. The separate runtime-state-replication-malformed and runtime-checkpoint-listing-malformed targets exercise bounded damaged frames, encoding limits and typed invalid-branch refusals in removed and revised entries. Each property checks that no codec reservation survives its case. These are message and conversion checks; transport framing, scheduling and recovery retain their separate simulation and public-scenario evidence. Run each named target with just test-bolero <target> for randomized/corpus checks and just fuzz <target> <seconds> for an actual sanitizer campaign through the same assertion.

RkyvMessage, the public supertrait and response bound of the typed request contracts, is nameable at the interconnect crate boundary. Its existing encode/decode methods use the owning executor, pool class and byte limit; semantic conversion callers use that same codec rather than recreating serialization or validation outside the engine. This introduces no separate codec, message shape or execution mode.

Domain routing retains each materialized relay’s installed-state publication from the shared state-replication routing owner. Branch reads and generator scans use that relay’s immutable index, while the selected route validates its retained assignment identity. Retirement ends the exact state intake before removing its member; an ending predecessor cannot withdraw its replacement. Materialized installation refuses a lower revision, fence or branch generation before changing live rows. Transport decoding and the snapshot container remain owned by their existing engines; this publication introduces no wire or stored-container variant.

A materialized dependency reader may observe the committed destination just before that node activates its prepared state, or the previous destination just after it leaves the assignment. A rejected, absent, or not-ready snapshot description in this handoff window means the dependency has no available record for that read. The dependency policy then waits, skips, or supplies its declared default; REQUIRED WAIT retains the batch and retries after routing, state, or bounded poll progress. Execution failures and transport failures remain errors.

The Control Plane defines when replicated changes and resources become authoritative. The Data Plane defines how a local execution consumes transferred state. The interconnect supplies bounded delivery between those owners and does not reinterpret their state.

A client may open a producer for a client ingestor through any live node. When the serving node does not execute the ingestor, it forwards the producer to the node that does over one client_producer_link: an ordered duplex stream on the relay pool, admitted through the shared relay subquota, with a five-second setup deadline. A serving node keeps at most one link to each owning node, opened by the first producer that needs it and shared by every producer it forwards there, so forwarding holds one stream per peer however many producers use it. Two opens racing for one owner start one link.

The opening frame names the serving node, and the owning node refuses a link whose named node is not the peer it authenticated. Each forwarded producer then has a key the serving node assigns and never reuses within its process, so a late frame about an ended producer can never reach a later one. The serving node sends Open with the domain, ingestor, expected fields, credit, and the largest batch one submission may carry, then the producer’s Submit frames carrying the Arrow IPC bytes of each batch and its Clear frames, and finally Close or Detach. The owning node answers with Opened and the producer’s description or Refused with its refusal, then the producer’s Admitting, Outcome and Admission frames, and finally Ended with a reason or Closed. Frames of one producer keep their order in both directions, so its open precedes its batches and its outcomes, admission changes, and end follow the answer to its open. A batch travels at most once over the link, and the frames are validated and charged to the relay memory class like every other relay-pool operation. A frame is bounded by the relay pool’s payload limit of 32 MiB, which the largest batch a session admits, 4 MiB less its submission envelope, fits with room to spare.

Two registered properties hold the link’s frames to the stream’s codec. client-producer-link-frames generates the opening message, every frame of the serving node and every answer of the owning node, and requires each to reach the other end with every field it was sent with. client-producer-link-frames-malformed damages those encodings, reads a frame as another direction’s type and reads arbitrary bytes, and requires the codec’s typed decode failure or a frame the codec keeps whole. Neither leaves relay memory charged.

The owning node queues a forwarded batch like any other, but admits it only once the serving node has cleared it. When the batch’s turn in the ingestor’s window comes, the owning node takes a slot of the window for it and sends Admitting naming the batch. The serving node records that the batch may now be admitted, answers Clear, and from then on counts the batch as possibly admitted; only on that Clear does the owning node hand the batch to its admission worker. The serving node clears only a batch it forwarded and holds no outcome for, and it answers in the order it was asked, so clearances return in the order the owning node requested them. Admitting a forwarded batch therefore costs one more round trip of the link. A batch awaiting its clearance holds its slot of the window, so a serving node that stops answering holds at most the slots it was asked to clear, until the silence limit ends its link.

The serving node waits at most 20 seconds for the owning node to answer a forwarded open, including opening the link. Both ends send a heartbeat after two seconds without other frames and treat ten seconds without hearing anything as a lost link; the owning node skips a heartbeat rather than queue it behind 64 unsent answers. When a link ends for any reason, the serving node refuses the opens it has not heard back about as EndpointUnavailable and ends every producer the link carried as OwnerLost. Before that end, it answers each batch of the producer that it never cleared — whether it sent the batch or the link ended before it could — as NotAdmitted with ProducerEnded: the owning node admits nothing without a clearance, so none of those batches entered the graph. Only the batches it cleared become OutcomeUnknown with cause OwnerLost. A crashed owning node closes its connections and ends the link at once; one that stops answering without closing them ends it when the silence limit passes. The owning node detaches the link’s producers: their admitted batches continue through the graph with nobody left to answer them, and the batches it queued or was clearing are dropped unadmitted, releasing their slots of the window. A later producer opens a new link.

Each end reserves the producer’s granted bytes in its own 128 MiB producer budget: the serving node for the batches its session holds, and the owning node again for the batches it retains for another node. The link adds no reservation of its own beyond the transport’s per-frame charge. Client Session Protocol describes what the client sees.

Client Consumer Streams

When a session opens a consumer of a TO CLIENT emitter executing on another node, the serving node opens one client_consumer_stream duplex stream for that attachment on the relay pool and shared subquota. The opening request names the authenticated serving node, domain, emitter, exact schema fields and granted credit. The owner checks the peer identity, reserves the grant in its own 128 MiB consumer budget and attaches to its local emitter endpoint before replying Opened with the endpoint description or Refused with a typed cause. The serving node has already reserved the same grant against its session and node budgets.

The owner sends each attempt’s identity, fresh ACK reference, source relay, opaque branch fingerprint, member count, execution snapshot and total byte count before its Arrow IPC bytes. It splits those bytes into ordered chunks of at most 1 MiB, each charged by the relay pool and well within its 32 MiB payload limit. The serving node checks the announced length against the grant and reassembles one delivery before answering a client read. It sends Settle frames with a stream-local correlation key and receives one typed result for each; an independent writer keeps settlement and heartbeat sends from blocking response reads. Both ends send two-second heartbeats and end the attachment after ten seconds of peer silence. A lost stream detaches the owner’s consumer, revokes its attempts and allows the retained batches to be reassigned. Output and ACK state stay volatile, so an owner loss can require upstream replay. Client Session Protocol owns what clients observe.

client-consumer-stream-frames and client-consumer-stream-frames-malformed hold the stream’s frames to its codec as the two link properties hold a link’s: every opening message, serving-node frame and owning-node answer reaches the other end whole, a chunk of the largest size included, and damaged bytes fail with the typed decode failure or decode to a frame the codec keeps whole. They check each frame alone. Putting a delivery’s chunks back together stays with the serving loop, and no property splits a delivery at arbitrary bytes. client-emitter-deliveries holds the Arrow IPC stream those chunks carry, from the sink that writes it to the client library that decodes it.

Domain Clock Progress

A paced domain’s mapping, generation, and authority fence are committed control-plane state. The interconnect carries only replaceable progress from that committed authority. It uses a typed management request in the reserved progress subquota, rather than an unclassified cluster event. The response confirms that the authenticated receiver evaluated the report against its current fence; it does not establish or replace the receiver’s committed clock mapping.

For each domain and installed remote node, the authority owns one delivery loop with at most one request in flight and one latest pending report. A newer logical frontier replaces the pending report while the loop waits for capacity or a response, so a fast clock or slow peer cannot create an unbounded tick queue. The node-wide progress quota bounds attempts across every domain and peer. Liveness, relay admission, cancellation, and terminal outcomes retain their independent capacity.

Only live peers that advertise installation of the domain clocks of the required revision receive progress. A node installs a reassigned authority fence ahead of its graph installation and advertises the revision through discovery once every domain clock it holds matches it, so a receiver whose graph preparation waits on a departed node still receives progress. A node that joins or reconnects receives the newest retained frontier once its advertisement reaches that revision. Each attempt has a two-second physical deadline. A failed attempt waits 200 milliseconds, then retries the newest frontier; authority shutdown cancels both an active request and its retry delay.

The wire report contains typed logical and UTC timestamps and no process-local monotonic instant. The receiver authenticates the reporting node and applies the committed generation, revision, authority, and fence checks before retaining the frontier. A stale, duplicate, reordered, or superseded report cannot replace the mapping or move logical time backward. See Domains And Time for clock semantics outside the transport boundary.

HTTPS Listener Installation

Every node’s HTTPS listener presents the TLS VHOST certificates of the runtime revision that node applied, and it installs them before it reports that revision prepared. A command that creates, changes, or drops a VHOST confirms the installation with the typed management request https_listener_installation, which uses the reserved progress subquota and a two-second deadline. The leader answers for itself in process and asks every other live process incarnation for its latest installation at or after the command’s runtime revision. The answer names the answering incarnation and reports the installed revision, a failure with that node’s own description, or that the revision is still pending.

An answer from another incarnation, a transport failure, and a pending answer all leave that incarnation pending, and it is asked again every 250 milliseconds until the command’s completion deadline. A failed installation ends the wait at once, so the command reports the failing node without waiting for the deadline. The request reads installation state and changes nothing, so a repeated or late request is harmless. The DYNAMIC TLS Refresh defines what every listener presents and how a failed installation fails or rolls back the command.

Application Health And Availability

An established HTTP/2 connection and a successful transport PING show that bytes can move; they do not show that the peer application can accept work. Nervix therefore probes application health through a typed management request with reserved liveness capacity.

A peer becomes a health target and an outbound target once discovery publishes its interconnect endpoint. A target with no usable endpoint is neither probed nor dialled, and its availability stays unknown until discovery publishes one. A previously established target remains eligible for probes and outbound connections through a Chitchat liveness loss while application health still has an observation of it from the node-unavailability interval, as described below. A different incarnation or advertised endpoint must establish a new target.

Each health round has at most one probe in flight for each peer and at most 32 probes across the node. A probe has a one-second total deadline. Results are published as they complete, so a silent peer occupies only its own concurrency slot. The next regular round begins roughly one second after the previous round finishes. Gossip updates remain pending while those bounded probes finish and start the next round immediately afterward. They never cancel an in-flight round: repeated gossip updates must not prevent an unreachable peer’s deadline from becoming a failed observation.

Every result is bound to the exact certificate node identifier, discovery incarnation, endpoint generation, advertised address, and observation time that were targeted. A healthy response must return the same application identity. If discovery replaces the incarnation or endpoint while a probe is running, its late result is ignored.

Health observations distinguish:

  • Healthy: the current target returned the expected application identity within the deadline.
  • Failure: the current target returned an error or did not answer within the deadline, including a target whose advertised host does not resolve.
  • Capacity exhausted: the probe could not obtain its reserved local capacity.

A missing, stale, or capacity-exhausted observation produces unknown availability. It does not mark a peer unavailable and does not extend a previous run of failures. Only continuous, fresh failures for the configured node-unavailability interval produce unavailable status; a healthy observation resets that run. Consensus membership continues to use the cluster topology established by gossip.

Scheduling and runtime availability retain a previously discovered incarnation that gossip stops listing live only while its latest application observation is younger than the node-unavailability interval and has not made it unavailable. A healthy, briefly failing, or capacity-refused observation from that interval keeps the peer available. A peer with no completed observation in the interval falls back to its gossip liveness, even though no failure was recorded. A stopped peer therefore leaves scheduling and runtime availability no later than when gossip declares it dead and its last observation has aged out, whether or not any probe to it completes.

During startup, Chitchat’s dead set can also contain a voter whose first heartbeat arrived through another peer’s digest: the failure detector has insufficient heartbeat intervals to establish liveness. Automatic scheduling uses a separate process-local live-observation history for its first ten seconds, including observations made before acquiring leadership. A voter that has never been observed live keeps scheduling in that bounded wait even when Chitchat lists it dead. Once all current voters have been observed or the grace expires, ordinary effective availability governs automatic failover. See Whole-Cluster Restart Keeps Ownership.

Command completion reads the leader’s effective availability view through the application_completion_peers management progress request. The response names the leader’s incarnation, Raft term, and required process incarnations. A follower uses it only while its own Raft leader and term still match, and includes its own incarnation in the barrier. Failure to reach the leader leaves the command pending. This avoids conflicting completion sets when application health is asymmetric: a connected follower may still probe an unreachable peer successfully after the leader has retired that peer. Revision and HTTPS listener progress requests remain bound to the reported incarnation.

SHOW CLUSTER STATUS exposes the interconnect address, endpoint generation, observation age, observation outcome, and derived availability. Its connected status means the latest application probe is healthy, rather than merely that a transport pool exists.

Connection And Credential Lifecycle

A preconnected pool slot that fails reconnects with exponential backoff beginning at 200 milliseconds and capped at five seconds. A peer removal, incarnation change, or advertised endpoint change retires the old target and cancels work tied to its slots. New operations use only the new target generation. A new DNS answer for the same advertised endpoint is not a target change: it leaves established connections in place and is used by the next connection attempt.

Each established HTTP/2 connection sends a protocol ping every 15 seconds and waits at most ten seconds for its acknowledgement. The outbound and inbound ends both close a session that cannot answer, independently of the operating system’s TCP retransmission timeout. Closing the outbound end starts its pool slot’s bounded reconnect; closing the inbound end releases the per-peer class slot so that reconnect can be accepted. Ordinary request deadlines still apply to individual operations during the detection window.

Packet loss on a link that stays up is left to TCP. The host kernel retransmits lost packets under its own retransmission timeout, which grows exponentially while losses continue, and the interconnect does not tune it. An exchange over a lossy link therefore takes as long as its retransmissions, and one whose deadline passes first fails with a timeout that its owner may retry. Nervix sets no bound on how long such loss delays the traffic the link carries. A Quorum Over A Lossy Link describes what it means for commits.

Interconnect certificate, key, and CA files are watched as one credential bundle. A candidate must be complete, valid, and identical in two consecutive reads before it replaces the active bundle, so a multi-file update cannot install a mixed generation. An invalid or partially written candidate leaves the current credentials active while the watcher continues trying.

TLS loading identifies the CA certificate, node certificate, or node private key by kind and keeps PEM parsing failures as typed categories. Error reports and watcher logs omit credential file paths and malformed PEM input bytes.

After a valid replacement, new outbound pools use the new credentials and existing inbound HTTP/2 connections begin graceful shutdown. Certificate expiration is also mapped to a process-monotonic deadline when a connection is authenticated, so a connection cannot remain open beyond the validity of either peer certificate even if the wall clock later moves.

A replacement publishes the new bundle and its generation as one value, under the generation after the one it replaces, so two replacements that race still publish distinct generations. Each connection records the generation of exactly the credentials it authenticated with. An inbound connection compares that record with the published generation, without a lock, whenever it accepts a stream and whenever a replacement is announced, and begins graceful shutdown once the published generation has moved past it. The replacement publishes before it retires the outbound pool slots. An outbound connection set up from the replaced credentials checks the published generation once it is established: one that checks after the publication is refused, and one that checked before it is held by a slot the retirement then ends, so replaced credentials never outlive their replacement.

Application shutdown has three ordered phases. A server process issues the stop request that starts them when it receives its first SIGINT or SIGTERM, and one shutdown deadline measured from that request bounds all three. The stop request first marks the local process incarnation as terminating and then closes admission on its public gRPC, connector, observability, and console listeners, closing the client connections they had accepted. Its interconnect listener and registered handlers remain available on that live node throughout the drain-support phase. They continue carrying queued and active relay payloads, admission and record acknowledgements, runtime-state replication and checkpoints, ownership-handoff coordination, domain-clock progress, and the schedule revisions that activate committed ownership. Drain support first moves scheduled work to a live replacement node when one exists and then completes the work the node already admitted in place, so these consumers remain available until the node’s own graphs are quiescent.

The relay payload lane retains its transport admission guard for every queued or active payload. Sender-side runtime drain accounting retains the tracked root until admission and every requested record acknowledgement resolve; this includes roots created for sources in MODE NO ACK. Ownership handoff does not complete until its replication acknowledgement and committed activation have been observed. These owners keep admitted work visible while the supporting interconnect consumers are still running.

Only after drain support completes or reports abandonment does terminal teardown call transport shutdown. Transport shutdown rejects new interconnect admission, cancels pool and operation waiters, and retires every pool connection the node opened: each stops leasing and closes once its leased streams return. Connections that peers opened to the node close at once, together with the handlers still serving their streams, so a peer’s request the node has not answered fails instead of completing. Whatever remains after ten seconds is then closed. Connection setup, including the name resolution it performs, and incomplete TLS handshakes remain inside this bound. A repeated SIGINT or SIGTERM, or the shutdown deadline passing, ends the process without running the rest of its shutdown, so its peers observe its connections ending exactly as they do when the process crashes.

See Shutdown And Recovery for the complete phase contract, the deadline and exit statuses, and what each ending preserves.

Failure Ownership And Persistence

Transport failures identify name-resolution, setup, authentication, admission, encoding, decoding, flow-control, timeout, remote-response, and target-departure failures separately. Typed remote errors remain available to the operation owner, which decides whether a request is safe to retry. The interconnect does not infer idempotency for arbitrary control-plane or runtime operations. Local transport operations return error_stack reports. TLS and wire failures retain their typed causes beneath TransportError; typed requests and stream readers add RequestError context while retaining the transport report. Deadline, shutdown, quota, cancellation, relay-rejection, and indeterminate-delivery decisions inspect the current typed context, not formatted report text. Runtime dispatch, relay admission-response and remote acknowledgement logs render every context of those local reports. State synchronization, checkpoint announcement and replica catch-up logs also retain the underlying request, placement or storage cause when their owner adds context. When remote ACK registration cannot reserve correlation memory, the affected record’s negative acknowledgement carries the registration report chain, including its admission cause. An answering node sends its established remote failure class or stream rejection text over the wire; a local report’s cause chain is not serialized into an HTTP/2 response.

The exact source owners classify RemoteRequestFailure, RemoteOperationFailure, OwnershipHandoffFailure, gossip refusals and resource-publication answers as fixed wire outcomes for the compiler failure-contract gate. The responding operation retains its local contextual report before projection. Errors And Diagnostics owns that source contract, including the distinction between a wire outcome and an operation failure.

Connections, request state, relay grants, delivery reconciliation, progress trackers, and acknowledgement maps are never persisted. Durable control-plane state remains in consensus, and selected runtime state remains in its owning snapshot or replication mechanism. This boundary is why process epochs are part of relay delivery identities, why an unresolved result across a receiver restart is reported as indeterminate, and why an acknowledgement registration names the run of the node that registered it.

An ownership-handoff gate lease is also in-memory coordination state. Releasing or expiring that lease removes the runtime fence but does not report a persisted preparation as cleaned up. Only an exact durable discard, activation, or schedule-based reconciliation resolves that preparation.

Observability

Each node exports interconnect measurements through its local metrics endpoint. The main groups cover connection and stream occupancy, pending operations, setup failures and resets, quota exhaustion, request latency, relay channels and grants, admission wait, unresolved delivery age, and bulk-transfer bytes. Interconnect memory, worker queues, reactor delay, and consensus retention show whether pressure originates in transport, execution, or the protocol using it. A connection attempt that could not resolve its peer’s advertised host is counted with reason resolution, apart from setup, handshake, capacity, and closed failures, so a DNS outage is visible as itself.

Typed-request observations identify application health as operation liveness and replaceable domain-clock delivery and HTTPS listener installation probes as operation progress, so their request counts, outcomes, latency, and quota failures can be evaluated independently. A client producer link’s opening, failure, silence, and the producers it ends or detaches are logged at debug on both ends; the owning node’s client-ingestor metrics count forwarded producers apart from local ones.

Metric labels are bounded dimensions such as traffic class, direction, operation, outcome, and reason. They do not include peer, domain, relay, branch, delivery identity, or payload values. An owner-delivery admission failure logs its domain, relay, non-sensitive branch fingerprint and target, together with the transport report’s retained cancellation and rejection causes. The undelivered batch and branch field values stay out of that diagnostic. Per-batch and payload-bearing logs use debug or trace levels and do not expose sensitive field values. See Metrics And Observability for the metric and logging contract.

Name Resolution

Nervix turns host names into addresses when a node connects to its peers, when a connector opens a connection to an external system, and when a native client opens a session. Wherever Nervix decides how a host is resolved, it asks one resolver owned by the nervix-dns crate: Hickory’s asynchronous DNS client, configured from a resolv.conf-format file and a hosts file and held to the cache, time and concurrency bounds this chapter describes. A lookup through it never occupies Tokio’s blocking pool and never calls the C library resolver. Some external drivers resolve hosts inside code that accepts no resolver from its caller; they keep the operating system’s resolver, and Residual Resolution names every one of them.

This chapter owns the resolver itself: its ownership and lifetime, its configuration, the order in which it answers, its cache, its bounds and cancellation, the outcomes of a lookup, the hooks that hand it to client libraries, the dependency features that select it, the paths that resolve elsewhere, the simulation boundary, and the evidence behind each claim. The boundaries that resolve through it keep their own facts, and this chapter links to them rather than restating them:

Transport selection retains pool-slot handles in immutable target publications. A changed dial address for the same advertised endpoint preserves those lifetimes. Established leases retain the authenticated connection; only its reconnect worker reads the current dial policy and resolves the endpoint again. Publication changes do not add DNS lookups to established leasing or change the resolver, setup budget, address-attempt order, TLS name or HTTP authority.

Ownership

nervix-dns sits in the engines and infrastructure layer. It owns one resolver’s configuration reading, hosts-file snapshot, answer cache and TTL bounds, the bound on concurrent lookups, the typed outcome of every lookup, the physical connection budget that a lookup shares with the address attempts after it, and the hooks that hand the resolver to the client libraries a node connects with: both Reqwest versions, hyper-util’s HttpConnector, and Smithy’s HTTP client. It must not know Models, peers, connectors, graphs, or any protocol’s retry policy. A caller gives it a host, a port and a time budget, and receives addresses or a typed failure.

OwnerWhat it owns about name resolution
nervix-dnsThe resolver, its policy and bounds, its failures, its client-library hooks, and the connection budget and ordered address attempts
The server, as composition rootLoading the node’s resolver once at startup from the node’s DNS options, and handing clones to the runtime, the interconnect, and the node’s own trace exporter
nervix-interconnectWhen and where a peer endpoint is resolved, the TLS and HTTP/2 identity of the connection, and peer failure classification
nervix-connectorThe shared HTTP client configuration that installs the resolver on the Reqwest clients of HTTP polling, Prometheus, Sentry and OTEL HTTP
Each connector crateInstalling the resolver its typed plan carries at its driver’s hook, or resolving and dialling itself; its TLS, framing, protocol handshake, and the error it reports
The host data planeRetry, acknowledgement, intake, quiesce and shutdown decisions around a connection that could not be opened
nervix-client-coreThe native session client’s resolver and the Tonic connector that dials through it

Connector crates receive the resolver as a value in their typed source or sink plan, so a connector never loads configuration or chooses a resolver itself, and the registry, which names no connector crate, never sees DNS at all. The interconnect receives it wrapped in its PeerResolver, the one seam where the Turmoil build substitutes simulated names, as Build Modes describes.

Resolver Lifetime

A node loads its resolver once, at startup, after its TLS material and before it opens its runtime state or binds the interconnect. Loading reads the resolver configuration and the hosts file on the blocking pool, so a slow filesystem never stalls a reactor thread, and then builds Hickory’s resolver on the node’s Tokio runtime. The read is one of the declared owners of the blocking pool outside the bounded executor, because a node loads its resolver before it builds its executor and a client tool loads the same resolver without one; see Blocking work outside the executor. Neither file is read again: a changed resolver configuration or hosts file takes effect when the node next starts.

Clones of the resolver share its configuration, hosts snapshot, answer cache and concurrency bound. The node hands one clone to the runtime, which passes it into the typed plan of every source and sink that resolves through it, one to the interconnect, and one to its own OTLP trace exporter when enabled. Nothing about the resolver is process-wide: it lives in no static, its name-server connections and background tasks run on the runtime that built it, and they end when its last clone is dropped. A runtime that shuts down with a lookup in flight is not held open by that lookup.

A runtime built without a resolver exists only in unit tests that start no external connector. A source or sink that needs the resolver fails to start there with the reason the node DNS resolver is not installed; it never falls back to another resolver.

A native session client loads its resolver once per client, before its first connection, from the host’s files or from the configuration its caller names, and reuses it for the first connection, seeds, redirects and reconnects. An owner that already holds a resolver hands its own over instead, and the CLI passes its DNS options through. The shared C binding always loads the host’s /etc/resolv.conf and /etc/hosts.

Node Trace Export

With --otel-enabled, the process installs its subscriber and batch span exporter before node startup. The lazy Tonic 0.14 connector waits for startup to publish the node’s resolver. Creating the channel performs no lookup or dial. Startup loads DNS at its usual point, after TLS material and before runtime state, then publishes a clone to this service. Early spans remain in the batch exporter’s existing bounded queue. No second resolver is constructed, and a DNS configuration failure remains a node startup failure.

Every new collector connection uses the node’s configuration, hosts snapshot, cache and concurrency bound. The endpoint retains its hostname and authority. Waiting for resolver installation stays outside the connection timeout so slow startup retains its early spans. Once installed, the OTLP export timeout bounds DNS and TCP address attempts together; the request uses the same timeout. It defaults to ten seconds; OTEL_EXPORTER_OTLP_TRACES_TIMEOUT overrides OTEL_EXPORTER_OTLP_TIMEOUT, both in milliseconds, with invalid values ignored as the exporter defines. Compression and metadata environment settings remain interpreted by opentelemetry-otlp.

The node’s exporter configures no Tonic TLS roots or identity. An https collector therefore fails at export time with Tonic’s TLS-required error even though the connector dependencies enable TLS support. The OTEL sink’s explicit TLS configuration is a separate contract.

Export failures belong to the telemetry SDK. They neither stop a running node nor enter connector retry or acknowledgement paths. If startup ends before installing DNS, dropping the tracing guard closes the resolver publication so a pending connection fails without waiting for a resolver that will never arrive. The guard flushes the provider while the node’s Tokio runtime still exists.

The Tonic connector’s standard-error hook owns a TraceConnectFailure carrying the complete Report<TraceConnectError>. Resolver-installation closure, timeout and connection failures retain their typed contexts and the connection’s cause chain. DNS hook carriers are checked by paired compiling and compile_fail examples in just test-typed-ratchet-product-docs.

Configuration

Three server options select what the node’s resolver reads. The native CLI accepts the same three options and environment variables for its own resolver, as Command Line Client lists.

OptionEnvironment variableDefaultMeaning
--dns-resolver-configNERVIX_DNS_RESOLVER_CONFIG/etc/resolv.confA resolv.conf-format file: its nameserver lines, its last search or domain line, and the ndots, timeout, attempts, and edns0 options
--dns-hosts-fileNERVIX_DNS_HOSTS_FILE/etc/hostsA hosts-format file consulted before DNS
--dns-name-serverNERVIX_DNS_NAME_SERVERSnoneA name server address with its port, repeatable or comma-separated, that replaces the resolver configuration’s nameserver lines while the rest of that file still applies

The resolver configuration is read with the C library’s grammar:

  • Each nameserver line names a server on port 53. A server is asked over UDP and, when an answer arrives truncated, again over TCP. A negative answer from a server is final; the resolver does not ask another server whether the name exists after all.
  • The last search or domain line is the search list. When the file has neither, the domain of the host’s own name is the search list, as the C library does; a host name that is not a valid DNS name contributes none.
  • ndots, timeout and attempts default to 1, 5 seconds and 2, the C library’s defaults. attempts counts the tries of each query, as the C library counts them, and a file that asks for none still gets one; timeout bounds each try. edns0 enables EDNS.
  • A line the grammar cannot read is logged as the warning ignored a line of the resolver configuration, with the file and the reason, and skipped. Every other directive and option the grammar reads has no effect, including sortlist, rotate, single-request, single-request-reopen, use-vc, no-aaaa, trust-ad and inet6.

--dns-name-server replaces only the nameserver lines, so the resolver configuration must still exist and be readable, and its search list and options still apply. The hosts file must exist and be readable too; an empty file is valid.

Loading fails, and the node does not start, with failed to load the name resolver configuration above one of these causes:

CauseWhen
the resolver configuration '<path>' could not be readThe file is missing or unreadable; the operating system’s error is attached
the resolver configuration '<path>' names no name serverThe file has no nameserver line and no --dns-name-server replaces them
the search domain '<domain>' is not a valid DNS nameThe search list holds a name DNS cannot represent
the hosts file '<path>' could not be readThe hosts file is missing, unreadable, or cannot be parsed
the resolver could not be constructedHickory rejected the resulting configuration
loading the resolver configuration stopped before it finishedThe blocking read did not complete because its task was cancelled or panicked

A file without name servers is an error rather than an instruction to ask the local host, which is what the C library does with one. Nothing falls back to a public resolver or to the C library.

Resolution Order

A host resolves in this order:

  1. A literal IPv4 or IPv6 address, bracketed or not, is its own answer. No query is sent and no lookup slot is taken, and the port the caller supplied is kept.
  2. A name the hosts file lists, compared exactly as written and without case, answers with every address the file gives it, IPv4 addresses first. Nothing is asked of DNS for that name in either family, so an entry with only an IPv4 address never waits for an IPv6 query, and search domains do not apply to it.
  3. Any other name goes to the name servers. A name with a trailing dot is fully qualified and asked exactly as written. Any other name is completed by the search list according to ndots, as the C library completes it.

A lookup that reaches the name servers asks the IPv4 and IPv6 questions in parallel and answers with every IPv4 address first and then every IPv6 address, each family in the order the server gave it, without duplicates. Either family is enough: a name with only IPv6 addresses resolves to them, and only when both questions fail does the lookup fail, with the IPv4 question’s outcome. The resolver does not choose between families and does not sort by reachability; the caller dials the answers, as Dialling The Answers describes.

localhost, and every name under it, is answered with 127.0.0.1 and ::1 without a query when the hosts file does not list it, as RFC 6761 asks of a resolver. Names under .local are asked of the configured name servers like any other name; nothing is resolved by multicast DNS. A host that is neither an address nor a valid DNS name fails as an invalid name before anything is asked.

When the configuration names several servers, a query goes to two of them at once, preferring those that have answered fastest so far, and the first usable response wins. The C library instead asks its servers one at a time in file order.

Caching And TTLs

Answers from the name servers are cached per name and record type, in a cache of 4,096 entries, for the TTL the answer carries. The TTL is capped at one hour for an address answer and at thirty seconds for a name that does not exist or has no address. A node therefore keeps using an address its name no longer has for at most an hour, or for the answer’s own TTL when that is shorter, and a name published after a node was told it does not exist resolves on that node within thirty seconds, or sooner when the negative answer’s own TTL is shorter. There is no minimum: an answer with a TTL of zero is not reused. Literal addresses and the hosts file are answered from memory and never cached.

An expired answer is asked again on the next resolution. Expiry never closes a connection: an established connection stays open until its own owner ends it, and a changed answer or an expired TTL takes effect only when that owner opens its next connection. Where Nervix Resolves links each owner’s own statement of this rule.

Bounds, Deadlines And Cancellation

Every lookup runs within a budget its caller gives it, and a lookup that exhausts that budget fails as a timeout. The budget covers the whole lookup: waiting for a lookup slot, every name the search list produces, both address families, and every try against every server. The resolver configuration’s timeout bounds each try and its attempts count the tries, so a configuration with short tries ends a lookup against a silent server well inside a generous budget.

At most 64 lookups past the hosts file run at once on one resolver, a lookup answered from the cache included, which is enough for every peer of a full 64-peer topology to reconnect together. A further lookup waits for a slot inside its own budget. Literal addresses and hosts-file names never take a slot.

A caller that dials for itself shares one physical connection budget between the lookup and the address attempts after it: the lookup receives the budget, and each answer, dialled in resolution order, receives an equal share of whatever time remains when its attempt starts. An address that refuses at once leaves its unused share to the attempts after it, and one that never answers cannot consume their time. Handshakes that the caller performs after an address accepts use what is left of the same budget.

A client library that resolves through one of the resolver’s hooks cannot pass the deadline of the request it resolves for, so every hook lookup has a budget of thirty seconds, and the library’s own request or connection deadline, where it has one, can end the lookup sooner. A lookup cut short by that deadline reports the library’s timeout rather than a lookup failure.

Cancelling a lookup, by dropping it, aborting its task or ending its budget, releases its slot at once. The resolver never retries a failed lookup; the only retries inside a lookup are the tries its configuration asks for, all within the budget. Whether and when a failed connection is attempted again belongs to the caller’s existing policy, so name resolution never multiplies a retry policy.

Lookup Outcomes

A lookup ends with at least one address, or with a DnsLookupError that names the host as the caller wrote it and one DnsLookupFailure. Its message is resolving '<host>' failed: <reason>.

FailureReason in the messageMeaning
NameNotFoundthe name does not existA name server answered that the name does not exist
NoAddressesthe name has no IPv4 or IPv6 addressThe name exists but has no address in either family
Timeoutno answer arrived in timeThe budget ended, or every try of the configured servers timed out
Refuseda name server refused or failed the queryA server answered with an error such as a refusal or a server failure
Unreachableno name server could be reachedNo server could be asked, or none answered with a usable response
InvalidNamethe host is not a valid DNS nameThe host is neither an address nor a valid DNS name

With a search list, the failure is the outcome of the last name that was asked, while the error still names the host as written. The error carries the host and the resolver’s own description of the failure, and nothing from a request, a record or a credential.

A client library that resolved through a hook receives the lookup’s whole report as the failure of its lookup. Every hook’s trait accepts only a standard error, and an error_stack::Report is not one, so the report crosses that boundary inside a DnsLookupReport: Hyper’s resolver service takes any error that converts into a boxed standard error, Reqwest’s hooks take a boxed standard error, Smithy’s a ResolveDnsError, and the MQTT and Redis dialers an std::io::Error. The library keeps it among the causes of its own connection error, however many errors of its own it wraps around it. It shows the lookup’s own message, so a library that renders its causes names the failure once, and its Debug form keeps what the lookup recorded beneath its failure: the name servers’ own answer, or the budget that ran out. DnsLookupReport::find_in finds the report there again, and DnsLookupError::find_in the typed failure in it, including through the Arc and std::io::Error wrappers Redis places around its causes, so a connector can keep the typed lookup failure beneath its own context. Which paths do so is listed under Operator Diagnostics.

Where Nervix Resolves

Every path below resolves through the node’s resolver, or through the native client’s own resolver for a session. Apart from the interconnect’s lookups at startup, each path resolves its configured name again for every new connection it opens.

PathHow it reaches the resolverWhat bounds the lookupCanonical description
Interconnect at startup: the node’s own advertised endpoint, its bootstrap endpoint, and the recovered Raft members’ endpointsPeerResolver, which calls the resolver directlyThe connection setup timeout, five seconds, for each lookupWhere The Interconnect Resolves
Interconnect: every pool connection to a discovered peerPeerResolver inside each connection attemptThe attempt’s five-second connection setup timeoutWhere The Interconnect Resolves
HTTP polling and Prometheus sources, Sentry and OTEL HTTP sinksReqwest 0.13’s dns_resolver, installed by the shared HttpClientConfigThe client’s timeout_ms for the whole request when it is set, otherwise the thirty-second hook budgetDNS for HTTP and Iceberg
Iceberg REST catalog and its OAuth token requestReqwest 0.12’s dns_resolver on the client given to RestCatalogBuilder::with_clientThe thirty-second hook budgetDNS for HTTP and Iceberg
Iceberg S3, GCS and Azure object storage and its credential providersReqwest 0.13’s dns_resolver on the client the connector installs through OpenDAL’s HttpClientLayerThe thirty-second hook budget, inside OpenDAL’s own operation timeoutsDNS for HTTP and Iceberg
HTTP request emitterThe connector resolves each request’s target host itselfThe request’s one physical timeout_msRequest Preparation And Transport Ownership
RabbitMQ sources and sinksThe connector resolves and dials, then hands the transport to Lapin’s Connection::connectorThirty seconds shared with the address attempts and TLSDNS for RabbitMQ
Syslog UDP, TCP and TLS sendersThe connector resolves and dials each new senderThirty seconds shared with socket setup, or with the attempts and TLSIntegration-specific boundaries
WebSocket ws and wss client sourcesThe connector resolves and dials each connection and resumeThirty seconds shared with the attempts, TLS and the upgradeIntegration-specific boundaries
ClickHouse sinks, with and without TLS entriesHyper’s resolver service on hyper-util’s HttpConnectorThe insert’s timeout_ms when it is set, otherwise the thirty-second hook budgetDNS for ClickHouse and SQS
SQS sources and sinks, with and without a custom CASmithy’s ResolveDns, installed with build_with_resolverThe SDK’s 3.1-second connect timeout, which encloses the lookup, and a sink’s timeout_ms as its attempt and operation timeoutsDNS for ClickHouse and SQS
The node’s own OTLP trace exportTonic 0.14’s lazy custom connector, which waits for the node resolver installed during startupThe OTLP export timeout, ten seconds by default, enclosing DNS and TCP attempts after resolver installation; the same timeout bounds the requestNode Trace Export
OTEL gRPC sinksTonic 0.14’s lazy channel over hyper-util’s HttpConnector with Hyper’s resolver servicetimeout_ms when it is set, as the connection and request timeout; otherwise the thirty-second hook budget aloneDNS for OTEL gRPC
Redis command poolsRedis’s AsyncConnectionConfig::set_dns_resolver, installed for every new physical bb8 connectionThe thirty-second hook budget, inside the thirty-second connection timeout the pool sets on the driverDNS for Redis
Redis Pub/Sub sourcesThe connector resolves and dials a dedicated stream, then hands it to Redis’s PubSub::newThirty seconds shared with the attempts, TLS, Redis setup and SUBSCRIBEDNS for Redis
MQTT sources and sinksThe connector’s socket connector, installed with MqttOptions::set_socket_connector, for every connection the driver’s event loop opensThe driver’s five-second connect timeoutDNS for MQTT
Native CLI and SDK sessionsThe client’s resolver, as Hyper’s resolver service under Tonic 0.13’s connector, for the first connection, seeds, redirects and reconnectsThe session’s connect timeout, ten seconds by default, which encloses the lookup, the attempts and TLSLeader Discovery, Redirect, And Reconnect

Two interconnect uses are resolved once, when the node starts: the address that identifies the node in gossip, which is the first answer for its own advertised endpoint, and its bootstrap seeds, which gossip keeps dialling at the addresses the startup lookup produced. Where The Interconnect Resolves describes both. No path writes a resolved address into its configuration, a plan, a Model or persisted state, so a service can move to new addresses without an alteration. Captured backup-section openings have one 30-second caller deadline that includes retries for request-capacity refusal. Pool connection lookups retain their five-second attempt budget; the caller deadline and stream ownership are described in Membership, Consensus, And Bulk Transfer.

Dialling The Answers

The resolver returns every answer; the caller decides how to dial them. A caller that dials for itself shares one connection budget between the lookup and its attempts, tries the answers strictly in resolution order, one at a time, and keeps the first that succeeds, so every IPv4 answer is tried before any IPv6 answer and no attempt races another:

CallerBudgetOne address attemptAfter an address succeeds
Interconnect pool connectionThe connection setup timeout, five secondsTCP connectionTLS, HTTP/2 and the connection hello within the same timeout; a failure there fails the attempt
RabbitMQ source or sinkThirty secondsTCP connectionTLS within the same budget, then Lapin’s AMQP handshake, which has no deadline of its own
Syslog TCP senderThirty secondsTCP connectionNo further step
Syslog TLS senderThirty secondsTCP connection and TLSNo further step
WebSocket client sourceThirty secondsTCP connection, TLS and the opening upgradeThe client’s signaling protocol, when it declares one, under that protocol’s own TIMEOUT
Redis Pub/Sub sourceThirty secondsTCP connection and TLSRedis setup and SUBSCRIBE within the same budget
MQTT source or sinkThe driver’s connect timeout, five secondsTCP connection with the driver’s network optionsTLS and the MQTT handshake by the driver within the same timeout

Syslog UDP has no connection to confirm, so a sender binds a socket in the family of each answer in turn and sends to the first answer whose socket setup succeeds. The HTTP request emitter tries the answers in order inside its one physical timeout_ms, which it does not divide: an answer that never responds uses the rest of that attempt.

A client library that resolves through a hook dials with its own connector. Reqwest, Smithy, ClickHouse’s client and Tonic all dial through hyper-util’s HttpConnector, which tries the answers of the first answer’s family in order, starts the other family’s answers when 300 milliseconds pass without a connection, as RFC 6555 describes, and divides a configured connect timeout evenly across the answers. The Redis driver dials every answer of a new command connection at once, TLS included, and keeps the first that completes.

Connection Identity

Resolution chooses where to connect and nothing else. Every caller keeps the configured host as the identity of the connection, whichever address accepted it:

  • TLS. The server name sent in the handshake and the name the certificate must carry are the configured host. A certificate that names only the dialled address, or another host, is rejected, and a literal IPv4 or IPv6 host is verified as an IP address. The interconnect additionally requires the peer’s cluster identity, as Peer Identity And Authentication describes.
  • HTTP authority. The Host header, the HTTP/2 :authority, the WebSocket upgrade’s URL, and the request path and query stay as configured. A literal IPv6 host is written in brackets there.
  • Proxies. Every Reqwest client Nervix builds, for HTTP polling, Prometheus, Sentry, OTEL HTTP and both Iceberg clients, keeps Reqwest’s default of honoring the proxy environment variables HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY. When a request goes through a proxy, the node’s resolver resolves only the proxy’s host; the proxy resolves the service, an HTTPS request tunnels through it with CONNECT, and TLS still verifies the configured host. An SQS client without a custom CA takes its proxy from the same variables under the same rule, and one with a custom CA uses no proxy. No other path uses a proxy.
  • Request signing. An SQS request is signed with SigV4 for its configured endpoint before its host is resolved, so the signature, the Host header and the certificate check name the same host.
  • Redirects. A Reqwest client that follows a redirect resolves the new host through the node’s resolver like the first, and a native session that follows a leader redirect resolves the leader’s advertised host through the client’s resolver. The HTTP request emitter follows no redirect.

Failure Ownership

A failed lookup is a connection failure of the path that asked, reported through that owner’s existing error and handled by its existing policy. It never rejects a record, never acknowledges or commits input, and never marks a message delivered: a source that cannot connect holds no delivery, and a sink that cannot connect confirms nothing, so the input stays unacknowledged until a later connection delivers it.

OwnerWhat a failed lookup doesWhat tries again
Interconnect pool slotFails that connection attempt and counts it with reason resolutionThe slot’s reconnect backoff, described in Connection And Credential Lifecycle
Node startupFails startup when its own endpoint does not resolve, or when the bootstrap endpoint does not resolve and there are no recovered Raft peer endpoints; otherwise logs and skips an unresolved bootstrap or recovered peer endpointThe operator or process supervisor for a failed start; reachable recovered peers for a restart with an unresolved bootstrap endpoint
A sourceFails its connection or its resumeThe ingestor host, on the source’s retry policy or, for a source in MODE NO ACK, its reconnect cadence
A sinkFails its start, its reopening or its publishThe emitter host, on its backoff and retry policy
A Redis command poolFails the physical connection the pool was openingThe emitter that borrows from the pool, on its retry policy
A native sessionFails the connection attempt as ClientError::ConnectServerThe session’s bounded reconnect policy until its command wait ends; BACKUP uses backup_wait_timeout

A shutdown or quiesce that arrives while a source is resuming cancels the resume, together with every lookup, dial and handshake in it, as Stopping Intake describes. Errors And Diagnostics names the error each owner reports and how the lookup failure stays beneath it.

Dependency Selection

Nervix selects each library’s resolver deliberately. The locked versions and selections are:

LibraryLocked versionSelection
Hickory: hickory-resolver, hickory-proto, hickory-net0.26.3The node’s resolver, on Hickory’s Tokio runtime. The resolver is built from the configuration nervix-dns reads itself, never from Hickory’s own system configuration reader, which other dependencies compile in. DNS over TLS, HTTPS or QUIC and DNSSEC validation are not selected anywhere in the dependency graph, so resolution adds no second TLS provider beside AWS-LC
Reqwest0.13.5hickory-dns is selected. Every client Nervix builds replaces Reqwest’s own Hickory adapter with the node’s resolver before its first request, so that adapter never reads the host’s configuration; left to itself it would fall back to Google’s public name servers when it could not
Reqwest, for Iceberg REST0.12.28, with Hickory 0.25.2Required by iceberg-catalog-rest 0.10.1. hickory-dns is selected and the client installs the node’s resolver the same way; its TLS stays on AWS-LC with bundled roots
OpenDAL0.57.0Iceberg object storage receives a Reqwest 0.13 client through HttpClientLayer, which also serves OpenDAL’s credential providers. OpenDAL’s standalone S3 detect_region helper builds its own client, and Nervix never calls it
hyper-util0.1.21HttpConnector::new_with_resolver with the node’s resolver as its resolver service, for ClickHouse, OTEL gRPC sinks, native sessions and the node’s own trace exporter
Smithy’s HTTP client1.4.2build_with_resolver with the node’s resolver as its ResolveDns, for SQS
Tonic0.13.1connect_with_connector over that HttpConnector, for native sessions
Tonic, for OpenTelemetry0.14.6connect_with_connector_lazy over that HttpConnector, for OTEL gRPC sinks and the node’s own trace exporter
Lapin and async-rs4.12.0 and 0.8.12hickory-dns deliberately off: it resolves through one process-wide resolver built on first use from /etc/resolv.conf, which ignores the node’s configuration and bounds and keeps name-server connections of the first Tokio runtime that used it. The connector hands Lapin an established transport through Connection::connector instead
Redis1.7.1AsyncConnectionConfig::set_dns_resolver for pooled command connections; the Pub/Sub source hands PubSub::new a stream it dialled itself
rumqttc-next0.34.0MqttOptions::set_socket_connector in place of the driver’s lookup_host connector; its proxy and WebSocket transports are not selected
MongoDB3.9.1dns-resolver selected for SRV and TXT discovery; see Residual Resolution

A feature selected anywhere in a workspace build is unified into every crate of that build, so a selection missing from one connector can be hidden by another. just validate-dns-dependencies therefore checks each affected crate’s own dependency graph, and runs in just validate and in CI:

  • The HTTP, Prometheus, Sentry, OTEL and Iceberg connectors select Reqwest 0.13’s hickory-dns, and the Iceberg connector also selects Reqwest 0.12’s without Reqwest 0.12’s Ring provider.
  • The RabbitMQ, Syslog, WebSocket, Redis, MQTT, ClickHouse and SQS connectors, and for RabbitMQ also the server, depend on the node’s resolver and Hickory’s Tokio runtime.
  • Neither the RabbitMQ connector nor the server selects the Hickory feature of Lapin, amq-protocol, amq-protocol-tcp or async-rs.
  • RabbitMQ, Redis, MQTT, ClickHouse and SQS keep their TLS on AWS-LC.
  • The MongoDB connector selects the driver’s dns-resolver feature.

The interconnect, the native client crates and nervix-dns itself are not checked as roots: each depends on nervix-dns directly and selects no optional resolver feature. The check has no rule for the connectors that resolve through their drivers.

Nervix-authored code cannot reach the operating system’s resolver at all. just validate-primitive-boundary, also part of just validate, rejects tokio::net::lookup_host and the ToSocketAddrs traits wherever Nervix code names them, and the primitive boundary’s sockets offer a lookup only in the Turmoil build, where it answers from the simulated DNS table, as Execution Primitives describes. A dependency that resolves inside itself is outside that check, which is why Residual Resolution lists each one.

Residual Resolution

The paths below resolve names without the node’s resolver, because the library that owns their connection takes no resolver, transport or socket from its caller at that boundary in the locked version. They keep working exactly as their drivers define, and each is named here so that no path is assumed to be covered:

PathWho resolves, and howWhy the node’s resolver is not used
MongoDB sources and sinks, mongodb 3.9.1For a mongodb+srv address, the driver’s own Hickory resolver, selected by its dns-resolver feature and built from the host’s /etc/resolv.conf, looks up the SRV and TXT records, and repeats the SRV lookup while it monitors the deployment. The host of every server address it connects to, whether listed in the address or discovered through SRV, is resolved with Tokio’s lookup_hostThe driver accepts no resolver for the addresses its sockets connect to. Its Hickory feature covers discovery only and does not replace that lookup
NATS sources and sinks, async-nats 0.49.1The driver resolves each server address with Tokio’s lookup_host before its per-address connection timeoutNo resolver or socket hook
PostgreSQL pools, sqlx-postgres 0.9.0The driver connects with Tokio’s TcpStream::connect((host, port))No resolver or socket hook at the pool’s connection boundary
MySQL pools, mysql_async 0.36.2The driver connects with Tokio’s TcpStream::connect((host, port))Its resolved_ips option fixes one address list for the life of the pool, which would pin a name to one answer
Pulsar sources and sinks, pulsar 6.9.0The driver resolves broker and proxy URLs with Url::socket_addrs on a blocking worker, then dials the addressesNo resolver hook
Kafka sources and sinks, rdkafka 0.39.0 with librdkafka 2.12.1librdkafka resolves bootstrap and broker hosts in its own native threadslibrdkafka’s resolver callback is not exposed by the Rust binding
ZeroMQ sources and sinks, zeromq 0.4.1The driver’s TCP transport connects with Tokio’s TcpStream::connect((host, port))No socket hook
The web consoleThe browser resolves the page’s origin and its WebSocket and fetch requestsThe browser owns resolution

Tokio’s lookup_host and TcpStream::connect with a host name, and Url::socket_addrs, resolve through the C library on a thread of Tokio’s blocking pool, and librdkafka calls it on its own threads. Such a lookup follows the host’s nsswitch.conf, NSS modules and multicast DNS where the host provides them, the node’s --dns-* options do not apply to it, and only the driver’s own connection timeout bounds it. A thread stays occupied until the C library returns.

Deployment Limits

The node’s resolver implements the resolv.conf and hosts-file behavior this chapter describes and nothing else of the operating system’s name service:

  • It does not consult nsswitch.conf, so the hosts file always answers before DNS and no other source is ever asked.
  • It loads no NSS module. Names served only by LDAP, NIS, mdns or myhostname do not resolve; in particular a node’s own host name resolves only when the hosts file or DNS lists it, which container runtimes arrange by writing it into the container’s hosts file.
  • It sends no multicast DNS, and it applies no sortlist and none of the options Configuration lists as having no effect.
  • It reads its files once. A change that a DHCP client, a VPN or a network manager makes to /etc/resolv.conf reaches the node only when it next starts. A local stub such as the systemd-resolved listener at 127.0.0.53 keeps a stable address while its own upstream servers change, so a node that names the stub follows such changes without a restart.
  • A platform split-DNS policy applies only as far as the name server the configuration names applies it, which is the case for the systemd-resolved stub.
  • It speaks plain DNS over UDP and TCP to the servers it is given. It does not validate DNSSEC and does not use DNS over TLS or HTTPS.

Answers are not authenticated, so an address is only as trustworthy as the DNS path that returned it. Every TLS connection verifies the certificate against the configured host rather than the dialled address, so a forged answer cannot impersonate a TLS server, and the interconnect always uses mutually authenticated TLS. A plain-text connection trusts whatever address it was given.

Docker’s embedded DNS and Kubernetes cluster DNS, with their search lists and ndots, are reached through the resolv.conf those platforms write into each container. The Compose cluster in Docker addresses nodes by container name through Docker’s embedded DNS. The Kubernetes resources in the repository advertise each pod’s fully qualified name under a headless service that publishes pods before they are ready, so a pod’s name resolves as soon as the pod has an address. Such a name has more labels than the platform’s ndots:5 and is asked as written before any search domain.

Because the interconnect gives a lookup at most its five-second connection setup timeout, keep the resolver configuration’s timeout well below five seconds when a node has more than two name servers: a try goes to two servers at once, and under the default five-second timeout two silent servers consume the whole budget before a third is asked.

Build Modes And The Simulation Boundary

BuildPeer namesConnector and client names
NormalThe node’s resolver through PeerResolver::newThe node’s resolver, or the client’s own
turmoilPeerResolver::simulated, which answers from the simulated host’s DNS table through the lookup the primitive boundary offers only in this buildNot part of the simulation
shuttleThe production PeerResolver, but sockets are Tokio’s and outside every model, and a Shuttle execution has no Tokio reactor, so no Shuttle check opens a socket or resolves a nameThe same: no Shuttle check opens a connector or a session
loomNot modeledNot modeled
BrowserThe web console runs no interconnectThe browser resolves; nervix-dns and Hickory are absent from the web console’s dependency graph

The Turmoil build still compiles nervix-dns for its typed failures and its connection budget, but it never constructs a resolver, reads a resolver configuration or hosts file, or sends a DNS packet, so a simulated host cannot reach the real network and shares no resolver state with another host. A simulated name that the table does not hold fails as a name that does not exist, and a simulated lookup does not wait on the caller’s budget. The simulation therefore exercises the production transport over names, while Hickory’s protocol, cache and failure handling are checked against local DNS authorities outside it, as Evidence describes. Deterministic Interconnect Simulation owns that build.

The resolver bounds its lookups with the primitive boundary’s semaphore and measures its budgets with the boundary’s timers, and it publishes no state to a data-plane protocol, so it has no Shuttle check or Loom model of its own; the concurrency bound and cancellation are checked by its own tests against real lookups. nervix-dns cannot be built for the browser at all, because it requires the native capability of nervix-primitives.

Evidence

Each form of evidence establishes a stated part of this contract and nothing more. tests/dns-resolution-ledger.md is the acceptance record: it names every path, the delivery that moved it, and each test and scenario by name.

The OTEL prepared-request check starts with a one-second negative DNS answer, then publishes a real loopback gRPC receiver. It requires a fresh question and one accepted export with the exact prepared bytes within ten seconds. This qualifies recovery through the connector’s existing lazy channel; it does not attribute a historical Collector observation timeout or model the external collector. The public one- and three-node log/trace outline retains that timeout’s failed attempt separately from successful controls and later qualification.

EvidenceCommandWhat it establishesWhat it does not
The resolver’s own checks against an in-process DNS authorityjust test-dnsLiteral and hosts-file answers without a question; search completion and fully qualified names; IPv4-first dual-stack answers; positive and negative TTL reuse and expiry; missing names, empty answers, refusals and invalid names as distinct failures; a silent server ended by the budget and, sooner, by the configured timeout and attempts; lookups beyond 64 waiting and freed by cancellation; a resolver rebuilt and torn down with its runtime; the configuration grammar and its rejections; both Reqwest hooks, the Hyper connector and the Smithy hook dialling a later answer, keeping the URL authority, following a redirect, reconnecting after TTL expiry, being cancelled by a request timeout, and keeping the typed failure and the lookup’s whole report as a causeThe authority answers over UDP only, so truncation and DNS over TCP, a name server that cannot be reached, and the order in which several servers are asked are not exercised
Connector and client unit checksjust test-package-lib <package>Each dialling connector’s ordered attempts, the time an unresponsive address leaves for the next, literal addresses without a lookup, typed lookup failures beneath the connector’s error, TLS failures and budgets; the native client’s ordered answers, deadline and typed failure; SigV4 headers naming the configured hostBehavior through a whole node
Public scenariosjust test-scenarios --input <feature>Nodes whose resolver asks the harness’s DNS authority, described in DNS Authorities, on one- and three-node clusters, with topology-specific cases on three: cluster formation through names and IPv6 literals, hosts-file and single-label names, a peer followed to a new address after its name stopped resolving, a stopped leader rejoining through its advertised name, and a recovered voter restoring quorum while its bootstrap name does not resolve; every migrated connector over plain and TLS transports through a fixture name; certificates checked against the configured host; outages answered as a missing name, no address or silence, with offsets held and delivery after recovery; changed answers followed after a restart; the CLI and a client’s named seedsResolution through a real recursive resolver, and redirects to a named session endpoint, because every advertised client endpoint in the harness is a literal address
The Turmoil simulationjust test-turmoilThe production transport resolving peer names through the simulated table on every connection, deterministically and under replayAnything about Hickory, which the simulation never constructs
Dependency graph checksjust validate-dns-dependenciesThe feature selections of Dependency Selection in each affected crate’s own graphRuntime behavior
The primitive boundary checkjust validate-primitive-boundaryNo Nervix-authored code calls the operating system’s resolver through tokio::net::lookup_host or ToSocketAddrsResolution inside a dependency
The external Chaos suitejust chaosThree node containers that advertise and bootstrap by container name through Docker’s embedded DNS with the production resolver, including a recovered voter whose stopped bootstrap container no longer resolvesArbitrary DNS failures, changed answers beyond the stopped container, and connector names

The scenario features are cluster/dns_resolution.feature, the runtime/*_dns_resolution.feature files for RabbitMQ, Redis, MQTT, Syslog, WebSocket, ClickHouse and SQS, the fixture-name scenarios in the HTTP polling, Prometheus, Sentry, OTEL, Iceberg, HTTP emitter and WebSocket client features, tools/cli_session.feature, and runtime/client_wire_qualification.feature.

The consensus append-admission unit fixtures construct the ordinary resolver from temporary resolver and hosts files with an explicit loopback name-server address. Their transport rejects a departed target before lookup; these tests qualify the caller’s retry and ended-input boundaries, and make no DNS-answer or network-resolution claim.

The MQTT changed-answer and restart scenario requires the new post-restart payload inside a bounded delivery wait. Its persistent QoS 1 source may replay the preceding payload before that marker; the resolver qualification proves reconnecting delivery and preserves MQTT’s replay semantics. The OTEL fixture-name metric scenario observes a successful export from both concrete emitter owners before checking the Collector’s HTTP and gRPC output.

Guarantees And Non-Guarantees

The evidence above establishes these guarantees:

  • A lookup through the node’s resolver never occupies Tokio’s blocking pool or calls the C library, and a configuration that cannot be loaded fails startup instead of selecting another resolver.
  • Every lookup ends within its caller’s budget, at most 64 run at once on a node, and a cancelled lookup frees its slot at once.
  • An address answer is reused for at most one hour and a negative answer for at most thirty seconds, and neither beyond its own TTL.
  • Apart from the interconnect’s startup lookups, every path resolves its configured name again for each new connection.
  • TLS verification, the HTTP authority and SQS request signing name the configured host, whichever address accepted the connection.
  • A failed lookup never rejects a record, acknowledges input or commits an offset.
  • The Turmoil build never constructs the production resolver, reads a resolver configuration or hosts file, or sends a DNS packet.

Nervix does not guarantee:

  • Any part of the operating system’s name service beyond the resolver configuration and the hosts file, as Deployment Limits lists.
  • That the paths Residual Resolution lists follow the node’s DNS options, cache or bounds.
  • Authenticated or encrypted DNS.
  • That HTTP polling, Prometheus, Sentry, OTEL and Iceberg name the lookup failure in their diagnostics.
  • That an established connection follows a changed answer before its owner ends it.
  • That the HTTP request emitter reaches a later answer when an earlier one never responds within its timeout_ms.

Operator Diagnostics

A node that loads its resolver logs at info:

loaded the name resolver configuration resolver_configuration=/etc/resolv.conf hosts_file=/etc/hosts name_servers=ResolverConfiguration

name_servers reads Explicit([...]) with the servers --dns-name-server named. A line of the resolver configuration that could not be read is logged at warn as ignored a line of the resolver configuration, with the file and the parser’s reason, and a node that cannot load its resolver exits with the startup failure Configuration describes.

A failed lookup is visible through the path that asked. The resolver itself logs no individual lookup and exports no metric of its own.

WhereWhat shows the failure
RabbitMQ, Redis, MQTT, Syslog, WebSocket, ClickHouse and SQS, and the HTTP request emitterDESCRIBE INGESTOR or DESCRIBE EMITTER shows the lookup failure on its transient error: line, with the host and the reason, such as resolving 'rabbitmq.example.internal' failed: the name does not exist. An SQS source looks its queue up when an instance resumes, and shows a lookup failure there on the same line
HTTP polling, Prometheus, Sentry, OTEL and IcebergDESCRIBE INGESTOR or DESCRIBE EMITTER reports the failed request, export or catalog call, such as OTEL gRPC export ended without an answer from the receiver, without the lookup failure beneath it
The interconnectnervix_interconnect_connection_failures_total{reason="resolution"} counts failed pool connection attempts by pool class, and each attempt is logged at debug as interconnect pool connection failed with the peer’s node, endpoint, pool class and slot and an error reading resolving <endpoint> failed: <reason>
Node startupA recovered Raft member that does not resolve is logged at warn as could not resolve a recovered Raft peer for gossip; with recovered peer endpoints, an unresolved configured bootstrap host is logged at warn as could not resolve the configured bootstrap host for gossip and skipped. An own endpoint, or a bootstrap endpoint needed for first contact, fails startup as failed to start cluster membership
The CLIThe command fails with failed to connect to server and prints its causes, the last of which is the lookup failure, such as resolving 'nervix.example.internal' failed: the name does not exist
The shared C bindingThe call fails with NX_ERROR_CONNECT, and its message joins the same causes

To see what a node sees, ask the name servers its resolver configuration names, remembering that the node answers a name its hosts file lists without asking DNS and never consults nsswitch.conf.

Recovery Examples

These follow from the contract above; Evidence names the checks behind each.

  • A broker’s name stops resolving. A source fails its resume with the lookup failure, which DESCRIBE INGESTOR shows, and tries again on its retry policy. It receives nothing and acknowledges nothing meanwhile, so what its system keeps for it, such as a queue’s messages, is delivered after it reconnects, while a system that keeps nothing for an absent subscriber, such as Redis Pub/Sub, delivers only what is published after the reconnection. A sink that cannot reconnect confirms nothing, so its input stays unacknowledged and a Kafka source feeding it holds its offset, while DESCRIBE EMITTER shows the failure. Once the name is published again, the first attempt after the cached negative answer expires, at most thirty seconds after it was cached, connects and delivers.
  • A name server goes silent. Each lookup ends at the caller’s budget, or earlier at the configuration’s timeout and attempts, as no answer arrived in time, and the caller retries on its own schedule. Established connections keep working, because nothing re-resolves an open connection.
  • A service or peer moves to new addresses. Open connections stay where they are until their owner ends them. The next connection resolves again once the cached answer expires and dials the new addresses; a peer that returns at a new address after its name stopped resolving is reached again the same way.
  • The first answer cannot be reached. A caller that dials for itself moves to the next answer when the first refuses or its share of the budget ends; a caller that dials through a library moves on under that library’s policy, as Dialling The Answers describes.
  • The DNS configuration must change. Edit the resolver configuration or hosts file, or change --dns-name-server, and restart the node; a rolling restart applies it across a cluster. A node that names a local stub such as systemd-resolved follows the stub’s upstream changes without a restart.
  • A node will not start. failed to load the name resolver configuration names the file and the reason. Make the file readable, give it a nameserver line or pass --dns-name-server, or correct the search domain, and start the node again.

Client Session Protocol

The client session protocol is the public boundary between a client and a Nervix node. It carries NSPL commands and their outcomes, transaction attachment and inspection, completion and choice lookups, domain selection and the observations that follow it, Row subscriptions, domain clock attachments, producers that submit batches to client ingestors, resource uploads, backup downloads, and restore streams. The CLI, the web console, the Rust client, every host of the shared Rust binding, and independent implementations in other languages all speak it.

The protocol owns framing, verification, the session limits, request correlation, cancellation, typed dispositions, the redirect to the leader, the lifecycle of a session, and delivery of the unsolicited frames a session receives. The operation behind a request still owns what that request means: the control plane decides what a command does and when it is complete, the transaction planner decides what a transaction affects, the resource catalog decides when a version exists, and the runtime decides what a relay delivers and when a submitted batch completes. A reply is therefore never more than the statement it makes. Four facts stay distinct at this boundary, and a client that confuses them either repeats an effect or reports one that did not happen:

  • Bytes transferred. A frame reached the other side. Nothing about a request follows from it.
  • Work admitted. The cluster durably recorded a command under its execution identity. The work continues whatever happens to the session that sent it.
  • Effects completed. The command reached its completion boundary, and its terminal outcome is retained under that identity.
  • Waiter cancelled. A client stopped waiting. Before admission nothing began; after admission the effect continues and can be recovered by its identity.

This chapter explains why the protocol behaves as it does. The Client Implementation Manual states, as rules, what a client implementation must do. Each links to the other instead of repeating it. Command Completion owns the completion boundary and the retention of execution identities, Control Plane owns the transaction lifecycle, Transaction Quiescence And Impact Inspection owns the impact report, Resource Versions And Bindings owns the version lifecycle behind an upload, Backup And Restore owns what a backup archive holds, Domain Clock owns the clock a session can follow, Errors And Diagnostics owns how typed failures become diagnostics, and Shutdown And Recovery owns the phases of a node stop.

A Boundary Separate From The Interconnect

Clients never speak the Cluster Interconnect, and nodes never use the session protocol to talk to each other. The two boundaries answer different questions and are built differently:

Client session protocolCluster interconnect
PeersUntrusted clients acting for a registry userCluster nodes holding certificates from the cluster CA
AuthenticationBasic credentials of a registry user on every call or upgradeMutual TLS 1.3 with one node identity URI per certificate
ListenersThe session gRPC listener and the console listener of every nodeOne interconnect listener per node
EncodingFlatBuffers frames verified against one public schemaBounded rkyv control archives and Arrow IPC relay bodies
Payload of a relayTyped Row cells, positional against an announced schemaArrow record batches
Identity of workA request identity per session and a durable execution reference per commandCoordination identities, process epochs, and delivery identities
EvolutionOne current schema; no version field, negotiation, or fallbackOne current wire contract, fenced by a fingerprint at connection setup

A follower does not forward a client’s leader operation over the interconnect on the client’s behalf. It answers with a redirect that names the leader, and the client reconnects there, so every leader operation arrives at the leader on a session the client itself holds. Subscriptions are the one path that crosses both boundaries: the relay owner fans a batch out to the subscriber’s node over the interconnect, and that node encodes Row frames for its own sessions.

Ownership By Layer

LayerOwnerWhat it owns
Vocabularynervix-modelsExecution references, domain, relay, subscription and user names, timestamps, the transaction status, preview identity and impact report, the resource description, and the observed domain clock.
EdgesThe wire crate, nervix-client-wireThe FlatBuffers schema, frame verification and ownership, the session limits, the typed requests, replies, events, transfers and rows the schema describes, the display text of a row, and how frames travel in gRPC and WebSocket messages. It knows no registry, runtime, consensus, parser, Arrow, or client dispatch.
EdgesThe session service in nervix-serverAuthenticating a call, one session per transport connection, correlation, the ordered and concurrent lanes, cancellation against admission, typed rejections, reply encoding and transfer, the control and subscription lanes a session’s frames wait in, unsolicited events, domain clock attachments, the upload stream, the backup download stream, the restore stream, and carrying a download or a restore over a console WebSocket of its own.
EdgesThe Row encoder in nervix-serverBinding one subscription generation to the row schema it announces and writing selected Arrow rows into bounded frames.
Control planeThe command pipeline and transaction use casesDurable execution identity, admission, exact recovery, domain mutation ownership, plan fencing, the transaction lifecycle, and the completion barrier.
Control planeBackup execution and retained archivesAssembling a backup’s archive from one applied revision, retaining it under the backup’s execution reference until a download collects it or its retry validity ends, and the bounded stream that sends it.
Control planeSession subscriptionsCreation and deletion, the generation each subscription opens with, its lifecycle, its filter and sampling, the delivery of one generation to its session, and the interest lease it holds on its relay.
Data planeRelay subscription fan-outThe subscribers of one relay, the definition they were attached under, and ending every subscriber before a batch of another definition can reach it.
EdgesThe Rust client, nervix-client-coreConnecting, TLS selection, the dispatcher that pairs replies with requests, execution identity across retries, redirect and reconnect, transaction binding and previews, desired subscriptions and their restoration, followed domain clocks, uploads, and backup downloads verified against their summary.
EdgesThe shared binding, nervix-client-ffiThe C ABI through which C, C++, Python, JVM and Ruby hosts drive the Rust client’s state machine, with borrowed frame access, bulk column copies, retained domain clock events, retained reads of the clock of each followed domain with its projections, and the Rust client’s producers and consumers with their typed outcomes, settlements and Arrow batches built and read one column level at a time.
EdgesThe web console and the CLIConsumers of the same protocol with bounded buffers of their own. The console speaks it over its WebSockets: the session, and one per backup download or restore stream, whose archive it verifies or measures itself; the CLI uses the Rust client.

The server is the composition root: it is the only crate that names the wire crate, the command pipeline, and the runtime together. The wire crate names neither Arrow nor the parser, so an implementation in another language needs only the schema. The Rust client additionally names the language edge, which it uses to split a request into statements and to recognize the statements it serves itself, such as USE; everything it knows about the cluster arrives over the protocol.

The Rust client and CLI declare their edge ownership in gated compiler source contracts. Their session maps remain client-owned synchronization outside the server record-path acquisition rule. Server relay subscription fan-out remains recurring data-plane execution with its own source contracts and exact-operation repair expectations. The annotations change no session framing, recovery or delivery guarantee. Data-Plane Concurrency owns this compiler boundary. Execution Primitives owns the shared backend selection, portable reference types and declared unmodeled exceptions used by the Rust client, binding and browser; those build choices change no wire contract.

Wire Format

One Schema, Eight Frame Roots

crates/client-wire/schema/session.fbs declares every shape of the public boundary, once. Eight root files name the table a frame is rooted at and the four-byte file identifier it carries:

RootIdentifierDirectionCarried by
ClientMessageNXCMClient to serverThe gRPC Exchange stream and the console WebSocket
ServerMessageNXSMServer to clientThe gRPC Exchange stream and the console WebSocket
UploadMessageNXUMClient to serverThe gRPC UploadResource stream
UploadReplyNXURServer to clientThe gRPC UploadResource stream, exactly once
BackupDownloadRequestNXBQClient to serverThe gRPC DownloadBackup call, exactly once
BackupDownloadMessageNXBDServer to clientThe gRPC DownloadBackup stream
RestoreMessageNXRMClient to serverThe gRPC RestoreBackup stream
RestoreReplyNXRRServer to clientThe gRPC RestoreBackup stream, exactly once

A frame is one finished FlatBuffer that carries its root’s identifier and no size prefix. The transport delimits frames, so a frame never has to describe its own length and a receiver never parses a length that a peer chose.

A ClientMessage holds one request and the request identity that correlates its replies. A ServerMessage holds either a Reply to one request or one unsolicited body: a server notice, leadership, the domain list, the selected domain’s snapshot and cluster summary, subscription rows and the notices about them, a domain clock frame, a producer’s admission change or end, or the ending of the session. An unsolicited body carries no request identity and never completes a request.

Verification Before Reading

A receiver verifies a frame once, when it takes ownership of the bytes and before it reads any field. The frame must fit the session frame limit, hold at least a root offset and identifier, carry the expected identifier, and pass FlatBuffers verification. Verification bounds every offset and length, requires every (required) field, validates every string as UTF-8 with its terminator, and limits nesting depth, the number of tables, and the apparent size of the traversal. The table and traversal budgets scale with the frame being verified rather than with the limit: a frame of n bytes may hold at most n / 4 tables and may expand to at most eight times its own size, so a small frame whose offsets alias one another cannot make a receiver walk far more than it sent.

Reading then applies the rules the verifier does not know. Every union is required, so the NONE discriminant is never valid. A union discriminant or enum value the schema does not declare is rejected, and so is an absent optional scalar that a comment requires, a zero where a comment requires a non-zero value, an empty collection that must not be empty, a vector or string longer than the session limits, a set that is not in canonical order, and a value the receiver’s platform cannot represent. A receiver never substitutes a default for a rejected value.

Scalars declared = null are optional: absent means not provided, and a present zero is a value. The distinction carries meaning in several places. An append that expects position zero is not an append that expects nothing, and a float cell holds its value as an optional scalar because a defaulted field would lose the sign of a negative zero. Other scalars read as their declared default, zero or false, when absent.

Verified bytes are immutable, and every value the wire crate hands out borrows from them, so no view reads unverified bytes and no read verifies again. A verified frame pins the buffer it was received in for as long as it, or any value that keeps it, is alive. The gRPC codec therefore copies a frame smaller than 64 KiB out of the transport’s read buffer, which grows to the largest message the connection has carried, and takes a larger frame without a copy.

Session Limits

One set of limits governs a session. The transports configure their message limits from it, so an oversized message is refused before it is buffered, and every encoder checks each string and vector against it before the FlatBuffers builder grows, so no Nervix encoder produces a frame that a receiver holding the same limits rejects.

LimitDefaultWhat it bounds
Frame4 MiBOne gRPC message or one WebSocket binary message. It matches the receive limit gRPC applies by default.
Transfer64 MiBOne reply reassembled from transfer parts.
Nesting depth64Nested tables in a frame, counting the root as the first level. The deepest structure of the schema that does not recurse, a node coverage in an execution step’s affected topology inside an inspection reply, nests 13 tables; the rest is for nested list values and list field types.
Collection entries262,144Entries in one vector.
String64 MiBBytes in one string.

Every Nervix server and client uses these defaults. The wire crate refuses a configuration whose frame limit is outside 1 KiB to 256 MiB, whose transfer limit is below the frame limit, whose nesting limit is outside 16 to 128, or whose string limit exceeds the transfer limit. The upper bounds keep the verifier’s budgets representable in the 32-bit address space of the browser build, and the nesting bound caps the stack a hostile frame can make the verifier and the decoders use.

Replies Larger Than One Frame

A reply may be larger than one frame; a rendered transaction report is the usual case. The server encodes the complete reply once, from one read of its state, and cuts that single frame into TransferPart replies. The parts of one reply share its request identity and its total size, arrive in order with contiguous offsets starting at zero, and each carries a non-empty chunk. The client joins them and verifies the result as a frame of its own, under the transfer limit, which must hold a Reply to the same request whose body is not itself a transfer part.

A transferred reply is therefore exactly as trusted as one that fit a frame. It is never delivered truncated, and it can never mix two reads of the server’s state. Parts of one reply stay in order, but other replies and unsolicited frames may arrive between them, which is why a client keeps one reassembly per request identity. A reply above the transfer limit is not sent at all: the request receives the typed rejection ReplyTooLarge instead. A reply that carries an impact report is encoded on the bulk workers under the bulk memory class; when neither memory nor a worker is available for it, the request receives ServerBusy, which is safe to repeat because such a reply belongs to a request that changes nothing.

One Current Form

The protocol has exactly one current form. Frames carry no version, a session negotiates nothing, and neither side keeps a compatibility reader for an earlier schema. A changed shape is a coordinated change of the server, the Rust client, the binding, the console, the conformance clients, and the corpus in one commit. A peer built against another schema meets the ordinary verification and reading rules: an undeclared request kind is rejected as UnsupportedRequest, an undeclared enum value as UnsupportedValue, and a client rejects a server discriminant it does not know rather than skipping it.

The conformance corpus in crates/client-wire/conformance pins the bytes the Rust encoder writes for a set of requests, replies, and events chosen to reach what live traffic does not, such as extreme values, nested lists, and every refusal of a domain clock attachment, together with corpus.report, the reading of those bytes that every other reader must reproduce. just test-client-wire checks that the checked-in bytes are exactly what the encoder writes, and just update-client-wire-corpus regenerates them for review.

The registered client representation properties also compare complete generated current values through the production codecs. Separate targets verify malformed bytes under fixed limits; Arrow properties compare selected logical values and validity with decoded Row cells, using float bits and explicit redaction, while binding properties check column copies and retained frame lifetime. The client representation coverage map records their schema coverage and ownership boundaries. Both ordinary corpus replay and sanitizer-backed fuzzing run the same assertions through the property target inventory.

Transports, Endpoints, And Authentication

Every live node runs both client listeners. Neither depends on leadership or placement, and both close only when the node begins to stop.

Native gRPC

Native clients reach the gRPC service nervix.session.Session, which serves four methods and answers every other path with UNIMPLEMENTED:

MethodShapeFrames
/nervix.session.Session/ExchangeBidirectional streamClientMessage frames answered by ServerMessage frames. One call is one session.
/nervix.session.Session/UploadResourceClient streamUploadMessage frames answered by exactly one UploadReply frame. One call is one upload.
/nervix.session.Session/DownloadBackupServer streamOne BackupDownloadRequest frame answered by BackupDownloadMessage frames. One call is one download.
/nervix.session.Session/RestoreBackupClient streamRestoreMessage frames answered by exactly one RestoreReply frame. One call is one attempt of a restore.

Each gRPC message holds exactly one frame, delimited by gRPC’s own length prefix. The messages are not protocol buffers, and no generated gRPC service code is involved: the codec hands the frame bytes to the transport unchanged and verifies what it receives. The server speaks HTTP/2 only, accepts no message compression, and sets gRPC’s message limits to the frame limit, so a message above it is refused before it is buffered.

The listener serves plaintext HTTP/2 on --addr (NERVIX_ADDR, default 127.0.0.1:47391) and advertises --grpc-advertise-addr (NERVIX_GRPC_ADVERTISE_ADDR), or the listen address when that is unset. --grpc-mode https (NERVIX_GRPC_MODE) serves the listener over TLS on --grpc-https-listen-addr instead and advertises --grpc-https-advertise-addr. That mode presents the development certificate that scripts/generate_dev_tls.sh writes under tls/dev in the source tree the server was built from; no option selects another certificate, and the Kubernetes deployment runs the listener in plaintext mode.

Console WebSocket

The web console reaches the same session engine over a WebSocket, GET /console/ws on the console listener: --web-console-listen-addr (NERVIX_WEB_CONSOLE_LISTEN_ADDR, default 0.0.0.0:47420), and optionally a TLS listener, --web-console-https-listen-addr with --web-console-tls-cert and --web-console-tls-key. Each binary message holds exactly one frame in either direction and nothing else: no size prefix, never two frames, never part of one. Ping, pong, and close messages belong to the WebSocket connection and never carry a frame.

The console session is served only by the leader. A console session on any other node is ended at once with a SessionEnding whose reason is a LeaderRedirect naming the leader, and the console reconnects to the leader’s advertised console endpoint. A console session also ends this way when its node stops leading, which the node checks every 250 milliseconds.

The console listener serves three WebSockets, each carrying one kind of call with exactly the frames its gRPC method carries:

PathCallFrames
GET /console/wsThe Exchange sessionClientMessage frames answered by ServerMessage frames. One connection is one session.
GET /console/backups/downloadOne DownloadBackup callOne BackupDownloadRequest frame answered by BackupDownloadMessage frames, then a normal close.
GET /console/backups/restoreOne RestoreBackup attemptRestoreMessage frames answered by exactly one RestoreReply frame, then a normal close.

A frame of another call’s root fails verification and closes the connection with 1007. A download connection takes exactly one request: another data message after it closes the connection with 1008, and a close or a failed connection stops the download before it sends another frame, which releases only its own hold on the archive. Any node serves a download and a restore, and each answers from its own role exactly as its gRPC method does: a node that does not retain the archive and is not the leader answers a download with a redirect, and a restore sent to a follower is answered with a redirect outcome.

A WebSocket client cannot half-close a stream the way a gRPC client does: once it sends a close, it reads nothing more. A restore over the console WebSocket therefore ends with the chunk that completes the size its start declares, and the connection stays open for the reply. A close or a failed connection before that chunk ends the attempt in transport, which changes nothing and releases what the leader staged for it. A frame the restore cannot read, such as a chunk before the start or a second start, ends the stream at that frame, and the restore refuses it without reading further. The server closes with 1011 the rare answer it cannot fit into a frame.

The console has no upload stream: it uploads a resource through its own HTTP path, /console/resources/upload, where the leader builds the archive from the files it receives, as Resource Versions And Bindings describes.

Authentication

Both transports authenticate a registry user with Basic credentials before any frame is read. A gRPC call presents authorization: Basic <base64(user:password)> metadata, on every call, and a call that fails authentication ends with UNAUTHENTICATED. The console upgrade presents the same credentials in an Authorization: Basic header or, because a browser cannot set headers on a WebSocket, as the auth query parameter; a failure answers 401 with a Basic challenge and never upgrades. GET /console/auth checks credentials without opening a session. Passwords are verified against Argon2 hashes held in consensus, on the node’s credentials worker under a charge of the working memory the stored hash’s parameters make Argon2 allocate. That class serves only password work, so saturated data, extension or bulk work never delays a login, and concurrent attempts wait for it in turn. A node whose credentials class cannot take the verification now answers UNAVAILABLE, or 503 without an upgrade on the console: the credentials were not judged, so the attempt neither counts as a failure nor paces the user, and the same credentials may be presented again. After a failed attempt for a user, that user’s later attempts are paced to ten per second until one succeeds.

Authentication establishes who owns what a session does: its transaction binding, its execution references, its subscriptions, and its uploads. It is the only access control. Every authenticated user may run every command in every domain; ownership decides only who may attach and inspect a transaction, and scopes execution references and upload identities to their user.

Transport Failures

A failure of the framing ends the connection, because nothing after it can be trusted. Everything a well-formed frame can get wrong is answered inside the session, as a typed reply to the request it names.

FailureNative gRPCConsole WebSocket
Credentials missing or wrongUNAUTHENTICATED401, no upgrade
Credentials the node could not verify nowUNAVAILABLE503, no upgrade
Message above the frame limitOUT_OF_RANGE, refused before bufferingClose 1009
Message that is not a valid frame, or a frame of another callINTERNALClose 1007
Text message—Close 1003
A second request on a download—Close 1008
The node began to stop, during a download or a restoreThe call is cutClose 1001
Frame without a valid request identitySessionEnding with ProtocolViolated, then the end of the streamThe same SessionEnding, then a normal close
Session ended by the serverThe end of the stream, with status OKA normal close

Requests, Lanes, And Cancellation

Request Identity Versus Execution Identity

Every ClientMessage carries a request identity: a non-zero 64-bit value the client chooses and never reuses within the session. Every reply to that request is a Reply naming the same identity, and a request receives exactly one terminal reply, delivered whole or as transfer parts. The identity exists only to pair replies with the request waiting for them. It is scoped to one session, which is one gRPC Exchange call or one WebSocket connection, and a new session starts over.

A command’s execution reference is a different identity with a different purpose. It names the command’s effects durably, across retries, redirects, reconnects, leader changes, and full restarts, and it outlives every session that carries it. A retry after a lost reply is a new request, with a new request identity, that repeats the same execution reference. Confusing the two breaks both guarantees: repeating a request identity cannot recover an effect, and minting a new execution reference for a retry can apply an effect twice.

Two Lanes

A session serves each request on one of two lanes:

  • The ordered lane serves the requests that change the session or may change the cluster: commands, transaction attach, subscribe and unsubscribe, domain clock attach and detach, and producer and consumer opens. They run one at a time in the order the client wrote them. A mutating command never overtakes the command written before it, and a subscription never opens before the command that created its relay has completed. Every command runs on this lane, including a SHOW or DESCRIBE, so within one session a read command waits behind a command that is waiting for its completion boundary.
  • The concurrent lane serves the requests that only read the session: completion suggestions, choice lookups, listing and selecting domains, and transaction inspection. Each runs as its own task beside the ordered lane, from the view of the session that the ordered lane last published, so a long command never delays a completion, an inspection, or a domain request. Their replies can arrive in any order. Selecting a domain changes only which domain’s observations the session receives.

A cancellation is on neither lane: it is answered as soon as it arrives. Neither is a submitted batch or a producer close: the receive loop hands both to the producer’s own task and reads the next request at once; see Producers. A session admits at most 64 requests in flight, counting both lanes and producer closes but not submitted batches, which their producer’s credit bounds instead. A request beyond that is refused with TooManyRequestsInFlight rather than queued, which also bounds the requests waiting for the ordered lane; nothing about the refused request was admitted. The web console never sends a request that keeps its order past that limit: it holds the request, in the order it was issued, until an earlier reply frees a place. Its completions, choice lookups, and domain selections go out at once and report the refusal if they find the session full. A request identity that is already in flight is refused with DuplicateRequestId, and that refusal necessarily names the same identity as the request still in flight.

Choice lookups on the concurrent lane return typed values for structured client controls. A domain dependency selects internal schemas, branches, relays, codecs, VHOSTs, signaling protocols, JSON/CBOR/AVRO wire schemas, or resource catalogs; a domain and relay reference select relay fields; a domain and codec reference select that codec’s output schema fields; a domain and resource reference select completed resource versions. Wire-schema targets have separate discriminants, so a codec cannot mistake a JSON wire schema for a CBOR or AVRO schema with the same name. A visual ingestor uses a distinct source-reference target for each of the 14 connector and entity pairs that read an external system; a client producer ingestor selects its schema through the schema target instead. Each source-reference target requires a domain and offers clients of its pair’s connector, except the HTTP endpoint and WebSocket endpoint targets, which offer endpoints of that TYPE. Its decoding-codec target offers codecs that can decode. An unbranched-relay target needs a domain; a branched-relay target also needs the selected branch and offers relays with exactly that branch. Ingestor error relays use the unbranched target. A branch-field target takes a domain and branch reference. Ingestor decoded and output fields use the existing codec-field and relay-field targets. A junction or reingestor asks for another input relay with a domain and the first relay reference; candidates have that relay’s exact named schema and branch. The same dependencies with the output-relay target select the input branch, and with the materialized-relay target select only relays with materialized state in that branch. Route construction of an unbranched or named branch uses the ingestor relay targets, while branch keys and record fields use the branch-field and relay-field targets. These processor targets resolve against the attached transaction prefix, and their page digests include the selected input definition. The typed target and dependencies remain part of the cursor identity. A completed-version value is either an explicit number or LATEST, not a label to parse. Resource catalogs include resources staged earlier in the attached transaction, while version choices include completed uploads only. The cursor binds the selected candidate set and its definitions; a changed context returns StaleContext instead of continuing an earlier page. Sessions owns the complete lookup contract and the server’s transaction-aware resolution.

flowchart LR
    Client -- ClientMessage --> Decode[Verify and decode]
    Decode -- commands, attach, subscribe, unsubscribe, clock attach and detach, producer and consumer opens --> Ordered[Ordered lane: one at a time, in written order]
    Decode -- suggest, choices, domains, inspection --> Concurrent[Concurrent tasks]
    Decode -- cancel --> Cancel[Answered at once]
    Decode -- submit batch, close producer --> Pumps[Producer tasks]
    Pumps --> Control
    Ordered --> Control[Control lane: 16 frames]
    Concurrent --> Control
    Cancel --> Control
    Clocks[Domain clock deliveries] --> Control
    Deliveries[Subscription deliveries] --> Rows[Subscription lane: 16 frames]
    Control -- always taken first --> Transport
    Rows -- taken while the control lane is empty --> Transport

What A Session Sends First

A session queues its frames on two bounded outbound lanes. Replies, transfer parts, session events, and domain clock frames wait on the control lane, which holds 16 frames. The session ending never waits: the transport writes it after the control frames already queued, and nothing after it. Subscription rows and the notices about them wait on the subscription lane, which holds 16 frames shared by every subscription of the session. The transport always takes a queued control frame first and takes subscription frames only while the control lane is empty. A reply, including the reply to the unsubscribe that stops a subscription, therefore never waits behind rows the client has not read. The lanes never reorder what the transport already took: a frame handed to the transport stays ahead of every frame queued after it. An attached clock’s tick holds one replaceable control slot per domain. Its delivery task can overwrite the slot while the lane is full or after queueing it, until the transport takes it; a changed clock state withdraws a superseded tick still in that slot.

Because every frame is at most the frame limit, the two lanes bound what a session holds for a client that reads slowly. Backpressure reaches the client’s requests too: a rejection or a cancellation outcome waits for room on the control lane before the session reads the next request, so a client that reads nothing eventually stops being read. The server has no idle or slow-client timeout; such a session ends only with its transport.

Cancellation

CancelRequest names the request identity it targets. The server answers with CancelOutcome: Requested when the target is in flight, or NotInFlight when it is not. The target still receives its own terminal reply: RequestCancelled, or its ordinary reply if it finished before the cancellation took hold. The two replies can arrive in either order.

Whether a cancellation stops an effect depends on one atomic decision per request. A request admits itself immediately before its first effect, and a cancellation that arrives first decides the request cancelled for good. RequestCancelled reports which came first:

  • BeforeAdmission. The request was not admitted and never will be. It has no effect.
  • AfterAdmission. The request was admitted. Cancelling ends only the wait for it. A command’s effect continues and is recovered by its execution reference, exactly as after a lost reply; what an admitted cancellation leaves of the other ordered requests is described below.

A submitted batch cannot be cancelled: a CancelRequest naming it is refused with InvalidRequest, and the batch still receives its outcome, because a batch the ingestor may already have admitted cannot be withdrawn.

A command admits itself just before its durable admission or first effect. Transaction attach, subscribe, unsubscribe, clock requests, and producer and consumer opens admit themselves when the ordered lane starts serving them, and a concurrent request never admits itself, so cancelling one always stops it. A command cancelled after admission keeps the ordered lane until its work finishes, and an attach cancelled after admission still binds the transaction. A domain clock attach cancelled after admission also keeps the ordered lane until it is served, but then attaches nothing, because the server delivers only an attachment whose reply it queued; a domain clock detach cancelled after admission has still detached, and sending it again reports the session’s state. A cancellation never rolls back admitted work: it is how a client stops waiting, not how it undoes a command, and undoing is a new command.

Rejections

A request that is not served receives RequestRejected with a typed reason, the offending field when there is one, and a message for display. The session keeps serving after any rejection.

RejectionMeaning
InvalidRequestA field is missing, malformed, or out of range, such as an execution reference that is not one or a completion cursor inside a character, or a cancellation names a submitted batch.
UnsupportedRequestThe request kind is not one the server’s schema declares.
UnsupportedValueAn enum value the server does not declare, including a subscription type it does not implement.
DuplicateRequestIdThe request identity is already in flight in this session.
TooManyRequestsInFlightThe session already has 64 requests in flight. Nothing was admitted.
ReplyTooLargeThe complete reply cannot be encoded within the session limits, above all because it exceeds the transfer limit.
ServerBusyThe server had no memory or worker left to prepare the reply of an inspection.

The first five refuse a request before anything about it runs. ReplyTooLarge and ServerBusy replace a reply that could not be prepared. For a read, repeating the request reads again. For a command, the missing reply says nothing about the command’s effect, which is recovered by its execution reference like any other lost reply.

Command Dispositions

A command’s reply is a CommandOutcome: the execution reference it answers, whether the outcome was produced now or recovered from the durable record of an earlier attempt, one typed disposition, a message for display, diagnostics with optional source spans, and, for a request of several statements, the outcome of each statement in written order. It also carries the transaction this session is bound to while the command was served, the operation admission when the command accepted one operation into the transaction, the typed inspection, WASM state, or resource description a describing statement produced, and the summary of a backup’s archive or the report of a restore.

The disposition is the one field a client decides from; the message is for a person. Each disposition belongs to one phase of the command and makes one statement about its effect:

Consensus proposal failures reach the session with typed leadership, conflict, storage, and transaction-mutation causes in local reports. The session selects its disposition from those causes and the command’s admission phase, while the display message retains the underlying Raft or I/O reason. Report carriage does not change request correlation, the durable acknowledgement boundary, or the stored transaction response.

DispositionPhaseWhat it establishesWhat a client does
CommandCompletedFinalizationThe command reached its completion boundary. already_existed says a creating statement found its entity present and changed nothing.Report success.
RequestFailedAdmission, execution, or finalizationThe command failed definitively. Nothing further will happen under its reference.Report the failure with its diagnostics.
LeaderRedirectBefore admissionThe serving node is not the leader, and nothing was admitted there. The leader’s endpoints are absent while no leader is known or discovery cannot reach it.Reconnect to the leader, or wait for one, and repeat the command under the same reference.
TransactionDetachedBefore admissionThe leader holds no binding for the session’s transaction, typically after a reconnect or leader change.Attach the transaction again and repeat the command under the same reference.
TransactionTakenOverBefore admissionAnother session attached this session’s transaction, and nothing was admitted for this request.Report it. Attaching the transaction again would take it back, which is the user’s decision rather than a retry.
OutcomeUnknownAdmission, execution, or finalizationThe command may have been admitted and its outcome is not known yet: leadership moved while it was admitted or applied (LeadershipLost), it is still applying (StillApplying), or it finished but its outcome is not yet authoritative on every live node (NotYetAuthoritative).Repeat the command under the same reference until the outcome is known. Never report success or failure from this disposition.
ExecutionReferenceConflictBefore admissionThe reference already identifies a different command, which differs in its owner, domain, transaction position, or content. Nothing was admitted for this request.Treat it as a defect in the client’s use of references.
ExecutionReferenceExpiredBefore admissionThe reference aged out of execution history. Its outcome can no longer be recovered, and the command was not executed again.Report that the outcome is unknown; issuing the work again is a new command.
PreviewStaleCommit admissionThe commit named a preview that no longer describes the transaction. Nothing was applied, and the transaction stays open. The reply names both the expected and the current preview.Inspect the transaction again and decide whether to commit against the current preview.

OutcomeOrigin distinguishes an outcome produced by this request, Executed, from one read back from the durable record of an earlier attempt with the same reference, Recovered. A recovered failure is the same failure: its message and diagnostics are the ones retained when it happened.

A request of several statements reports each statement’s own disposition, which is one of CommandCompleted, RequestFailed, or LeaderRedirect, in written order, and the command’s disposition is that of the statement that ended it. Several statements in one request always belong to a transaction: the request begins one and appends to it, possibly committing it too, or appends several statements to the transaction the session holds. Outside a transaction a request carries one statement. DESCRIBE TRANSACTION and SHOW TRANSACTIONS read beside the transaction and are always sent alone.

The leader’s reference check and replicated admission return the same typed dispositions. A reference the history no longer holds returns ExecutionReferenceExpired; a conflicting request detected during replicated admission returns ExecutionReferenceConflict with the conflict kind. Neither refusal admits the request. Exact Recovery explains the retry fence.

Four Boundaries

The dispositions exist because a client can lose sight of a command at four different points, and each point means something different:

sequenceDiagram
    participant C as Client
    participant S as Session on the leader
    participant R as Replicated execution record
    participant E as Effect owners
    C->>S: CommandRequest(request 7, reference X)
    Note over C,S: Bytes transferred: nothing about X follows yet
    S->>R: admit X with its semantic command
    Note over S,R: Work admitted: X continues without this session
    R->>E: apply, activate, release
    E-->>R: completion boundary reached, outcome retained under X
    Note over R,E: Effects completed
    S--xC: Reply(request 7) lost with the session
    C->>S: CommandRequest(request 1 of a new session, reference X)
    S->>R: look up X
    R-->>S: retained outcome
    S-->>C: Reply(request 1): CommandCompleted, origin Recovered

A cancelled waiter is the fourth point: a CancelRequest that wins before admission leaves no record of the reference at all, and one that arrives after admission ends only the wait. In every case the same reference, sent again, finds the truth: nothing recorded admits the command now, an applying record joins it, and a terminal record returns it.

Exact Recovery

A client recovers a command it lost sight of by sending the same command again under the same execution reference. The protocol makes that safe by keeping one durable record per reference and answering every repetition from it. Command Completion owns the record’s lifecycle, its retention, and the retry fence; this section describes what a client can rely on.

What Is Recorded

Every persistent command is recorded under its reference before its first effect: model creation, alteration, and removal, REBIND RESOURCE, CREATE DOMAIN, ALTER DOMAIN, CREATE USER, CREATE RESOURCE, START, STOP, DROP NODE, CORDON NODE, UNCORDON NODE, DRAIN NODE, RELOCATE, RESET WASM PROCESSOR ... STATE, and BACKUP. So is every transaction request: a request that begins, commits, or reverts a transaction, one sent while the session is bound to a transaction, and one that carries an expected transaction position. An ordinary request carries at most one persistent statement; several statements in one request always form a transaction request.

Reads record nothing. SHOW, DESCRIBE, LOOKUP, DESCRIBE TRANSACTION, and SHOW TRANSACTIONS accept any well-formed reference, and repeating one reads again. Uploads have an identity of their own and are covered in Resource Uploads.

The record binds the reference to one semantic request: the authenticated owner, the request’s domain, the expected transaction position, and a digest of what the request asks for. For an ordinary command the digest covers the parsed statement, so a retry that differs only in whitespace or keyword case is the same request. For a transaction request it covers the exact request text. A new reference is admitted only when it is a UUIDv7 whose creation time lies after the cluster’s retry fence and no more than five minutes ahead of the leader’s clock; a reference that fails either check is refused before any effect.

How A Repetition Is Answered

The leader serializes the requests that carry one reference, then consults its record:

  • No record. The request is admitted as new, subject to the checks above.
  • Applying. The request joins the admitted execution and waits for it. After a leader change, the new leader resumes every applying record on its own in its next reconciliation pass, which runs every 250 milliseconds, so the work completes whether or not anyone repeats it.
  • Finished. The request returns the retained outcome once that outcome is authoritative on each node in the leader’s current completion participant set. The leader can retire an unreachable node from that set; the node must catch up before its local reads reflect the completed effect. If the wait fails, the reply is OutcomeUnknown with cause NotYetAuthoritative, and a later repetition returns the outcome.

The first reply is itself rebuilt from the durable record, so the reply to the original attempt and the reply to a repetition are identical except for their origin. The record keeps the disposition, message, diagnostics, per-statement outcomes, transaction status, operation admission, and the archive summary of a completed backup. It does not keep the typed inspection, WASM state, or resource description that a describing statement returns, because those statements are reads and record nothing.

An execution whose attempt returns OutcomeUnknown, a leader redirect, or TransactionDetached stays applying. The finalization boundary returns that disposition unchanged and leaves recovery under the original reference to determine the terminal outcome. Reconciliation follows the same rule when an attempt is interrupted after admission.

The same rule covers every transaction control:

  • BEGIN. The new transaction’s identity is fixed when the request is admitted. Repeating a BEGIN whose reply was lost returns the same transaction and binds the repeating session to it, instead of opening a second one.
  • Appends. Each statement of a request is recorded under an identity derived from the request’s reference and the statement’s position in it. Repeating an append with the same reference, position, and text returns the admission recorded for it, without preflighting it again or renewing the transaction’s activity; the transaction is not appended to twice.
  • COMMIT. Repeating a commit joins the committing execution or returns its recorded outcome.
  • REVERT. Repeating a revert of a transaction its owner already reverted succeeds again.
  • Multi-statement requests. A request of several statements is one execution with one outcome per statement, and a repetition after a leader change or a full restart returns those outcomes without repeating any effect.

Conflicting Reuse

A reference names one request forever. Repeating it with a different owner, domain, expected position, or content, checked in that order, is refused as ExecutionReferenceConflict naming what differed, and nothing about the new request is admitted. A session bound to a transaction other than the one the reference was recorded against is refused as a position conflict. When two leaders race and the replicated state machine detects the conflict after the leader’s local check, the refusal carries the same typed ExecutionReferenceConflict and kind.

Bounded History And Expired Identities

The history is bounded by count, 65,536 records by default, counting applying records, finished records, and tombstones together. When it is full a new reference is refused with an explicit capacity failure, and admitted work is never evicted to make room. A finished record is retained for the retry validity after the command finished, 15 minutes by default, and then reclaimed; an applying record is never reclaimed. Reclamation advances a durable, monotonic retry fence first, so a reclaimed reference, repeated at any later time, fails the admission check and never starts its effect again. The refusal is ExecutionReferenceExpired whether the history still holds a tombstone or only the retry fence. The outcome of the original attempt can no longer be recovered.

Why Aggregate State Proves Nothing

A client that lost the reply to an append, or to a commit, might be tempted to look at the transaction instead: if it is COMMITTED and its operation count moved, the append must have been in it. That inference is false. Another session can attach the transaction, commit it without the outstanding append, and leave the same counts. Finishing a transaction clears its queued statements, so an append that was never admitted cannot match anything afterwards, and the only witness of what happened to a particular request is the record of its own reference. A client therefore reports an append or a commit only from the outcome recorded for that exact request.

sequenceDiagram
    participant A as Session A
    participant L as Leader
    participant B as Session B
    A->>L: append S (reference R, expected position 0)
    Note over A,L: R is not admitted yet
    B->>L: attach transaction T, taking it over
    B->>L: COMMIT
    L-->>B: CommandCompleted: T is COMMITTED
    L-->>A: R refused: S was never admitted, and T holds no S
    Note over A: T being COMMITTED says nothing about S

Domain Mutation Ownership And Plan Fencing

Exact recovery answers “did this command happen”; ownership and fencing answer “could anything else have changed the domain under it”. Both are control-plane contracts, and a client observes them only through the outcomes they produce.

One Owner Per Domain

A command that changes a domain acquires the domain’s mutation lease when it is admitted, and a transaction acquires it when its commit is admitted. The lease is replicated state. It names its owner, a command’s execution reference or a transaction’s identity, and carries a recovery fence: the log position of the entry that acquired it. Every write to the domain’s replicated state, its definition, schedule, and lifecycle, must present exactly the lease currently held. A write that presents none while a lease is held, a different one, or one from a superseded acquisition is refused, and so are automatic schedule changes and clock-authority updates while any lease is held. The command or transaction releases the lease when it finishes, whatever its outcome.

Because the lease lives in replicated state rather than in a leader’s memory, it survives leadership changes and restarts. A new leader resumes the applying record, or the committing transaction, that holds the lease and continues under it; a deposed leader can no longer propose, and a stale acquisition fails on its fence. A second mutation of the same domain is therefore refused at once, with a failure that names the owner, rather than queued behind the first, while mutations of other domains proceed in parallel. A client that receives such a refusal decides whether to send the command again once the owner has finished.

Plans Apply Only To What They Planned

A command or commit is planned from one captured set of inputs: the domain’s state, its resources and completed versions, its schedule, and cluster membership, voters, and cordons. The replicated state machine applies the plan only if those inputs are still equal to what was captured, and refuses it otherwise, naming the input that changed. Eligibility that lives outside consensus, liveness and incarnations, is checked again by the leader immediately before it publishes. A plan can therefore never publish a candidate derived from one schedule over a newer one: a RELOCATE planned before a schedule revision fails rather than overwrite that revision.

What happens after such a refusal depends on who owns the plan. An ordinary command applies through a frozen internal attempt; when its captured inputs change before its effect is recorded, it keeps the failed attempt and starts a new attempt derived from the current revision under the same execution reference, so its caller sees one outcome. An explicit transaction freezes its plan when COMMIT is admitted: stale inputs at admission leave the transaction OPEN, and a step whose inputs changed after admission fails the transaction with the applied prefix preserved, rather than being replanned against state the client never reviewed. Transaction Quiescence And Impact Inspection defines the frozen plan and the preview it is checked against.

Transactions Over The Protocol

A transaction is replicated control-plane state; its binding to a session is not. Control Plane owns the lifecycle, the eligible statements, and the limits, and Transaction Quiescence And Impact Inspection owns planning, previews, and reports. This section describes how a transaction travels over the protocol.

Opening. BEGIN is an ordinary CommandRequest for the selected domain, which must already exist, and every later request of the transaction names that domain. The outcome’s TransactionStatus carries the transaction’s identity, domain, state, and accepted and applied operation counts; the identity is the durable handle a client keeps. One request may also begin the transaction, append to it, and commit it at once.

Appending. Each append carries the position it expects: the number of operations the transaction had accepted, as the client last learned it. A statement accepted into the transaction returns TransactionOperationAdmission with its one-based operation number and the whole-transaction preview identity after it. An append that is refused leaves the transaction and its position unchanged, so the client corrects it and appends again. Other sessions keep seeing committed configuration until the commit.

Binding, attach, and takeover. The session that begins a transaction is bound to it. The binding is soft state in the leader’s memory: it routes commands, and it is gone after a leader change or when the session ends. A session binds an existing transaction with AttachTransactionRequest, which the leader grants only to the transaction’s owner; the reply is TransactionAttached with the current status, TransactionAlreadyFinished with the final status and aggregate outcome while its tombstone is retained, or a failure for an identity that is unknown or retained no longer. A leader that holds no binding for the session’s transaction answers the session’s next transaction request with TransactionDetached, and the client attaches again and repeats the request under the same reference. A finished or failed attach does not resolve commands whose replies were lost: the client repeats each under its own execution reference and expected position. The leader returns a recorded outcome before planning a new request; a command it never admitted receives a failure. Attaching from a second session takes the binding over, and the displaced session’s next transaction request receives TransactionTakenOver before anything is admitted. While a session holds a transaction, the server refuses the requests that belong to the session rather than the transaction, subscribe and unsubscribe and domain clock attach and detach, and the Rust client and the console refuse USE and LIST DOMAINS themselves.

Committing. COMMIT may name the preview identity the client reviewed: the transaction identity, its accepted position, and the fingerprint of its planning basis, taken from the last append’s admission or from inspecting the attached transaction. If that preview no longer describes the transaction, the commit is refused with PreviewStale naming the expected and the current identity, nothing applies, and the transaction stays open and bound. A preview fences only a commit of a transaction that the same request did not itself begin or append to, because such a request has already moved the transaction past what the client reviewed. A commit that is admitted runs its frozen plan step by step; each step is atomic, and a failed step leaves the applied prefix in place rather than rolling back the transaction. A commit’s outcome arrives only when the final step is usable on every live node, and a client waits through COMMITTING for the outcome recorded under the commit’s own reference.

Inactivity and retention. An OPEN transaction expires once it has been inactive for its idle timeout, 15 minutes by default, whether or not a session is bound to it. Attaching it, appending to it, and a commit’s admission or failure renew that deadline; an open connection, a bound session, and reads of the transaction do not. The deadline is a durable UTC instant, so time the cluster spends stopped counts toward it, and an overdue transaction expires once the cluster runs again. A COMMITTING transaction never expires. A finished transaction remains as a tombstone, 15 minutes by default, during which attach and inspection report its outcome; afterwards its identity is unknown.

Ending a session. A session that ends uncleanly, through a transport failure, a node stop, or the server ending it, only releases its binding; the transaction stays open for attach until it expires. A session that the client closes cleanly, with no request in flight, reverts the open transaction it is bound to on the leader, which is how an interactive client that exits discards its unfinished work. Neither ending affects admitted work: an append already admitted stays admitted, and a COMMITTING transaction continues without its client.

Read consistency. A read, such as SHOW, DESCRIBE, or LOOKUP, is served by the node the session is on from its locally applied replicated state; it is not a linearizable read. Once a command completes, its effect is applied on every node in the leader’s completion participant set, so a later read through one of those nodes observes it. A node the leader has retired can still serve an older local state until it catches up. Completion for a session bound to a transaction resolves against the committed configuration with the session’s own queued statements applied, while every other session sees committed configuration alone. Inspecting a transaction reads it without changing its binding, domain, activity, or queue position.

Leader Discovery, Redirect, And Reconnect

What A Session Is Told

Every session receives LeadershipObserved first and DomainsObserved second, and each again whenever it changes; the serving node checks leadership every 250 milliseconds. Leadership is one of three states: the serving node leads, a remote node leads, with whichever of its endpoints discovery has established, or no leader is known. Selecting a domain with SelectDomainRequest opts the session into that domain’s observations as well: a ClusterObserved summary and a DomainSnapshotObserved carrying the domain’s live graph as a JSON document and its declared entities, at once and then every 500 milliseconds. A session that selects no domain receives neither. Server notices report runtime and control-plane errors and cluster events as text for display. A session that falls behind the node’s notice bus skips the notices it missed; the node logs how many. The Rust client holds at most 128 notices and 1 MiB its caller has not read. A notice that does not fit drops the ones held, and the caller’s next read reports that gap before the notices that follow it. Notices end with the session that delivered them, and the client’s notice stream continues with the next session it opens.

There is no separate discovery request. A client learns where the leader is from these observations and from the redirects in its replies. Endpoints come from the cluster’s discovery, which carries each node’s advertised gRPC and console URLs. A redirect never guesses: while no leader is known it names none, and while discovery has not established a leader’s endpoint the redirect names the leader without it.

Which Requests Need The Leader

The node a session is on serves completion and choice lookups, listing and selecting domains, subscriptions, domain clock attachments, producers, and the reads its locally applied state can answer: SHOW CLUSTER STATUS, SHOW TRANSACTIONS, SHOW CREATE, SHOW UDFS, SHOW INGESTORS, SHOW PLACEMENTS, SHOW RELAY MATERIALIZED STATE, LOOKUP, and the DESCRIBE statements of domains, resources, endpoints, lookups, placements, UDFs, relocations, relays, ingestors, junctions, deduplicators, reingestors, correlators, reorderers, window processors, WASM processors, and emitters. Every other statement needs the leader, and so do every transaction request, transaction attach and inspection, and uploads. A follower answers them with LeaderRedirect before anything is admitted, and the client reconnects to the leader and repeats the request. Because persistent commands and transactions only ever run on the leader, a follower holds no execution state a client could lose.

The console session is the exception: the web console is served only by the leader. A console session on any other node, and a console session whose node stops leading, is ended with a SessionEnding that names the leader, and the console reconnects to the leader’s console endpoint.

Deadlines And Cancellation

The server imposes no deadline on a request’s wait and no idle timeout on a session, and neither transport sends keepalives; a connection is judged dead by its transport or by the client’s own deadline. A client deadline bounds only the client’s wait. It never cancels admitted work, and Command Completion owns what the work does meanwhile. A client that gives up on an uncertain command keeps its execution reference and can recover the outcome later, within the retry validity.

The Rust client bounds each connection attempt by connect_timeout (10 seconds by default), each request by request_timeout (120 seconds), and each public call, including all of its retries and redirects, by retry_timeout (120 seconds). Without keepalives, a request that outlives request_timeout is the client’s only sign that its session may be dead, so it ends every request still waiting on that session, and the next request opens a new one. While an election converges it retries with a delay that starts at 100 milliseconds and doubles up to one second. When a call’s deadline passes before a command’s outcome is known, and the command may have been admitted, the call fails as uncertain and names the execution reference. The Rust client, the CLI, and the console never send CancelRequest: a caller that stops waiting only drops its waiter, and the reply, when it comes, is discarded.

nervix-cli backup --timeout extends its request and retry budgets by the named quiesce timeout, leaving the ordinary request budget for command admission, capture and the reply.

For native Rust sessions, the client loads a Hickory resolver during setup or uses one its owner provided. It resolves the hostname of each selected server, seed, and redirect on a new connection attempt, preserving the advertised URI’s authority and TLS name. Tonic’s connection timeout spans DNS, address attempts, and TLS. A lookup takes at most 30 seconds when no shorter connection deadline cancels it. Failed resolution is a connection failure under the same bounded reconnect policy. Server-internal sessions to a peer’s session service reuse the node’s loaded resolver. Browser WebSocket and fetch resolution remains owned by the browser. Name Resolution describes the resolver, its bounds and its failures.

Reconnecting A Session

A lost session takes everything session-scoped with it. As soon as its frames stop, the Rust client reports an interruption for every subscription and clock it held, and it rebuilds the session in a fixed order. Domain clock attachments and subscriptions come before the transaction, because a session that holds a transaction refuses both, and the transaction comes before any command that belongs to it:

  1. It opens a new session against the servers it knows: its configured seeds, then at most 32 endpoints it learned from redirects and leadership observations, oldest first, then the server it was using, with the same backoff as an election.
  2. It attaches every domain clock it followed.
  3. It publishes the new session to its callers and ends the old one. When the old session was still open, as after a leader redirect, ending it reports an interruption for every subscription and clock it still held. Its frames stop before the interruption, so nothing it delivers afterwards can make an attachment look held.
  4. It attaches every clock the old session began to follow while the new one was opening, opens again every subscription the server had acknowledged, each as a new generation, and opens its native producers and consumers again.
  5. It attaches its transaction before it repeats any command that belongs to it.

The requests of steps 2 and 4 are sent before step 5 begins, not merely started, so the transaction can never overtake them on the ordered lane. Only then does the client repeat outstanding commands, each under its original execution reference and, for an append, its original expected position.

The new session can still refuse a restoration: a relay may no longer exist or not be running yet, the session may already hold as many requests as it admits, or a transaction a caller attached meanwhile may have made it refuse session-local requests. The client then reports a restoration failure for that subscription or clock, carrying the server’s message and the wait before the next attempt, and sends the request again on the same session. A restoration the session leaves unanswered past the request timeout ends that session instead, as any request that outlives the timeout does, and the next session restores it again. The wait starts at one second and doubles after each refusal up to thirty seconds. It keeps trying for as long as the session stays open and the client still wants the subscription or follows the clock; the next session starts again from one second. An attach answered DomainClockAlreadyAttached means the session already holds the attachment, so the client follows the clock on that session and reads its frames from then on.

The client’s event streams outlive the session. A caller reading subscription events, clock events, or notices keeps reading across the replacement: the interruptions mark the gap, and events of the new session follow them. Reading subscription or clock events opens a new session while one waits to be restored; reading notices never does, and waits for the session something else opens. A failed attempt to open one is returned to the reader, and its next read tries again.

The web console follows the same order on every connection: it selects its domain, opens again the subscription of every tab the server had acknowledged, and attaches its transaction before any ordered request it holds goes out. A restoration belongs to the connection that sent it; when that connection ends before the reply, the next connection restores the tab again. A transaction that has finished, or can no longer be attached, ends only the commands issued in it: restorations, subscription changes, and commands issued outside the transaction keep their place.

stateDiagram-v2
    [*] --> Connecting
    Connecting --> Open: a server accepted the session
    Connecting --> Failed: retry deadline passed
    Open --> Open: reply routed by request identity
    Open --> Redirecting: LeaderRedirect names the leader's endpoint
    Open --> AwaitingLeader: LeaderRedirect names no endpoint, or OutcomeUnknown
    AwaitingLeader --> Open: back off, then repeat under the same reference
    Redirecting --> Restoring: new session on the leader
    Open --> Lost: transport failure, session ending, or protocol violation
    Lost --> Restoring: a known server accepted a new session
    Lost --> Failed: retry deadline passed
    Restoring --> Open: clocks, subscriptions, and transaction attached again
    Failed --> [*]

A command moves through its own states within that session state machine:

stateDiagram-v2
    [*] --> Prepared: one execution reference, domain, expected position, and preview captured
    Prepared --> Sent: new request identity
    Sent --> Completed: CommandCompleted
    Sent --> Failed: RequestFailed, ExecutionReferenceConflict, ExecutionReferenceExpired, or TransactionTakenOver
    Sent --> Stale: PreviewStale
    Sent --> Sent: LeaderRedirect or OutcomeUnknown, repeated under the same reference
    Sent --> Reattaching: TransactionDetached
    Reattaching --> Sent: transaction attached again
    Sent --> Uncertain: session lost after sending
    Uncertain --> Sent: new session, same reference
    Uncertain --> GaveUp: caller deadline passed
    GaveUp --> Sent: caller repeats the same prepared execution
    Completed --> [*]
    Failed --> [*]
    Stale --> [*]

Row Subscriptions

A subscription is a read-only view of one relay, owned by the session that opened it. Its rows travel from the relay owner to the subscriber’s node as Arrow batches, and from there to the client as typed Row frames. Sessions owns the NSPL form and what a user sees; this section explains the protocol and data path behind it.

Opening A Subscription

SubscribeRequest names the domain, carries exactly one CREATE SUBSCRIPTION statement, and selects the subscription type. The statement carries the subscription’s name, relay, BLOCKING or DROPPING delivery, sample rate, and WHERE predicate; the server parses it once and compiles the predicate Model directly. The type is required, and Row, the only type, must be named: a request that omits it is refused rather than defaulted, and a value the server does not support is refused as UnsupportedValue rather than replaced by another encoding. A subscription’s type never changes.

Opening runs on the session’s ordered lane:

sequenceDiagram
    participant C as Client
    participant S as Session on node N
    participant G as Every other live node
    participant O as Relay fan-out on N
    C->>S: SubscribeRequest(domain, CREATE SUBSCRIPTION, Row)
    S->>S: validate against the scheduled relay definition, compile the predicate
    S->>S: take the next generation, wait for N to apply the current runtime revision
    S->>S: take N's interest lease on the relay and advertise it
    S->>G: wait until each publishes N's interest at the leased version
    S->>O: attach under an equal relay definition
    S-->>C: SubscriptionOpened(name, generation, Row, schema)
    Note over S,O: rows flow only after this reply is queued
    O-->>S: Arrow batches
    S-->>C: SubscriptionRows(name, generation, frames)

A refusal at any step releases everything taken before it, and the name stays free. The generation is a per-session counter that starts at 1, so a handle, the name together with its generation, is unique within its session only; a generation taken by a request that then failed is simply skipped. The visibility wait is what makes the reply meaningful: by the time a client reads SubscriptionOpened, every live node already routes the relay’s batches to the subscriber’s node, and an advertisement left over from before an earlier withdrawal cannot satisfy it.

The reply carries the schema and precedes every row. Delivery is held back until the reply is queued on the control lane, which the transport always serves first. When the reply cannot be queued, because the request was cancelled, the session ended, or the reply did not fit the session limits, the generation is abandoned before it delivers anything, so a client never receives rows it has no schema for. Batches the relay published during the opening are not delivered.

Encoding From Arrow Columns

The subscriber’s node encodes Row frames directly from the Arrow batch it received. It validates the batch against the announced definition once, then writes each selected cell from its typed column into the FlatBuffers builder; no JSON object, map of fields, or intermediate row is built. Every NSPL type has exactly one cell type, DATETIME travels as signed nanoseconds, floats keep their exact bits, and BYTES travels as raw octets. A sensitive field is written as RedactedCell in every row, whether or not it held a value, so the frame reveals neither the value nor its nullness. A null in a nullable field is NullCell; a list element is never null or redacted.

A frame holds rows of one concrete branch. Its branch key is written once per frame, with sensitive key fields redacted, and is absent exactly when the relay is unbranched. The encoder groups the selected rows of a relay batch into runs of adjacent rows with the same branch key, so interleaved branches produce one frame per run.

A frame holds at most 256 rows and at most the frame limit in bytes. When a row does not fit the frame being built, that frame is finished and the row starts the next one. A row that does not fit an empty frame cannot be delivered at all; the encoder then reports every selected row of that relay batch as skipped with cause EncodingFailed, and the subscription stays open. Each subscription encodes its own frames, so two subscriptions to one relay never share a frame.

Selection, Sampling, And Delivery

Each row passes the subscription’s predicate, then its sampling, then its delivery mode:

The delivery owner supplies its node’s bounded executor to predicate evaluation together with the batch’s domain-time snapshot. The selector borrows that executor for the call.

  • Predicate. The WHERE expression is an ordinary BOOL expression over the relay record. A row is selected only where it is true, so a null result does not select it and is not reported. When a predicate is present, each relay batch takes one domain-time snapshot on the subscriber’s node, so every row and every volatile function of the batch sees the same instant. Rows whose evaluation fails are counted and reported once per batch as skipped with cause FilterFailed, and a batch whose domain time cannot be read is skipped whole with cause DomainTimeUnavailable.
  • Sampling. BATCH SAMPLE RATE is applied to each selected row after the predicate, with a pseudo-random draw shared by every subscription on the node. A rate of 1 passes every row and a rate of 0 passes none.
  • BLOCKING. A frame waits for room on the session’s subscription lane. While it waits, the subscription stops taking batches, and the relay’s fan-out waits for it: on the relay owner that holds back the relay itself, including its runtime consumers, and on another node it holds back that node’s ordered delivery from the owner, which waits within its relay admission bounds.
  • DROPPING. A frame that finds the lane full is discarded, and its rows are counted. Before the subscription’s next rows, the session sends SubscriptionDeliveryLost with the count, if that report itself finds room; otherwise the next rows are discarded too and counted with the rest.

The subscription lane holds 16 frames shared by every subscription of the session. One slow BLOCKING subscription therefore also holds back the session’s other BLOCKING subscriptions and makes its DROPPING ones drop, while replies and session events keep flowing on the control lane.

Fan-Out Across Nodes

The relay owner is the only fan-out source. It delivers each admitted batch to its local subscriptions, to every node that advertises interest in the relay, and to its runtime consumers. It encodes one Arrow body for the whole subscription fan-out and shares it across nodes; a node that also hosts a runtime consumer of the relay receives the batch once, for that consumer, and its subscriptions piggyback on that delivery. Each batch reaches a subscription at most once. Delivery to a node is ordered per branch; batches of different branches may arrive in a different order than the owner published them. The subscription copy carries no acknowledgements, so a subscriber never holds back a source’s acknowledgement beyond the time its BLOCKING backpressure takes.

Generations, Endings, And Cleanup

Every frame about a subscription carries its handle. A name reused after deletion, or after the server ended its generation, opens a new generation, and the protocol never lets a frame about one generation be taken for another. That fence matters: the SubscriptionEnded of an earlier generation can still be queued when the same name is opened again, and because replies travel ahead of queued subscription frames, it can reach the client after the new generation’s opening reply.

The server ends a generation with SubscriptionEnded, its last frame, in two cases. RelayChanged means the relay was redefined, so the schema the subscription announced no longer describes its rows: a payload field’s name, position, type, nullability, or sensitivity changed, the relay became branched or unbranched, or its branch or branch key schema changed. RelayRemoved means the relay, or its domain, no longer exists. The relay fan-out ends every subscriber before a batch of another definition can reach it, so no row of a redefined relay is ever read against the earlier schema. Stopping and starting the domain, a rebuild that keeps the definition, a capacity or materialized-state change, and an ownership move keep the subscription delivering.

Deleting a subscription, or ending its session, withdraws it at once, even while its client reads nothing: the frames it still has queued are discarded, it stops waiting on the lane, it releases its relay receiver and its interest lease, and only then is the reply to the deletion queued. Nothing about the deleted generation follows that reply. A node advertises interest in a relay exactly while at least one of its subscriptions holds a lease on it, and every lease is released exactly once.

Gaps

A subscription is a live view. It keeps no offsets, is not persisted, and replays nothing. The protocol reports the losses the server knows of and states which ones it cannot report:

LossReported to the client
Rows a DROPPING subscription discardedSubscriptionDeliveryLost before its next rows. A count still outstanding when the generation ends, or when no further rows follow, is not reported.
Rows whose predicate failedSubscriptionRowsSkipped with FilterFailed
A batch without domain timeSubscriptionRowsSkipped with DomainTimeUnavailable
A batch with a row too large for a frameSubscriptionRowsSkipped with EncodingFailed for every selected row of the batch
Batches published while the subscription was openingNot reported; they precede the subscription
Batches lost in transit while relay ownership moves or a node-to-node delivery failsNot reported; the nodes log them
The session itselfNot reported by the server, whose session is gone; the Rust client reports an interruption of each subscription the server had not ended
A restoration the new session refusedNot a server event; the Rust client reports each refused attempt, and the gap lasts until an attempt succeeds

Restoration And Bounded Consumers

Nothing on the server restores a subscription. A client that wants one to outlive its session opens it again on the next session, which is a new generation with a new schema announcement, and must treat the time between as a gap. The Rust client does this for every subscription it holds that the server acknowledged and did not end, and fences its restoration by generation: a late reply for an attempt the caller has since cancelled is followed by a deletion before the name can be reused, and rows of a generation the client no longer holds are ignored. A restoration the new session refuses is reported as a restoration failure and sent again on that session after a growing wait, as Reconnecting A Session describes. Each interruption and refused attempt reaches the caller’s next read as soon as the client records it, without waiting for another change to the subscriptions it holds.

A generation the server ended is never restored, because a new session would not change why it ended: its relay was redefined, so the announced schema no longer describes its rows, or the relay no longer exists. The Rust client applies SubscriptionEnded as the frame arrives, before it queues the end for its caller, so the subscription reads Ended from then on, even to a caller that has not read the end yet. It stays Ended across later sessions until its caller subscribes again under the same name, which opens a new generation that announces the relay’s current definition, or deletes it. The end reaches the caller exactly once, after the generation’s other events: when the session ends before the caller reads it, the client discards that session’s unread events as it does for every subscription, reports no interruption, and reports the end itself. The one exception is a subscription whose events overflowed the client’s queue, for which the consumer overflow stays the last event of its generation.

A deletion asks the server only while an open session may hold the subscription. A subscription whose session ended, including one whose delivery had already failed, is deleted without a request, and so is an interrupted subscription the current session refused to open again: no session holds either, so the client simply stops wanting it and releases the name. A subscription the server ended is deleted without a request too: the server keeps an ended name only until it is reused or its session ends, and nothing of the ended generation remains to release. A deletion whose session ends before it is answered is complete, because the subscription ended with that session. A deletion that waited for an opening or a restoration still in flight asks the server only if that request opened the subscription. Only a name the client never held is always asked about, on a new session if the current one ended, and a refusal leaves that name free. Each subscription moves through Creating, Active, Interrupted, Restoring, DeliveryFailed, Ended, Closing, and DeletionFailed:

stateDiagram-v2
    [*] --> Creating: subscribe
    Creating --> Active: opening reply
    Creating --> [*]: refused
    Creating --> Interrupted: session lost after the server opened it
    Active --> Interrupted: session lost
    Interrupted --> Restoring: next session
    Restoring --> Active: opening reply for a new generation
    Restoring --> Interrupted: refused; reported, then retried on the same session
    Restoring --> Interrupted: session lost; retried on the next session
    Active --> DeliveryFailed: the client's event queue overflowed
    Active --> Ended: SubscriptionEnded
    DeliveryFailed --> Ended: SubscriptionEnded
    Ended --> Creating: subscribe under the same name
    Active --> Closing: unsubscribe
    Creating --> Closing: unsubscribe
    Restoring --> Closing: unsubscribe
    DeliveryFailed --> Closing: unsubscribe while its session is open
    Interrupted --> [*]: unsubscribe, which no session is left to answer
    DeliveryFailed --> [*]: unsubscribe after its session ended
    Ended --> [*]: unsubscribe, which needs no request
    Closing --> [*]: deleted, the session ended, or the opening it waited for was refused
    Closing --> DeletionFailed: deletion refused
    DeletionFailed --> Closing: unsubscribe again
    DeletionFailed --> [*]: the session ended

A consumer must stay bounded however fast rows arrive, and must never let an unread event hold back a reply. The Rust client charges each subscription event for the whole frame its rows keep alive and for the event itself. One subscription holds at most 32 events and one frame of the frame limit, 4 MiB, with the event that carries it; all subscriptions together hold four times as much, 128 events and 16 MiB of frames with their events. An event that does not fit drops that subscription’s queued events, reports a consumer overflow, and marks the subscription DeliveryFailed; the reader that routes replies never waits. An event stops counting once the application reads it, so a subscription whose events are read before the next one arrives stays within its own allowance however wide the server’s frames are, and overflows only when other subscriptions have filled the total. The gRPC receiver shares its receive buffer with a frame of 64 KiB or more rather than copying it, so a retained frame costs about its own size: four subscriptions each holding one 4.17 MB frame kept 16.78 MB live for 16.69 MB of frames, and the receiver keeps the allocation of the last large frame it read as its receive buffer after that frame is released. The CLI bounds its terminal output to 128 lines and 1 MiB, cuts a line above 8 KiB, and reports how many lines it omitted. It keeps reading every stream across reconnects, and when the client cannot reopen a session to restore subscriptions or clocks it prints why while the client keeps trying. The web console keeps at most 256 lines and 256 KiB per REPL and per subscription tab, and marks where it omitted earlier lines. It keeps the latest 256 commands and 256 KiB of its command history and the snapshot of the one domain it observes. It holds at most 64 requests of its controls waiting for a connection, carrying at most 4 MiB of text, and at most 256 held or awaiting their reply, carrying at most 16 MiB, and sends the ordered ones only while the server has room for them in flight; a request past either bound is not sent, and the control that issued it reports why. It drops the parts of a reply nobody awaits, such as a superseded completion.

A tab whose generation the server ended with SubscriptionEnded turns ended in the web console. It keeps its rows and the reason, and it is not restored on a later connection, which would not change why the server ended it. The operator resubscribes it under the same name, which opens a new generation announcing the relay’s current schema, or closes it. Closing an ended tab sends no deletion: the server keeps an ended name only until it is reused or the session ends, and nothing of the ended generation remains to release.

Domain Clock Attachment

A session can follow the clock of a domain. ATTACH DOMAIN CLOCK; and DETACH DOMAIN CLOCK; reach the server as AttachDomainClockRequest and DetachDomainClockRequest naming the active domain, and the reply to an attach carries the START generation and the clock as the serving node has it installed. Sessions owns the public contract and Domain Clock owns how an installation and accepted progress become observations; this section places the attachment in the protocol.

An attachment is not a subscription, and the protocol keeps the two apart end to end. It reads no relay, takes no interest lease, and has no name or generation of its own: it is keyed by its domain, so a session follows each domain clock at most once, and every frame about it names the domain. Its frames travel on the control lane rather than the subscription lane. Attach and detach run on the ordered lane, in order with the session’s commands. The attach reply is queued before delivery starts, so its state precedes every frame. If the installation has not changed since the reply and the node already holds a tick of that generation, its newest tick is the first frame after the reply. Each changed installation arrives as DomainClockObserved before any tick of its generation. A committed unassigned authority produces uninstalled and then the same mapping on reassignment; a direct authority move retains the mapping and produces no state frame.

DomainClockTicked carries the domain, generation, nonzero tick id, logical boundary, the authority’s UTC observation, and the serving node’s logical reading from a clock snapshot taken when the frame is built. The three timestamps use signed Unix nanoseconds. The serving reading lets a client anchor itself even when its local UTC differs from the cluster’s. The runtime publishes only accepted progress, fenced by generation and authority; a late attachment reads the newest accepted tick without waiting for another one. Tick ids can skip when the authority coalesces missed periods or a slow client’s pending tick is replaced.

A state frame waits for room on the control lane, and changes published meanwhile collapse into the newest installation. A tick occupies one replaceable slot per attached domain, including while the control lane is full, so a slow client never accumulates a tick backlog. Delivery withdraws a pending tick when state changes and rechecks installation before selecting another. Detach stops delivery before its reply is queued, so nothing about the domain follows that reply. When the serving node no longer has the domain, DomainClockAttachmentEnded with reason DomainRemoved is the last frame about the attachment.

Every node serves attachments from its own installation, which it derives from the same committed revision as every other node, and from progress it already accepted, so an attachment adds no interconnect traffic and survives nothing: it ends silently with its session. The Rust client reports the gap as an interruption when the session ends, clears its previous tick, and attaches every clock it followed again on its next session. An attach that session refuses is reported as a restoration failure and sent again on the same session after a growing wait; an attach it leaves unanswered past the request timeout ends that session instead, and the next session attaches the clock again, which a caller already waiting for clock events opens at once. A reply that the session already follows the clock moves the attachment to that session: the caller keeps the clock the previous session last reported, without a tick, until that session’s later state or tick frames arrive. When the new session’s attach reply arrives before the old session ends, as after a leader redirect, the attachment moves without an interruption: the client reports the clock only if it changed, keeps the newest tick of a clock still paced in the same generation, and reports a tick of the new session only when it is newer. A detach the client sends while a clock waits to be restored reaches the current session: a closed session is opened again first, which attaches the clock before the detach releases it, and an open session that has not restored it answers DomainClockNotAttached, after which the client no longer follows the clock. The client sends no restoration attach of a clock behind a detach of it: a detach waits until every restoration attach already decided for the clock is sent, so each reaches its session first, and a restoration that would attach the clock while the detach is in flight waits for the detach to end and attaches it only if the client still follows it. After DomainClockAttachmentEnded, a detach is answered DomainClockNotAttached and an attach is served as a new attachment. The shared binding exposes this event stream through nx_session_next_clock_event and a dedicated retained nx_clock_event handle, and the clock the client holds for each followed domain, starting with the one the attach reply carried, through nx_session_domain_clock and a retained nx_domain_clock handle. The web console attaches the selected domain clock once per session, detaches it on selection changes, and restores it on reconnect; its REPL sends the same typed requests for explicit attach and detach statements. These requests enter the console’s bounded session hand-off; a local refusal is shown to the operator and sends no request. Both requests are refused while the session holds a transaction, like every other session-local request.

A node answers an attach only once it has installed the cluster’s committed domains since it started. Before that it holds no domain, so it cannot tell one the cluster lacks from one it has not installed yet, and an attach that reaches a restarting node waits on the ordered lane rather than hearing that a live domain was not found. The server bounds that wait only by the session, as it bounds every wait, so a client that needs an answer sooner applies its own deadline. Ordered requests the session sends after the attach wait behind it. DomainNotFound therefore always means that the committed state the node installed holds no such domain, and the Rust client ends an attachment it was restoring when a new session answers it so, as it does after DomainClockAttachmentEnded.

The CLI’s domain-clock subcommand uses the Rust client’s typed attach reply and clock event stream. It prints the reply and then the same state, tick, interruption, and end lines as its REPL, including the fresh state after the client restores an attachment. Ctrl-C sends a detach request before the process exits; an attach refusal exits nonzero with its typed reason.

Producers

A session can open producers that submit typed batches to a client ingestor. Ingestors owns what an ingestor admits and how an outcome is decided, and Sessions owns the public contract of a producer; this section places producers in the protocol.

Opening, Submitting, And Closing

OpenIngestorRequest names a domain explicitly, the ingestor, the fields the producer expects, and the credit it asks for: how many batches and how many bytes it may have outstanding. The open runs on the ordered lane. The serving node checks the session’s transaction binding, the requested limits, the committed domain state and schedule, and the ingestor’s kind, reserves room in the session’s budget of 32 producers and 32 MiB and in its own node budget of 128 MiB, and attaches the producer through the node that executes the ingestor, which checks the expected fields against the execution it runs. The reply is ProducerOpened or a typed ClientProducerRefusal with a message; a refused open leaves nothing attached and reserves nothing. ProducerOpened carries the domain, the ingestor, and a ClientProducerDescription: the attachment identity, the input schema, the domain’s START generation, the endpoint contract digest, the ingestor’s window, ACK timeout and retry backoff, the granted credit with the largest batch one submission may carry, and whether admission is open.

The request identity of the open names the producer: every later request and frame about it carries that identity as its ProducerId, so a client needs no second identity space. The producer accepts batches from the moment its reply is queued. It belongs to the session: USE does not retarget it, and it cannot be opened while the session holds a transaction.

SubmitBatchRequest carries the producer and one canonical Arrow IPC stream as opaque bytes. The receive loop checks the batch against the producer’s credit, takes its share, and hands it to the producer’s task without waiting for anything; the batch’s reply, a SubmissionOutcome, follows whenever its outcome is decided. The session never decodes a batch: the node that executes the ingestor validates and admits it. A batch beyond the credit is answered at once as not admitted with CreditExceeded, and the producer is closed and later ended as ProtocolViolated; the batches it already submitted still receive their outcomes.

CloseIngestorRequest stops admission for the producer: its queued batches are refused, its admitted batches are answered as they resolve, and the close is answered Closed once every batch has its outcome, or NotOpen when the session holds no producer by that identity.

Outcomes, Admission, And Ends

SubmissionOutcome carries one terminal ClientSubmissionOutcome and a bounded, non-sensitive message. The four outcomes keep the boundary’s four facts apart:

  • NotAdmitted. No row entered the graph. The cause is an InvalidBatch defect, Suspended, Busy, Draining, ProducerEnded, or CreditExceeded. Only Suspended and Busy are temporary.
  • Completed. The batch’s source acknowledgement root resolved successfully.
  • ProcessingFailed. The batch was admitted and its acknowledgement failed, with AckTimedOut or Rejected. Some effects may have happened.
  • OutcomeUnknown. The batch may have been admitted, and no terminal result can be established: Interrupted when the ingestor’s execution stopped or the producer ended with the batch unresolved, OwnerLost when the node that executes it, or the connection to that node, was lost.

A transport receipt is never an outcome, and a reply is never sent before the outcome is decided. The session never replays a batch: the client decides, and only a temporary refusal is safe to send again unchanged.

ProducerAdmissionChanged tells the client that admission is Suspended or Open again. Changes are coalesced, so a client that reads late receives the newest state. ProducerEnded carries a ClientProducerEndReason and is the last frame about the producer; every batch it held has its outcome before it: EndpointChanged, EndpointRemoved, DomainStopped, Relocated, ShuttingDown, OwnerLost, or ProtocolViolated. Ingestors says which change causes each. A producer closed by its client ends with the close reply and no ProducerEnded.

During an ingestor alteration, admitted buffered batches keep their original outcome and credit while admission is suspended. The server drains them before publishing the replacement contract, including partial branch batches that must first pass through a shared relay. An intake hold and the full entity hold overlap, so no intermediate Open admits work between those phases. A failed drain keeps the committed contract and producer attachments; it does not replay an unresolved submission. A successful contract change delivers the admitted outcomes before EndpointChanged.

Ordering And Backpressure

Each producer has one task on the serving node that is the only writer of its replies and frames, so a batch’s outcome, the producer’s admission changes, and its end reach the client in the order the ingestor produced them. Outcomes, admission changes, and ends travel on the control lane. A batch keeps its share of the credit until its reply is queued, so a client that sends a batch only once an earlier reply freed room never exceeds its credit, and a producer whose client stops reading outcomes stops receiving room for new batches. The credit therefore bounds what a producer holds on the serving node, whatever the graph does, while commands, subscriptions, and clock frames keep moving beside it.

The ingestor admits a producer’s batches in the order they reach it. A batch refused as Busy or Suspended reaches it again only when the client sends it again after the backoff, behind every batch the client sent in the meantime, so batches outstanding at the same time can be admitted, and their output delivered, in another order than they were first sent. A client that needs submission order keeps one batch outstanding.

Forwarding To The Executing Node

A client may open a producer through any live node. When the serving node does not execute the ingestor, it opens a producer link to the node that does over the interconnect, reserves the producer’s bytes in its own budget, and relays the batches, outcomes, admission changes, and end; the executing node reserves the same bytes again for the batches it retains. Cluster Interconnect owns the link. The client sees the same protocol either way. The executing node admits a forwarded batch only after the serving node cleared it, so when the link is lost, whether the executing node crashed or stopped answering, the serving node still knows which batches may have entered the graph. Every batch it never cleared is answered NotAdmitted with ProducerEnded, which the client may submit again on a new producer without duplicating its effects; every batch it cleared has an OutcomeUnknown with cause OwnerLost; and the producer then ends as OwnerLost. When the serving node itself is lost, its sessions end with it, as When The Session Ends describes.

When The Session Ends

When the session ends, including because the node that serves it crashed, its producers detach: the executing node stops accepting their batches, drops the ones it held queued or awaiting their clearance without admitting them, answers nobody, and lets admitted batches finish in the graph. No server attachment survives the session. The Rust client retains the application’s desired producer and its submission ledger. It reports every batch sent without an outcome as OutcomeUnknown(SessionLost) and never resends it. On a new exchange it sends a fresh OpenIngestorRequest, compares the returned START generation, endpoint contract, schema, policy and grant with the first open, then publishes the new attachment to the same producer handle. A different generation or contract, a removed or stopped endpoint, or an incompatible schema makes the handle require an explicit new open. Temporary owner and capacity refusals are retried with bounded physical backoff while the new exchange lives. Closing or dropping the handle fences a late open and releases any attachment it created. The shared binding opens producers through these same Rust client handles, so its hosts restore them the same way; the web console does not open producers.

A serving node whose runtime has not yet passed its process-start linearizable catch-up barrier refuses producer and consumer opens as EndpointUnavailable. Its local domain snapshot may still show the state before a committed START, so it does not classify a missing or stopped endpoint as terminal until that barrier passes. The client retries this temporary refusal with its bounded restoration backoff.

Emitter Consumers

OpenEmitterRequest names the domain and TO CLIENT emitter, an exact field list, and requested outstanding batch and byte limits. The serving node validates the committed schedule and schema, reserves the requested bytes against the session’s 32 MiB and node’s separate 128 MiB consumer budgets, and attaches to the executing node before sending OpenEmitterOutcome.Opened. It refuses an open that cannot hold the emitter’s maximum IPC batch. At most 32 consumer handles belong to one session. The request identity of the open is its ConsumerId. The protocol and its FlatBuffers shape are identical over native gRPC and the console WebSocket. An opened reply also carries the domain START generation and the emitter endpoint contract fingerprint. The fingerprint is BLAKE3 of canonical emitter NSPL with flush cadence normalized to immediate, a 0xff separator, and the ordered output fields serialized as JSON. A flush-only change therefore keeps the live endpoint contract while changes to construction, publishing or ACK behavior do not.

ReadEmitterBatchRequest is a concurrent, potentially long read. It occupies one of the 64 ordinary in-flight request places but does not hold the ordered lane or receive loop. Its reply is a ReadEmitterBatchOutcome: Batch carries one canonical Arrow IPC stream, a stable delivery identity, a fresh attempt reference, source relay, opaque branch fingerprint, member count and execution-time snapshot; Ended says the attachment is gone. It does not distinguish relocation from removal or a changed contract. A client retaining the desired consumer reports the interruption, then checks a fresh open on the current exchange against its pinned generation and contract. A large reply uses the normal bounded transfer parts. Reading the reply never acknowledges it. The Rust client decodes a batch only when its stream declares exactly the Arrow schema of the fields the emitter declared at the open and holds one record batch of the member count, and treats any other stream as an unexpected reply. The registered client-emitter-deliveries property holds that decoder to the stream a client emitter’s sink writes: the declared schema, every delivered row in order, and every value and null.

SettleEmitterBatchRequest carries the consumer, attempt reference, and Ack, Retry, or Reject with a bounded non-sensitive reason. It runs beside the ordered lane and answers with Confirmed, StaleReference, WrongConsumer, InvalidReason, or ConsumerEnded. An ACK waits for that confirmation. ACK is idempotent while its bounded result remains; a reference revoked by retry, timeout, detach, or reassignment cannot settle a newer attempt. The emitter’s physical backoff controls retry. CloseEmitterRequest detaches and answers Closed or NotOpen.

A read the server answers with a batch assigns that attempt to its consumer, so a reply nobody waits for would strand the attempt until its ACK timeout. A caller of the Rust client that stops waiting for a read therefore leaves the read with its consumer, and the next read of the same attachment receives its reply instead of sending a read of its own; a read of an attachment that has since ended is dropped with it. Closing a consumer, like closing a producer, sends the close from a task of its own, so a caller that stops waiting still releases the attachment, and a second close, or a close while the handle waits to be restored, finds nothing attached and returns at once. The close also ends every read left with the consumer, including one left while the close ran, so no read outlives the close of its consumer.

Consumer and producer operations can share one session. A read awaiting output runs beside commands, producer submissions and their outcomes, clock observations, and consumer settlement. Each server attachment is bound to its session exchange. The Rust client keeps a desired consumer across reconnects and emitter relocation, reports one interruption before the next delivery for each exchange it lost, whether the loss or a reply racing it is observed first, and opens a new attachment only while the generation, contract, schema and granted behavior still match its original open. Removed or changed endpoints require an explicit new consumer. A delivery reference remains bound to the exchange that delivered it; the client never sends its ACK on the replacement exchange, and a settlement whose answer was lost is uncertain. The serving node forwards remote consumers on an authenticated relay-class duplex stream and reserves their granted bytes on both the serving and executing nodes. Loss of the stream or either endpoint revokes outstanding attempts. There is no durable cursor or consumer result history across an owner loss; upstream replay may be needed. Emitters owns the source acknowledgement guarantee and Cluster Interconnect owns the forwarding form.

The executing node exports active consumer and forwarding credit gauges, retained IPC work gauges, and retry, application ACK, and rejection counters. DESCRIBE EMITTER reads those values from the scheduled owner, including when the command enters through another node.

Resource Uploads

A resource archive travels on its own gRPC call, UploadResource, beside the session rather than inside it. The client streams one UploadStart frame, then the archive as UploadChunk frames in order, and half-closes the stream; the server answers with exactly one UploadReply. The start names the domain, the declared resource, the upload identity, and the exact size of the archive, and each chunk is non-empty and fits one frame. There is no manifest, digest, or finish frame: the leader hashes the archive as it stages it and verifies everything else when it installs it. The console WebSocket carries no upload stream.

The Rust client builds one deterministic archive in a temporary file before opening an upload call. Archive construction uses bounded 1 MiB read and write buffers. Only a successfully terminated and flushed archive becomes an upload body. An I/O failure abandons the partial file and returns BuildUploadArchive above its originating I/O error; cancellation abandons it without finalizing in a destructor or spawning cleanup work. The command deadline may cancel construction and retains its typed deadline outcome. Once construction succeeds, every transport attempt reads the same complete archive and keeps the same upload identity.

An upload is identified by its upload identity, not by an execution reference. The identity is chosen by the client, at most 128 bytes of ASCII letters, digits, -, _, and ., and is durable under the key of the authenticated user, the domain, and the resource. The first complete body admitted under a key binds it to that archive’s digest and takes the next version number of the resource. Repeating the same identity with the same archive joins the installation or reports its recorded outcome, with the same version, and the reply’s origin says whether this call installed it (Executed) or found it recorded (Recovered). The same identity with another archive fails as InstallationFailed with a message naming the conflict and, when the leader finds the identity recorded, the version it already holds. Upload identities never expire. Because the digest is computed from the body, recovering an uncertain upload means sending the whole archive again; the protocol has no request that asks for an upload’s outcome without it.

Uploads are leader operations. A follower reads the start frame, answers LeaderRedirect, and reads no chunk. The leader refuses what a stream gets wrong with a typed failure:

FailureMeaning
InvalidStreamThe stream did not begin with exactly one valid start followed only by chunks.
ResourceNotDeclaredThe domain declares no resource of that name.
SizeMismatchThe chunks add up to more or fewer bytes than the start declared.
QuotaExceededThe declared or received size exceeds the archive quota.
InstallationFailedThe archive could not be staged or installed, its identity is bound to another archive, or the completed version’s outcome could not be confirmed on every live node. It carries the assigned version when one was assigned, and repeating the same identity and archive returns the recorded outcome.

A body that ends early or overruns its declaration admits nothing and leaves its identity unused, so it can be sent again. Once the complete body is admitted, installation belongs to the cluster: it continues when the uploading connection closes, a new leader resumes it, and ResourceInstalled arrives only after every live node incarnation has verified and installed the same digest. If leadership moves while the upload installs, the reply is a LeaderRedirect, and repeating the same identity and archive on the new leader recovers the recorded outcome. Resource Versions And Bindings owns what admission, installation, publication, and completion mean, and Command Completion owns the completion boundary.

Authentication gives an upload its owner and nothing more: any authenticated user may upload a version of any resource declared in a domain, and the user scopes only the identity, so two users who choose the same identity string upload two versions.

Backup Downloads

A backup is a persistent command that leaves an artifact behind: its archive, which can be far larger than any reply. BACKUP therefore travels as an ordinary CommandRequest and is recorded under its execution reference like every other persistent command, while its archive travels on a call of its own, DownloadBackup, keyed by that reference. Backup And Restore owns what an archive holds and how it is laid out.

The leader runs the backup. Each domain is captured at its own applied revision and records whether its cut was quiesced, live, stopped, or configuration-only. The leader assembles the archive in its staging area, and only then completes the command. The CommandCompleted outcome carries the archive’s summary: its size and BLAKE3 digest, the capture time, the instant the node stops retaining the archive, whether resource bytes are included, the number of users, and each domain’s revision, cut kind and quiesce counters, section count, and bytes. The summary is part of the recorded outcome, so repeating the command under its reference returns the same summary and never assembles a second archive.

A download call carries exactly one BackupDownloadRequest, which names the backup’s execution reference, and the node authenticates the call from its metadata before it reads the request. A node that retains the archive answers with a BackupArchiveStart naming the archive’s size and digest, then the archive in order as non-empty BackupArchiveChunk frames of at most 256 KiB, then a BackupArchiveComplete. Every other answer is one frame:

AnswerMeaning
BackupDownloadFailed with InvalidRequestThe request did not decode.
BackupDownloadFailed with ExpiredThe retry validity of the reference has ended.
BackupDownloadFailed with NotOwnerAnother user ran the backup.
LeaderRedirectThe node does not retain the archive and does not lead.
BackupDownloadFailed with NotRetainedThe leader does not retain the archive: a download already collected it, the node that assembled it restarted, or the reference names no backup.
BackupDownloadFailed with ReadFailedThe node could not read the archive it retains; the archive stays retained.

An archive is retained on the node that assembled it until the first download whose completion frame was queued collects it, or until the retry validity of the backup’s reference ends. Any node reads that end from the reference’s UUIDv7 time, so an expired reference is refused the same way before and after its execution record is reclaimed. A node that does not retain the archive sends the client to the leader, and the leader, which then does not retain it either, answers from the recorded outcome. If leadership moves while a backup runs, the new leader resumes the applying command and assembles an archive of its own, and the archive the former leader assembled is never offered again.

Each running domain has its own quiesce timeout; the serial capture does not imply a total command timeout. Native clients bound the overall backup command wait with ConnectOptions::backup_wait_timeout (ten minutes by default, one millisecond through 24 hours), including every reply, redirect and reconnect. An exhausted wait returns UncertainCommand with the original reference and leaves admitted work recoverable. A new wait attempt retains that reference and its original selected domain, scope, resource inclusion and capture options; the client’s local output path is excluded from the server’s semantic request binding. A changed capture request conflicts, and an expired reference cannot admit a replacement execution. Waiting again does not extend server retry validity or the archive’s retention. Downloading begins only after the summary arrives and has separate per-frame stall bounds.

The node reads the archive only as fast as the client takes frames, at most four frames ahead of it, so a slow client holds back reads instead of growing what the node buffers for it. The call is not bounded by the request deadline, because a transfer can take far longer than a command; the Rust client bounds the wait for each frame instead. A download that ends early releases only its own hold on the archive, which stays retained for the client to download again. The protocol has no resume offset: a client starts a failed download again from the first byte, and accepts the archive only when the completion frame has arrived and every byte matches the summary’s size and digest.

The web console sends BACKUP on its session and downloads the archive over a console WebSocket of its own, from the console of the node whose session reported the backup. It verifies the archive exactly as the Rust client does, and hands it to the browser as a download only once it is complete and matches the summary. The browser tab records the backup’s execution reference, query and domain until the archive is downloaded, so a reloaded page repeats the command under the same reference, recovers the summary, and downloads the archive again while it is retained. DESCRIBE BACKUP never reaches a node at all: a client reads the archive file on its own machine, and a node answers the statement sent as a command with RequestFailed.

Restore Streams

A restore reads an archive that exists only on the client’s machine, so the archive has to reach the leader before the restore can even be planned. RESTORE therefore never travels as a CommandRequest: the client sends the statement together with the archive on a call of its own, RestoreBackup, and the call’s one reply carries the restore’s outcome. A node answers RESTORE sent as a command with RequestFailed. Backup And Restore owns what a restore recreates and the order of its steps.

The client streams one RestoreStart, then the archive in order as non-empty RestoreChunk frames of at most 256 KiB, and half-closes the stream. The start carries the request identity of the reply, the restore’s execution reference, the statement as canonical NSPL, and the archive’s exact size and BLAKE3 digest. The reference is a persistent command’s execution reference, and the request it is bound to is the statement together with the archive’s size and digest: a restore is admitted once under it, repeating the same statement and archive under it joins that restore, and another statement or archive under it is answered ExecutionReferenceConflict, like any persistent command.

The reply is a RestoreReply holding either a CommandOutcome, exactly as a command’s reply would hold it, or a RestoreUploadFailed that refuses the stream itself and leaves nothing changed:

FailureMeaning
InvalidStreamThe stream did not begin with exactly one valid start followed only by chunks.
InvalidStatementThe start does not name exactly one RESTORE statement.
SizeMismatchThe chunks add up to more or fewer bytes than the start declared.
DigestMismatchThe archive’s bytes do not have the digest the start declared.
QuotaExceededThe archive is larger than one staged artifact may be, or the leader’s staging area cannot hold it now.
StagingFailedThe leader could not write the archive to its staging area.

A refusal of the stream is not recorded; sending the stream again stages the archive again. Every other answer is a CommandOutcome under the start’s execution reference:

  • A follower reads the start, answers LeaderRedirect, and reads no chunk.
  • A leader that finds the restore finished answers with its recorded outcome, whose origin is Recovered, and reads no chunk.
  • A leader that holds the archive of the restore while it applies answers OutcomeUnknown with the StillApplying cause, and reads no chunk.
  • Otherwise the leader stages the whole archive, verifies it, and plans the restore. A restore it refuses there is answered RequestFailed and is not admitted. A restore it admits, or finds admitted without its archive, runs to its outcome, which carries the typed RestoreReport: the mode, the archive’s size and digest, the capture time, what the users step did, each restored domain with its counts, required lifecycle status and required archived start generation and, for a dry run, its model run’s transaction impact report, and each step with its outcome.

A restore that failed at a step is RequestFailed, and its report names the step as Failed, the steps before it as Applied, and those after it as NotAttempted. A dry run reports every step as Planned, and is never admitted or recorded.

A domain whose restore has not published its complete runtime-state generation refuses START with a definitive command failure naming the incomplete installation. Planning rejects it before admitting a lifecycle step, and consensus checks the same gate at activation. Failure of the restore, release of its mutation lease, and node restart do not release this gate. Only completion of the whole installation does. With RESUME, complete publication, gate release and the transition to Running at the archived generation and clock mapping precede the successful outcome. A client can immediately inspect that lifecycle or attach its clock. A dry run reports the planned lifecycle without activation. DomainInfo in DomainList and DomainsChanged carries a required start_version beside its status and pace. Wire verification rejects missing required lifecycle and generation fields; the decoder never invents a default generation.

The call is not bounded by the request deadline, because an archive can take far longer to send than a command takes to run; the Rust client bounds each frame by it, and then the wait for the reply once the last frame was sent. Once the leader has received the whole archive, the restore is no longer tied to the call: a client that goes away does not stop it, and repeating the stream under the same reference returns its outcome. A call that ends before the whole archive arrived changes nothing and releases what the leader staged for it.

Only the leader the archive was streamed to holds it, until the restore finishes or the retry validity of its reference ends. If leadership moves, or that leader restarts, while the restore applies, the reply is a LeaderRedirect or the call fails, and repeating the stream on the new leader hands it the archive, from which it resumes the restore at its first step not recorded. The protocol has no resume offset: every repetition sends the archive from its first byte.

The web console streams a restore over a console WebSocket of its own, to the console of the leader its session follows. It reads the archive file the operator chose once to measure and digest it before the start, and again as it streams. The stream ends with the chunk that completes the declared size instead of a half-close, and the console watches for a reply that arrives before every chunk was sent. It repeats a redirected, interrupted, or still-applying restore under the same reference, statement and archive, and reports the outcome as unknown, naming the reference, when ten minutes of repetitions leave it unknown.

Node Stop And Restart As A Client Observes Them

Shutdown And Recovery owns the phases of a node stop. This section describes what a client sees at each ending and what it recovers.

Graceful stop. When a node begins to stop, it closes admission on its session gRPC and console listeners and ends the sessions they carry. Every request a session had not yet admitted is decided cancelled before admission and never begins an effect. Work that was admitted keeps running and no reply follows for it: a transaction commit continues, and the node waits for it until its shutdown deadline. Subscriptions and domain clock attachments stop with their session, and an upload stream still waiting for its client ends at once. A console session receives SessionEnding with reason ServerShuttingDown after the control frames it had already queued, then a normal close. A backup download or a restore stream on a console WebSocket of its own ends at once with close code 1001, and a restore admitted with its whole archive goes on without its call. A gRPC connection is cut when admission closes, so a native client sees its stream fail, as after any transport loss, rather than the ending frame. Either way the client reconnects to another node and repeats each outstanding command under its execution reference, which joins the admitted work or returns its retained outcome.

Forced ending. A repeated signal, an expired shutdown deadline, SIGKILL, or a crash ends the connection without warning. A command whose reply was not received may or may not have been admitted, and the client cannot tell which from the connection. It repeats the command under the same execution reference on whichever node now leads. If the command was admitted, the new leader resumes it from its durable progress, and the repetition joins it or returns its retained outcome with origin Recovered; if it was not, the repetition admits it for the first time.

Leadership change. A gRPC session on the former leader receives LeadershipObserved naming the new leader, or no leader during an election, and its next leader operation is redirected. A command that leadership moved under returns OutcomeUnknown with cause LeadershipLost, and repeating it on the new leader recovers its outcome. A console session on the former leader ends with a SessionEnding that names the new leader, and the console reconnects there.

Full restart. When every node stops and restarts from its own storage, the cluster reopens its committed state, including every retained execution record, transaction, and upload record. Repeating an execution reference admitted before the restart returns its retained outcome without repeating its effect, a COMMITTING transaction resumes from its recorded step, and an open transaction whose inactivity deadline passed while the cluster was down expires once it runs again. Nothing a session held survives.

FactWhere it livesAfter a failover or a full restart
Execution record: admitted command, progress, terminal outcome, retry fenceConsensusRetained; repeating the reference recovers it
Transaction: owner, domain, state, operations, previews, commit progress, inactivity deadline, tombstoneConsensusRetained; attaching the identity recovers it
Domain mutation leaseConsensusRetained by the command or transaction that holds it
Upload record: identity, digest, version, outcomeConsensusRetained without expiry; repeating the identity recovers it
Session, request identities, admission decisions, queued framesThe serving processGone; a new session starts with new request identities
Transaction bindingThe leader’s memoryGone; the client attaches the transaction again
Selected domain and its observationsThe sessionGone; the client selects the domain again
Subscriptions, generations, interest leases, queued rowsThe session and its nodeGone; rows in transit are lost, and a restored subscription is a new generation
Domain clock attachmentsThe sessionGone; the client attaches again and receives the clock as it is then
Producer and consumer attachments, their credit, and the replies they oweThe session and the executing nodeGone; unresolved producer batches are of unknown outcome, admitted ones finish in the graph, and matching client handles acquire fresh attachments
Waiters, transfer reassembly, previews, desired subscriptions, followed clocks, desired producers and consumersThe clientKept by the Rust client across reconnects; lost if the client process ends

A subscription therefore always has a gap across the loss of its session. The server cannot report that gap, because the session that would carry the report is the one that was lost. The Rust client reports it instead, as an interruption of every subscription and clock the lost session held, before it restores them.

Bindings For Other Hosts

A host language reaches the protocol in one of two ways. It loads the shared Rust binding, nervix-client-ffi, whose C ABI is crates/client-ffi/include/nervix_client.h, or it implements the protocol itself from the schema. The binding is the supported path for C, C++, Python, JVM, and Ruby hosts. It drives the Rust client’s own state machine, so request correlation, leader redirects, reconnection, execution identity across retries, and desired-subscription restoration are decided once, in the library, and a host never retries, reroutes, or reinterprets an outcome on its own. A separate SDK per language would have to reimplement each of those decisions and be qualified again.

The binding’s lifecycle follows from that choice:

  • Handles. A host opens a session with nx_session_connect and releases it with nx_session_free; outcomes, events and batches read from a session stay valid after it is freed, and its producers, consumers and deliveries keep it running until they are released too. Every object a function hands out is owned by the caller and has exactly one release function, and a borrowed pointer stays valid only until the object it was read from is released.
  • Execution identity. nx_session_prepare captures one command and its durable execution reference before anything is sent. nx_session_execute runs it, and running the same prepared execution again after an uncertain, cancelled, or timed-out call recovers the command’s outcome instead of running it twice. Every failure of nx_session_execute names that reference.
  • Threads. Each session runs on a runtime of its own, with two threads the library owns. The library never calls host code, so no host function ever runs on a library thread. Every blocking call runs on the calling thread until it completes, fails, or is cancelled, and a session may be used from several threads at once; commands of one session still run one at a time. A call made from a thread that is itself driving a Tokio runtime cannot block on the library’s runtime and aborts the process, so a Rust host calls the binding from a blocking thread.
  • Cancellation and deadlines. Every blocking call takes an optional nx_cancel. A token triggered from any thread makes the call return NX_ERROR_CANCELLED, and a token created with a deadline makes it return NX_ERROR_DEADLINE once the deadline passes. Cancelling a wait never rolls back admitted work.
  • Bulk access. A Row event keeps the verified frame it was decoded from, after checking the batch against its subscription’s schema. nx_event_frame borrows that frame without copying it, a column accessor copies one whole column into caller-provided memory in one call, and nx_event_cell_varlen borrows one string or bytes value. Events are reference counted: nx_event_retain adds a reference, and the event and its frame are freed with the last nx_event_release, from any thread.
  • Clock events. nx_session_next_clock_event uses the same cancellation and deadline rules as the subscription wait, but returns a separate nx_clock_event reference. Its kind, domain, generation, state, paced mapping, tick and end-reason accessors expose the fields each event carries. An accessor for a field its event lacks returns NX_ERROR_TYPE. Retain and release preserve the event and its borrowed domain name across threads until the last reference ends.
  • Attached clocks. nx_session_domain_clock reads, without blocking, the clock the Rust client holds for a followed domain, or NULL for a domain it does not follow. After an attach completes it is the clock the attach reply carried, so a host reads the generation and committed mapping of a running clock before its first tick, and every later observation and tick replaces it in the order the session received them, so it is never older than an event the host has taken. Its accessors expose the domain, generation, state, paced mapping and newest tick, and its projections answer logical time, physical waits and admission with the Rust client’s arithmetic. A read never changes; retain and release share it across threads.
  • Producers and consumers. nx_session_open_ingestor and nx_session_subscribe_emitter open the Rust client’s producer and consumer with the host’s exact fields, built with nx_fields, and the credit it asks for; a refusal carries its typed nx_open_refusal. A producer submits a batch and waits for credit, and its outcome is taken with nx_producer_rejoin, listed with nx_producer_pending and let go with nx_producer_release, each with the Rust producer’s semantics: a cancelled wait leaves the submission and its outcome with the producer. A consumer hands out reference-counted nx_delivery handles; releasing one settles nothing, and nx_delivery_ack, nx_delivery_retry and nx_delivery_reject return the server’s typed settlement. Both keep the Rust client’s restoration: a consumer reports NX_ERROR_INTERRUPTED once per gap, and a handle whose endpoint changed reports NX_ERROR_REOPEN_REQUIRED with its typed reason. The binding keeps no acknowledgement, credit or reconnect state of its own.
  • Batches. A producer’s batch is built with nx_batch_builder from the host’s columns, one level of a column’s type at a time, and every buffer is copied before the call returns. A delivered batch is decoded the first time it is read, held to the consumer’s schema and member count, and read the same way. Every batch is its canonical Arrow IPC stream, which nx_batch_ipc and nx_delivery_ipc borrow and nx_producer_submit_ipc accepts from a host’s own Arrow tooling, so the binding encodes nothing a second way and the observation API stays free of Arrow.
  • Typed failures. A failing call returns an nx_error whose kind separates an invalid argument, a failed connection, a failed session, an uncertain outcome that carries the execution reference or leaves a settlement unconfirmed, a server refusal, a deadline, a cancellation, a protocol violation, a type mismatch, a session or handle that ended, an interrupted consumer, and an endpoint that has to be opened again. The binding classifies the Rust client’s error_stack::Report<ClientError> by its current context, and a failed backup download by the BackupDownloadError beneath that context, never by text, and nx_error_message returns the report’s whole chain.

The binding connects with the Rust client’s default options. It exposes no seeds, timeouts, or certificate authority, so it reaches a node over plaintext and connects to it directly. It exposes commands, completion, subscriptions and their events, domain clock events and attached clocks, bulk row access, and producers and consumers of client endpoints with their batches, but not the typed transaction status, inspection, choice lookups, notices, or leadership. A host executes ATTACH DOMAIN CLOCK;, reads the clock the reply carried with nx_session_domain_clock, and reads later observations and ticks through the clock-event wait; the Rust client restores the attachment after reconnect. An attach reports a refusal as a failed disposition carrying the server’s reason, not as a typed refusal. Its Row column accessors cover scalar, string, and bytes fields; a list field of a subscription reports its levels, and its values are read from the borrowed frame with generated code, while an endpoint batch’s lists are read one level at a time.

Independent implementations exist as qualification clients rather than supported SDKs. The Go client speaks native gRPC with flatc --go output and google.golang.org/grpc, and the TypeScript client speaks the console WebSocket with flatc --ts output, unchanged under Node.js and Bun. They correlate replies by request identity, follow a leader redirect with the same execution reference, and fail rather than report success when an outcome is unknown. Neither restores subscriptions or domain clock attachments after reconnection; that recovery belongs to the Rust client and, through it, to every binding host. The FlatBuffers runtimes for Go and TypeScript have no verifier, so those clients check each frame’s identifier and every union discriminant and required value they read, and the Rust verifier remains the only full structural verification. Conformance Evidence lists the probes, the runtimes they cover, and how to run them.

Measured Costs

The protocol’s costs were measured against the accepted FlatBuffers Row form and recorded, with their raw results and reproduction commands, in tests/client-wire-16-performance.md and tests/evidence/client-wire-16. The figures below summarize that record. They were taken on a shared host with a release server and a debug measurement harness, so they show where the cost of each stage lies and bound regressions. They are not throughput guarantees, and the record does not attribute a difference between two of its runs to the protocol unless it says so.

Construction and verification. The server writes each Arrow column value directly into FlatBuffers tables, with no record-shaped JSON object, map of fields, or intermediate row. A frame is verified once, and every later read borrows from it:

WorkloadRows / frames / wire bytesArrow to wire p50Verify p50Borrowed full scan p50Owning every cell p50
One row per frame, alternating branches100 / 100 / 131,192112 µs52 µs7 µs15 µs
Narrow rows in one batch1,024 / 4 / 135,896415 µs250 µs47 µs91 µs
Wide rows with strings, bytes, nulls, and redaction100 / 1 / 124,576271 µs132 µs28 µs50 µs
Large batch4,096 / 16 / 4,672,3762,441 µs1,263 µs393 µs873 µs
Rows of 16 KiB, limited by frame bytes256 / 2 / 4,224,416332 µs134 µs25 µs160 µs
Every fourth row selected256 / 1 / 33,968100 µs58 µs25 µs20 µs

Verification and borrowed reads allocate nothing; reading one cell per frame takes about 0.2 µs. Owning every cell of 1,024 narrow rows allocates about 172 KB, and rendering them as the JSON text the CLI and console display allocates about 724 KB, with escaped field names computed once per batch rather than once per row.

Copies and retained buffers. Variable-length values are copied once, into the builder, and the finished buffer becomes the queued frame without another copy. The gRPC sender copies a frame into its output buffer, about 77 µs for the 4.67 MB large batch. The gRPC receiver copies a frame below 64 KiB into an allocation of its own and shares the receive buffer of a larger one. A WebSocket message must be owned, so the console transport copies a frame once more, about 79 µs for the same batch. The 256-row and frame-byte limits keep a frame from growing into an unbounded buffer: the large batch held 8.38 MB of live allocation for 4.67 MB of frames, the byte-limited batch 4.28 MB for 4.22 MB, and live allocation returned to its baseline once the frames were released.

End to end. The record repeats the workload of the original Protobuf baseline. In its last run, on a clean commit, the release server answered a native command in 2.9 ms at p50 and 3.9 ms at p99, delivered a typed HTTP input to a Row subscriber in 23.6 ms and 28.9 ms, served a live graph snapshot on the console WebSocket in 176 µs and 397 µs, and completed a one-file upload in 15.0 ms and 15.6 ms. Of the command time, about 2.1 ms at p50 is preparation in the debug client, dominated by its statement parser, and about 1.0 ms is the prepared request and its reply. Control commands sent while a subscriber was paused or draining slowly completed in 4.5 ms and 4.9 ms at p50, because replies take the control lane ahead of queued rows. The median command request and response were 176 and 1,640 bytes, and a one-row subscription frame 1,320 bytes.

Transport and bindings. Opening a session on one node took about 2 to 4 ms over plaintext and 4 to 5 ms over TLS; the per-command difference between the two did not stay stable across runs. Through the C ABI, borrowing a verified frame takes about 60 to 70 ns, retaining and releasing an event 80 to 90 ns, and copying the states, fixed-width values, or variable-length values of a 100-row column into caller buffers 64 to 122 µs. Through CPython and ctypes, each call costs about 0.6 to 6 µs, which is why a host reads a batch column by column rather than value by value.

The record also sets regression alarms derived from the Protobuf baseline: a p99 of 6.5 ms and a 1,900-byte response for a command, 90 ms and 1,500 bytes for a typed Row delivery, 3.6 ms and 4,600 bytes for a graph snapshot, and 75 ms and 200 bytes for an upload acknowledgement. They are qualification thresholds, not promises.

Guarantees And Non-Guarantees

The protocol guarantees:

  • Correlation. A reply completes exactly the request whose identity it names, in whatever order replies arrive, and never a request of another session.
  • Bounded, verified frames. Every frame fits the session limits and is verified before it is read. A reply larger than a frame arrives whole or not at all.
  • One outcome per identity. Repeating a command under the execution reference that admitted it joins the admitted execution or returns its retained outcome. It never applies the effect a second time, and a reference whose record was reclaimed is refused rather than run again.
  • Exact proof. The outcome of an append or a commit comes only from the record of that exact request, never from the state of the transaction as a whole.
  • Written order. The requests that change a session run in the order the client wrote them.
  • Responsive control. Replies and session events are never queued behind subscription rows, and a command waiting for its completion boundary delays no concurrent request.
  • Typed rows. A Row frame carries every value with its declared type and nullability, withholds every sensitive value, and names its concrete branch. Its schema precedes it, and no row of a redefined relay reaches a subscription announced under the earlier definition.
  • Named losses. A subscription reports the rows the server skipped, the rows a DROPPING subscription discarded before its next rows, and its end. The Rust client and its bindings also report the loss of the session a subscription lived on, and every attempt to restore it that a new session refused.
  • One outcome per batch. Every submitted batch receives exactly one terminal outcome, never completed before its acknowledgement root resolved, and a producer’s end follows every outcome it owed.
  • Verifiable archives. A backup’s archive arrives with the size and digest its recorded outcome names, and a download that ends early leaves it retained for another attempt.

It does not provide:

  • Exactly-once submission. A batch whose outcome is unknown or failed may have had effects, and nothing replays or deduplicates it. The application keeps its source data and decides.
  • Exactly-once delivery. A subscription is a live view with at-most-once delivery per admitted batch. Rows in transit can be lost without a report while relay ownership moves or a node-to-node delivery fails, and a source may redeliver a record that the graph then passes again.
  • Automatic rollback. A failed transaction keeps the applied prefix of its execution steps, and a cancelled waiter keeps its admitted effect. Undoing either is a new command.
  • Durable subscription replay. Subscriptions keep no offsets and are not persisted. A restored subscription starts with the rows that arrive after it opens again.
  • Guaranteed restoration. The Rust client opens a subscription or endpoint again, or attaches a clock again, only when the serving exchange accepts it. A subscription whose relay no longer exists stays interrupted until its caller deletes it. A producer or consumer whose endpoint contract or domain generation changed requires a fresh application open. There is no durable consumer cursor.
  • A required Arrow client library. Rows arrive as typed FlatBuffers cells; a client needs only code generated from the schema.
  • Columnar subscriptions. SubscriptionType has one value, Row. A server refuses a type it does not declare as UnsupportedValue and never substitutes another encoding.
  • A durable session. A session, its request identities, its selected domain, its subscriptions, its domain clock attachments, its producers, and its transaction binding end with the connection. Only execution records, transactions, and upload records outlive it.
  • Liveness detection. Neither transport sends keepalives, and the server has no idle timeout. A client that needs to detect a half-open connection bounds its own waits.
  • A durable archive. A backup’s archive lives in the staging area of the node that assembled it and is lost when that node stops, or once its reference’s retry validity ends; the recorded outcome outlives it. Only the first complete download receives it.
  • Authorization. Every authenticated user may run every command in every domain.

Observability

The protocol makes a client’s view of its own work explicit rather than inferred:

  • Correlation. Every reply names the request identity it answers, and every command outcome names its execution reference. A client that logs both can pair a reply with the request it sent and with every later attempt of the same command. The CLI prints the execution reference of a command whose outcome is unknown, so an operator can see what a later retry will recover.
  • Dispositions and origin. A command outcome states its disposition, and whether it was executed now or recovered from the durable record. OutcomeUnknown names its cause, a conflict names what differed, and a stale preview names both identities.
  • Transactions. SHOW TRANSACTIONS lists live transactions and retained tombstones with their owner, domain, state, pending count, progress, age, and idle time, from any node. DESCRIBE TRANSACTION reads one transaction’s full impact report; see Transaction Quiescence And Impact Inspection.
  • Subscriptions. A client learns of its own losses from SubscriptionDeliveryLost, SubscriptionRowsSkipped, and SubscriptionEnded, and the Rust client reports a lost session as an interruption of each clock and of each subscription the server had not ended, and each refused attempt to restore one as a restoration failure with the server’s message. Client::subscription_lifecycle reads each subscription’s state, including Ended for one the server ended. The web console shows each tab’s state on the tab: pending, active, interrupted, restoring, ended, resubscribing, or closing. Each node exports nervix_session_subscriptions, the number of subscription leases it holds per relay, and nervix_session_subscription_dropped_rows_total, the rows its DROPPING subscriptions discarded, both labeled by domain and relay; see Metrics And Observability. Skipped rows are reported only to the client.
  • Leadership. LeadershipObserved tells every session which node leads and where to reach it, and SHOW CLUSTER STATUS shows each node’s availability.
  • Producers. A client learns every batch’s outcome and every admission change and end of its producers. SHOW INGESTORS and DESCRIBE INGESTOR show each client ingestor’s attached and forwarded producers, its outstanding batches and bytes, and the batches holding its window, and the node that executes it exports them as metrics with the answered batches by outcome and cause; see Metrics And Observability.
  • Backups. A completed backup’s outcome carries its archive’s summary. The node that assembled the archive logs its assembly and its collection by a complete download at info, naming the execution reference, and every other end of a download at debug.

The server keeps its own records at the levels the logging contract assigns. Listener startup is logged at info. A reply replaced by a rejection, a reply the session ended before it could queue, a subscription notice or loss report that does not fit a frame, domain clock attach, detach, and end, and producer opens, refusals, closes, and ends are logged at debug. A session that falls behind the notice bus, an event or snapshot that does not fit a frame, a failed revert at a clean close, and a failed remote subscription dispatch are logged at warn. Session opening and closing, individual requests, and rows are not logged, and no log line carries a payload value. The server exports no metric for sessions, requests, rejections, or authentication attempts; a client that needs them measures them itself. Producer counts are exported by the node that executes the client ingestor, not per session.

Retained Subscription And Producer Dependencies

A subscription generation retains its domain lifecycle when delivery starts. Filtered batches read the currently installed clock through that capability, including a domain started after the subscription opened. Unavailable-clock skipped-row outcomes remain unchanged. It also retains the node’s executor and passes it to predicate evaluation for bounded execution. Its dropped-row counter is resolved with the generation. A native ingestor endpoint resolves every public batch-outcome metric child at startup and retains its execution’s acknowledgement trackers. These internal ownership rules change neither session framing nor client recovery behavior.

Control Plane

The control plane is where Nervix applies strong consistency.

It is responsible for:

  • storing NSPL models
  • validating references, compatibility, and placement claims
  • computing domain schedules
  • tracking domain lifecycle
  • handling cluster coordination
  • exposing control operations like SHOW CREATE, DESCRIBE INGESTOR, and SHOW CLUSTER STATUS

The most important property is that control-plane state is authoritative. A runtime node only exists because the control plane says it exists.

Execution graph configuration is part of this control-plane state. NSPL models, domain schedules, and lifecycle transitions are persisted with strong consistency guarantees before runtime nodes execute them. The committed schedule becomes an in-memory typed execution revision at the application boundary; Execution Plans describes its installation and recovery.

Schedule planning captures live voter incarnations separately from destination candidates, which exclude both terminating incarnations and the captured consensus cordons. Revalidation uses the same composed candidate set. A cordoned former owner beginning to terminate leaves that set unchanged, while its loss or replacement incarnation still invalidates the plan. The consensus publication fence independently checks the captured membership and cordons.

In practice, the control plane covers:

  • domain creation and selection
  • model creation and deletion
  • scheduling decisions, including domain defaults and named placement rules
  • explicit node removal with DROP NODE <node_id>
  • node cordon and uncordon with CORDON NODE <node_id> and UNCORDON NODE <node_id>
  • node drain with DRAIN NODE <node_id>, which cordons the node and moves scheduled graph nodes away one at a time
  • explicit relocation with RELOCATE <selection> ONTO NODE <node_id>, which moves a selected subgraph onto a named cluster node, and DESCRIBE RELOCATION, which shows the plan without executing it
  • primary and replica assignment
  • Kafka OFFSET BY DOMAIN partition-to-instance assignment and rebalance
  • domain START and STOP

This is the part of Nervix where Raft-backed consistency matters. It keeps cluster-wide definitions coherent.

Durability And Recovery

A control-plane mutation returns its semantic result only after Raft has committed it and the leader has durably applied it. The applied write stores the changed semantic records together with its final log position, membership, transaction progress, and revision. Recovery therefore finds a complete applied range or replays its committed entries; it never reconstructs an acknowledged command from a partial state-machine update.

Errors And Diagnostics owns how validation and planning failures accumulate context and become client diagnostics. This chapter owns the command’s durability and transaction outcome.

Consensus uses a dedicated database and journal under <db-path>/consensus. Registry and runtime state remain in the node database at <db-path>, so consensus synchronization does not flush or wait behind data-plane journal writes. The full durability boundary, append-stream pacing, log reader bounds, retention policy, snapshot lifecycle, storage layout, and operator settings are defined in Consensus Storage And Replication.

Observers see a coherent state revision only after durable success. Change notifications identify committed revisions and may coalesce intermediate revisions; readers retrieve a coherent current view. A storage or transport error can arrive after the durable write completed, so it does not prove that the command was uncommitted. Persistent administrative requests keep the same execution reference across an uncertain result and join the admitted execution or retrieve its retained terminal result.

Each node applies a published cluster revision once. A revision at or below the one it has already applied carries nothing newer and is ignored, and the first revision a node sees always applies. No revision value is reserved to mean that a node has applied nothing yet, so every revision a cluster can publish, including the largest one, still suppresses the stale revisions that follow it. A revision whose application fails is not recorded as applied, so the node applies that revision again instead of treating the failed attempt as its current state.

Replicated NSPL Transactions

NSPL command grouping is explicit. BEGIN creates a Raft-replicated control-plane transaction and returns its id. Following eligible statements are preflighted and then appended to that transaction in written order; COMMIT applies them and REVERT discards them. BEGIN inside an active transaction is rejected, as are COMMIT and REVERT without one. A request containing multiple statements outside an explicit transaction is rejected instead of becoming an implicit batch.

A transaction belongs to exactly one domain. BEGIN binds the transaction to the session’s selected domain, which must already exist; without a selected domain, or with one that does not exist, BEGIN fails and no transaction is opened. Every statement queued afterwards must select that same domain, and a statement submitted for another domain is rejected without changing the pending count. A transaction therefore cannot create the domain it configures, and cannot span domains.

The cluster owns the transaction, not the TCP or WebSocket connection. Its owner, timestamps, state, structured semantic statements, and commit progress are replicated. The original statement source is retained for display, but execution never reparses that text. BEGIN, queueing, COMMIT, and REVERT are leader operations; clients transparently follow the normal leader redirect, including for the initial BEGIN. Transactions Over The Protocol describes how a transaction travels over the client session protocol and how clients recover its requests exactly.

A transaction is OPEN, COMMITTING, or finished as COMMITTED, FAILED, REVERTED, or EXPIRED. A client retains the transaction id and attaches it after reconnecting. Attach is restricted to the authenticated owner. Attaching from a second live session takes over the transaction, so the displaced session’s next transaction operation reports that it was taken over. The transaction reports the domain it is bound to, and an attaching or reconnecting session adopts that domain as its selected domain. An unclean transport loss or leadership change leaves an open transaction available for attach. Binding is leader-local soft state, so a leader that does not hold it reports the session as detached; clients treat that as a routing condition, attach the transaction again, and replay the command. A session that its client closes cleanly, with no request in flight, reverts the open transaction bound to it on the leader.

Only the bound domain’s replicated configuration effects may be queued:

  • model CREATE, supported model ALTER, and model DROP statements;
  • REBIND RESOURCE, as one atomic model-mutation batch;
  • ALTER DOMAIN, START, and STOP;
  • CREATE RESOURCE and RESET WASM PROCESSOR ... STATE.

Completion on the bound session resolves identifiers against the configuration the queued statements produce, applied in written order, so a client is offered the models and resources its own transaction defines and is no longer offered a model whose DROP it has queued. Only the create and drop sequence decides a name, so an intermediate configuration that does not yet resolve still completes. Sessions that are not bound to the transaction, including other sessions of the same user, are offered committed configuration alone. For a REBIND RESOURCE ... FOR list, completion further limits each kind to queued-result models that bind the named resource.

Read-only SHOW, DESCRIBE, and LOOKUP statements are rejected at queue time. CREATE DOMAIN and CREATE USER are rejected too: neither belongs to a domain, so neither is transaction content. Session subscriptions, UPLOAD RESOURCE, BACKUP, RESTORE, and node scheduling or membership operations (CORDON, UNCORDON, DRAIN, DROP NODE, and RELOCATE) are also immediate, non-transaction content. Run those statements outside BEGIN/COMMIT.

Queue admission is not a blind append. The leader replays the replicated transaction prefix into a side-effect-free ordered plan, then checks the new statement against that plan. The planner uses one captured set of domain, model, resource, schedule, placement, membership, and liveness inputs. It simulates lifecycle and placement changes, resource declarations, and model candidates in written order, with the same execution-step boundaries COMMIT uses. This catches such errors as duplicate configuration, a missing ALTER target or field, invalid domain lifecycle, invalid external bindings, and invalid UDF or schedule inputs before the statement is replicated. A successfully queued model mutation reports the effective quiesce level of its complete consecutive model run at that prefix. Extending the run can raise that level or make cancelling changes a no-op. REBIND RESOURCE resolves its target and usages from that same prefix. LATEST and its impact are provisional at admission and are planned again from the captured commit basis. All selected models pass ordinary creation and external-resource validation before the one model step can commit. Resource Versions And Bindings defines LATEST resolution and the rebinding contract. A rejected statement does not change the pending count or the transaction’s activity time, so the client can correct it and continue the same transaction. The admitted result is stored with the statement. The same consensus update also stores its stable operation number and the report revision for the resulting prefix. A report identity contains the transaction id, accepted queue position, and a fingerprint of the coherent planning basis. An exact retry with the same request reference, source, semantic statement, and expected position returns that admission and identity without rerunning preflight or extending activity. A reused reference with different content is rejected. Limits are checked before preflight.

Side-effect-free preview planning always reads the replicated semantic transaction content and one coherent set of authoritative inputs. COMMIT requires a complete identified preview. Admission validates its position and captured inputs, then stores the report, exact execution plan, and domain mutation fence in the same transition to COMMITTING. A stale preview returns the typed refreshed identity while the transaction remains OPEN; no gate or external effect has begun. Once admitted, each step uses the stored decisions and captured inputs. A restart or new leader does not replan it from newer configuration.

A COMMIT request may name the preview it expects to apply. The expected identity covers the transaction id, its accepted queue position, and the whole planning basis, whether the client read that identity from an accepted append or from an inspection. When the preview no longer describes the transaction, the commit is refused with both the expected and the current identity, nothing applies, and the transaction stays OPEN with its session binding intact. A client that decides to proceed anyway commits again against the identity the refusal reported. A COMMIT that names no expected preview applies whatever the transaction’s own latest preview describes. Naming an expected preview fences only a commit of a transaction the same request did not change, because an append in that request moves the transaction past the preview the client is holding.

The plan records affected topology on both sides of every execution step. The before side retains nodes and edges that the step drops or rewires; the after side records the graph that activation will install. Edges distinguish configuration dependencies, normal delivery, message-error and correlation-timeout routes, and materialized-state reads. Downstream pause traversal follows only relations that can carry records or state effects, while the reported topology also retains the configuration dependencies needed to explain those nodes. Parallel relations between the same two nodes remain separate, and every node and edge retains all operations that contributed to it.

An entity pause names its concrete branch coverage and the admission relays from the current schedule. Shared relay gates and every member of an affected hard placement group are included in that scope. Schedule entity swaps raise the effective scope to an entity pause, while a schedule rebuild raises it to a domain pause before activation begins. A domain pause covers every execution node in the before and after graphs. Changed, paused, force-flushed, ownership-moved, activated, rebuilt, and state-reset nodes remain separate effects in the report; because engaging any entity gate requests a domain-wide force flush, that flush effect covers the full current execution graph. A VHOST TLS version change is reported as an HTTPS listener refresh activation of that VHOST, with no paused subgraph and no rebuilt node. Commit uses the gate plan and schedule delta captured for this report, so execution cannot silently widen the planned scope with a second decision.

Transaction Quiescence And Impact Inspection is the full reference for the operation, execution-step, and transaction report levels; typed pause scopes; topology and attribution; preview freshness; actual engagement; and retained inspection.

An accumulated model run that already forms a complete graph receives the full registry, binding, UDF, and scheduling preflight. Cross-model completeness may remain provisional only for the unfinished final model run. An intermediate schema/codec mismatch or temporarily referenced model may be repaired by a later statement in that same run. Once a lifecycle, domain, or resource step ends the run, a later model run cannot repair it. Statement-local mutations must still be valid against the prefix, and COMMIT requires every run to pass every check. This keeps coordinated multi-model migrations possible without allowing a later execution step to make an earlier step valid retroactively.

Within a transaction, each consecutive run of model mutations can mix CREATE, ALTER SCHEMA, ALTER WIRE ... SCHEMA, ALTER RELAY, ALTER JUNCTION, ALTER DEDUPLICATOR, ALTER REORDERER, ALTER EMITTER, ALTER <CONNECTOR> <ENTITY> INGESTOR, ALTER REINGESTOR, ALTER GENERATOR, ALTER PLACEMENT, and DROP. Nervix applies that run as one registry mutation: all operations are evaluated in written order against one candidate model map, the complete domain graph is revalidated, and one atomic storage batch persists the base-to-final result. Drop/recreate and multi-ALTER sequences are therefore classified jointly; cancelling changes can produce a no-op. A failure writes nothing and does not swap the active registry state. This supports coordinated wire-schema, internal-schema, codec, relay, processor, emitter, ingestor, generator, placement, and dependent-node migrations without exposing an invalid intermediate graph.

Other eligible statements apply individually. COMMIT records authoritative effect progress and completed application separately and stops at the first definitive failure. A transaction remains COMMITTING after a step’s authoritative state is durable while runtime activation, source readiness, remote stopping, ownership handoff, or gate release is outstanding. It becomes COMMITTED only after the final step is usable on the current live-node set and its outcome is authoritatively visible. Its successful output is only the highest quiesce level actually executed across the transaction; it does not repeat the individual command outputs.

The retained report starts with every frozen step unattempted. The authoritative effect transition records that step as applying with its planned and actual impact, and application completion records it as applied or failed. Operation summaries and before/after topology are independent of the executable statement payload, so clearing statements at a terminal outcome does not erase the facts an inspection reads. Reports use keyed header, operation, and step records. Topology graphs are content-addressed, stored as bounded node and edge records, and shared by every report revision that names the same content instead of being copied into one growing transaction value.

Those storage bounds do not narrow the public report. Raft log readers and snapshot transfer read large stored ranges in bounded chunks, while inspection reconstructs every referenced operation, step, node, and edge before returning one typed result. Text and JSON responses are not paginated or silently truncated, including when an expanded report is larger than one Raft replication batch. A client therefore budgets one response proportional to the accepted operation count and affected graph; an encoding or transport failure yields no successful partial inspection.

Actual quiescence is an ordered engagement history, distinct from the frozen plan. Each attempt records its request and then whether engagement was confirmed, definitively failed, or remained uncertain; a confirmed cleanup records release. A request timeout is uncertain when the remote node may already have engaged its gate, so it contributes its possible scope to the executed aggregate. A definitive failure before any engagement leaves the actual level DYNAMIC. If runtime activation cannot perform the planned entity swap and rebuilds the domain to recover, the report retains the entity attempt and appends the wider domain pause and recovery rebuild effects. Later validation, publication, activation, or leadership failure never erases engagement already observed. The transaction result derives its aggregate from these actual histories rather than the planned scopes.

Inspecting A Transaction

Inspection reads one transaction’s impact report and changes nothing about that transaction. It reads either the transaction bound to the calling session or one named by identity, and may name a positive one-based operation to read about. Inspecting by identity never attaches or takes over the transaction, adopts its domain, touches its activity time, appends content, or moves its next queue position, and it never overwrites the commit basis cached for a different attached transaction. Ownership is the same check attach uses: a transaction belonging to another user is refused.

Which report an inspection reads follows the transaction’s state. An OPEN transaction has no frozen revision yet, so it is planned from a coherent read of the control plane and reports the planning basis and completeness of that read. A repeated inspection plans again rather than joining durable mutation admission, so two reads of an unchanged transaction agree because the inputs agree. Inspecting a COMMITTING transaction reads the frozen revision its commit admitted, with the actual outcomes its steps have recorded so far, including the step that is still applying. A committed, failed, reverted, or expired transaction answers with the same frozen revision and its recorded outcomes for as long as its tombstone is retained.

An inspection returns the whole report or an explicit refusal; it never truncates topology and never mixes revisions from different reads. Naming an operation selects what a reader leads with and does not narrow the report that operation belongs to. A refusal says which of these it is: the session has no attached transaction, the identity is unknown or already reclaimed, the transaction belongs to another user, the operation number is past the operations the transaction accepted, or the transaction has no readable report, which is the state of one that ended before any operation was planned. The inspected report travels in its own typed envelope; the transaction status a command result carries continues to describe the caller’s own session binding.

NSPL reaches inspection through DESCRIBE TRANSACTION [ '<id>' ] [ OPERATION <n> ] [ FORMAT TEXT | JSON ]. The session answers it before transaction queueing: sent while a transaction is attached, it neither becomes queued content nor consumes an operation number, and it takes no queue position, durable admission, or execution-reference replay, so repeating it reads the transaction again. It must be the only statement of its request, because sharing a request with an append would make it part of that append’s durable admission. A request that pairs it with any other statement is refused before anything in it runs. Other read-only statements remain rejected inside a transaction. TEXT renders identity and state, progress, completeness and planning basis, the transaction-wide quiesce level and pause scope, each operation with its execution step and reasons, and each execution step with its planned scope, effects, actual quiescence, and outcome; naming an operation moves that operation, its own contribution, and its step to the front. FORMAT JSON renders the same inspection as one JSON document holding the status, the selected operation or null, and the whole report. Every format carries the same typed envelope in the command result.

A new leader automatically resumes every COMMITTING transaction from its recorded applying step. Completed effects are not repeated, and a failed remaining step records its statement number and error while preserving the applied prefix. A model step that did not pause and whose VHOSTs an HTTPS listener could not install is the one step whose failure removes its own effect: the record of that failure also restores the schedule the step replaced, so the applied prefix ends before it. Both the first attempt and a resuming leader apply this rule. Repeating the outstanding COMMIT joins this execution and waits for the retained terminal result. Atomicity still does not span the whole transaction.

Finished transactions remain as small tombstones containing the outcome, step progress, errors, and executed quiesce levels. Their final report revision remains available for committed, failed, reverted, and expired outcomes even after later graph changes. Removing the tombstone removes its plan and report records and any topology content no retained report references. During retention, attach reports the exact outcome and aggregate commit output; after removal the id is unknown. SHOW TRANSACTIONS; can be served by any node from locally applied replicated state and lists the id, owner, domain, state, pending count, progress, age, and idle time for live transactions and retained tombstones. It also runs on its own beside an attached transaction without entering its queue or changing its session binding.

An OPEN transaction expires once it has been inactive for its idle timeout, whether or not a session is bound to it. Attaching it, appending to it, and a commit’s admission or failure renew that deadline; an open connection, a session binding, and reads of the transaction do not. The deadline is a durable UTC instant, so time the cluster spends stopped counts toward it. A COMMITTING transaction never expires. Defaults and server settings are:

SettingEnvironment variableDefault
--transaction-idle-timeoutNERVIX_TRANSACTION_IDLE_TIMEOUT15m
--transaction-tombstone-retentionNERVIX_TRANSACTION_TOMBSTONE_RETENTION15m
--transaction-max-statementsNERVIX_TRANSACTION_MAX_STATEMENTS256
--transaction-max-source-bytesNERVIX_TRANSACTION_MAX_SOURCE_BYTES1048576
--transaction-max-openNERVIX_TRANSACTION_MAX_OPEN1024

These limits are enforced by replicated state, so every leader observes the same admission result. Transaction state changes do not force schedule publication or a runtime barrier.

Data-plane records remain outside this control-plane atomicity.

Node Health, Membership, And Scheduling

Each node publishes application-health observations independently as its probes complete. The scheduler reads the current observation snapshot and applies the configured node-unavailability policy. A peer leaves the live scheduling set only after a current sequence of application probe failures has lasted for that policy’s interval. A stale observation is unknown, and probe-capacity exhaustion remains distinct from a peer failure, so neither condition alone makes a healthy node unavailable.

Automatic scheduling runs independently of the health-probe sweep, resource downloads, and Raft learner catch-up. It does not wait for any of them to finish. Slow health responses, learner admission, and bulk progress can therefore overlap scheduling and unrelated administrative work. Scheduling uses only observations that are current when it computes and publishes a candidate; a changed topology or effective-health revision causes the candidate to be recomputed. A newer observation with the same effective health does not invalidate an otherwise current candidate.

The leader’s effective availability view also defines the required process incarnations for authoritative visibility, runtime preparation and readiness, and HTTPS listener installation. Connected followers fetch that view with the leader’s Raft term before completing a command. If the leader has retired an unreachable node, a follower’s independent healthy observation of that node does not keep its runtime barrier open. The local incarnation must still finish its own work.

The current leader owns membership changes and serializes them one at a time. Learner admission or catch-up and voting-membership changes each have a ten-second wait deadline. When that deadline expires, Nervix stops waiting and observes effective committed membership again before a later retry. It does not infer that the change was rolled back: an admitted learner may continue catching up after the timed wait ends.

An automatic schedule candidate carries the leader identity and term under which it was computed, plus the full applied control-plane revision of its inputs. Publication succeeds only during the same leader tenure and while that applied revision and the expected current domain schedule still match. Any intervening membership, cordon, domain, configuration, or schedule command therefore invalidates the candidate and makes the scheduler compute again from a coherent current view.

Placement Activation

Placement coverage is derived from the complete candidate execution graph rather than stored as a fixed runtime-node list. During every graph activation, the control plane recomputes path-gated rule claims, applies rank resolution, rejects equal-rank policy conflicts, and forms the effective REQUIRE COLOCATION groups before publishing the schedule. A rejected candidate writes nothing and leaves the prior models and schedule active.

Schedule publication preserves the existing primary and replicas of every single-owner ingestor, client ingestors included, while all cluster nodes in that assignment are live. WEBSOCKETS CONNECTION ingestors follow this rule along with the other client sources, so unrelated graph changes, cluster-node joins or uncordons, and soft placement changes do not restart their external sessions. HTTP ENDPOINT, WEBSOCKETS ENDPOINT, and SYSLOG LISTENER ingestors are the only ingestors whose assignments follow live membership, because their listeners execute on every cluster node.

Every relay is also scheduled with one primary owner. Ordinary relays have no replicas. Materialized relays use the same relay schedule entry: additional assigned nodes are replicas of materialized state only, not relay buffers, branch presence, fan-out, or metrics.

Hard colocation groups constrain every scheduler. A newly effective require group is consolidated through the normal runtime-node handoff path, and failover or drain moves the group as one unit. Soft policies affect only future placement decisions and do not relocate existing assignments. ALTER DOMAIN SET PLACEMENT changes the active domain’s fallback through the same schedule activation boundary. See Placement Policies for corridor coverage, precedence, carve-outs, lifecycle commands, and introspection.

ALTER Lock And Quiesce Classification

Every model-mutation batch acquires one exclusive leader-local ALTER lock for its domain before validation. The lock remains held through candidate planning, quiescing, persistence, schedule publication, rollback when required, and resume. Validation covers every candidate binding that reaches outside the registry: domain pace, VHOST TLS material, hash-map content, inferencer tensor metadata, and WASM module compilation. A binding that fails there rejects the batch while the models it would have replaced, and the node-owned state keyed by them, are still the current ones. A concurrent mutation is rejected instead of queued. Raft still serializes the durable domain lifecycle and schedule, while the registry’s base-model comparison remains a final consistency check.

Nervix classifies the validated base-to-candidate model diff, not the spelling of the statements that produced it. The batch uses the highest level contributed by any changed entity:

Quiesce level and ingestor quiesce mode are separate contracts. DYNAMIC, ENTITY_PAUSE, and DOMAIN_PAUSE determine which graph work must pause before a change commits. The required ON QUIESCE clause on each ingestor determines what that ingestor’s external source experiences while an entity or domain pause is active. Memory-pressure shedding consults the same mode. There is no operator PAUSE or RESUME statement.

  • DYNAMIC changes do not pause ingestion. Relay capacity; processor filters, source predicates, collection, route construction, route flush, and same-target message-error policies; deduplicator/reorderer MAX TIME; emitter flush policy; placement definitions; and a VHOST moving to another version of the TLS resource it already binds are hot-applied while retaining buffered and branch-local state when ownership stays fixed. A placement definition is a dynamic model change, but its effective command level rises to ENTITY_PAUSE when the resulting schedule moves a running runtime node. A VHOST TLS version change is applied as an HTTPS listener refresh on every live node, including for a stopped domain, and no execution node pauses or restarts. If the listener of any node cannot install the change, the batch fails and restores the previous models. The DYNAMIC TLS Refresh defines what every listener presents and when.
  • ENTITY_PAUSE changes gate only the affected relays on every live node, force-flush affected work, and wait for the owner buffers, fixed dispatch slots, and target-node work counters to drain before commit. Other domain traffic continues. A processor topology change then swaps only the affected node tasks and hands pending materialized-state work to their replacements. Deduplicator key changes also purge the old keyspace before the replacement starts; reorderer ordering changes flush the old ordering buffers before swapping. Relay materialized-state changes update membership in place. Emitter source-predicate, sink, publishing-mode (including any confirmation window, timeout, or retry-policy variable), client, codec, input-collection, and attachment changes drain and replace only the affected emitter task. Every ingestor alteration quiesces and drains only the affected ingestor instances under their declared source mode, then starts their desired source configuration from the published schedule. A SET QUIESCE operation still uses this level; the mode active when the hold began governs that hold. A client ingestor suspends admission for the hold, and its producers stay attached across the swap unless the alteration changed their endpoint contract; see Client Ingestors. Reingestor alterations replace their relay consumers and branch-entrypoint wiring; generator alterations quiesce and replace their timed task after flushing pending route output. Correlator, window-processor, inferencer, and WASM-processor structural changes use this level as well. A WASM processor participates like every other stateful node: the host gates its input relays, asks the guest to release what it buffers, checkpoints it, and restores that checkpoint into the replacement instance. A change of the module a processor binds instead starts a new guest-state generation for every branch, so its replacement starts without guest state; see Resource Versions And Bindings.
  • A schedule change that only adjusts replica roles is DYNAMIC. A planned primary-owner change uses ENTITY_PAUSE, even when no model changed. RELOCATE is classified this way: it reports ENTITY_PAUSE when it moves at least one runtime node in a running domain, and DYNAMIC when it moves nothing or the domain is stopped. Both reach every live node as narrow activations: a runtime node whose primary owner and replica set stay fixed does not stop, restart, restore a snapshot, or reopen an external session. Its in-flight batches, retained REQUIRED WAIT work, branch instances, branch-local state, and ingestor session continue. Producers and materialized state readers rebind to a moved node at the published revision. A cluster node that gains or loses only a replica role starts or stops replication without restarting the primary. A hard colocation group moves in one narrow activation, and a model batch applies its model and schedule changes together.
  • DOMAIN_PAUSE changes stop ingestion and generators across the domain and fully drain attached work before commit. Relay schema or branching changes and schema or wire-schema definition changes use this level. Changing the membership of an emitter’s FROM relay list also uses this level because it changes graph topology. Configuration entities use this level too: codec, client, endpoint, signaling-protocol, hash-map, and UDF definitions, vhost hostnames, adding or removing a vhost’s TLS, binding a vhost to another TLS resource, and branch schema, TTL, and eviction settings. Their consumers read that configuration when they are built, so the domain rebuilds around the new models rather than reconfiguring in place.

For an HTTP emitter, the method and path expressions are part of its sink definition. Replacing them, the client reference, body selection, or retry mode takes ENTITY_PAUSE; a FLUSH-only alteration remains DYNAMIC. The gate keeps the admitted request with its original destination and prepared bytes until the old task drains. If it cannot drain, the mutation remains unapplied. A transaction that drops and recreates the same emitter evaluates its complete candidate as one model change and still drains the old emitter before replacement. Changes to a client’s own definition use the configuration-entity DOMAIN_PAUSE above.

An entity-paused model change also gates everything downstream of the affected model, so a dependent node cannot observe a half-applied change through its input relay.

Coordinated WASM Guest-State Reset

The control plane owns one reset operation for a WASM processor. Its target is always one of three typed scopes: the explicit unbranched instance, one concrete branch, or every concrete branch. A request for an unbranched instance is rejected for a branched processor, and a concrete or all-branches request is rejected for an unbranched processor. The operation takes the domain’s exclusive alteration lock, so reset, lifecycle, model, placement, ownership-handoff, and resource rebinding decisions cannot publish competing schedules.

The coordinator engages a branch-selective gate on every live node for each input relay of the processor. A one-branch gate waits for dispatches already admitted to that branch and leaves sibling branches open; the unbranched and all-branches forms select their complete declared scope. The owner then drains accepted input and stops each selected branch task at its serialized callback boundary. Completed callbacks and their buffered output are finalized once through the ordinary output, checkpoint, and acknowledgement paths. Work still suspended on a materialized dependency is discarded with the old task and negatively acknowledged, and old timeout handles are cancelled.

Before changing durable state, the owner creates a fresh guest for every selected live branch, runs initialization without restoring saved state, and captures its initial save in memory. A failure here restores the stopped branch tasks and releases the gate; the schedule, generation, stored checkpoints, and branch lifetimes remain unchanged.

Publication is a two-phase schedule transition identified by the administrative command’s stable execution reference:

  1. Publishing advances the selected branch generation in the Raft-backed schedule. Every live replica installs the new state identity before the owner while the selected relay scope remains gated. This ordered local activation does not wait on the ordinary cluster runtime-revision barrier, because a failed owner checkpoint leaves that same barrier incomplete. This is the point of no return: checkpoints and replica state of the replaced generation can no longer address current state.
  2. The owner installs the selected fresh branch tasks, durably publishes their branch lifecycle, writes each initial guest checkpoint, and waits for every assigned replica. Only then does the coordinator publish Ready and release the gates.

A failure before Publishing preserves the previous lifetime. A failure after it reports that the reset is committed but not usable and leaves the scope fenced. Repeating the same execution reference resumes the same generation and missing durability work; it does not start another lifetime. Restart, leadership change, and owner recovery read the published phase and follow the same path. Offline replicas catch up under the new generation, and a stale former owner, replica, or handoff preparation cannot reinstall bytes from the generation that was replaced. This one operation serves every trigger: the RESET WASM PROCESSOR ... STATE statement, which a transaction records as an ordered effect rather than a model mutation, a guest’s request for a new lifetime of its own branch, and ON REJECTED STATE RESET. See Coordinated Reset.

Backing Up A Domain

A normal domain backup acquires a replicated mutation lease under its persistent command execution. The leader also takes its node-local alteration guard, pauses and drains the domain, reads the configuration at the cut, and asks state owners to stage their checkpoints. It resumes the domain and explicitly releases that domain’s lease before transferring its sections into the archive. A cluster backup repeats this process per domain, so one domain’s archive transfer does not hold another domain paused or mutation locked. A stopped domain is confirmed under its lease and has no intake to pause; WITHOUT STATE takes no mutation lease, and WITHOUT PAUSE reads a live checkpoint without quiescing. A retry still uses the command execution’s stable identity and outcome. The backup drain reads admitted work on every live node and uses a separate confirming force-flush round after the cluster first appears quiet. A parked materialized-state wait is reported but does not keep the cut open, and neither do the rows a window retains, which the cut captures as window state. If work reappears, the leader drains and confirms again. Each drain request is bound to the authenticated coordinator process; a successor cannot reuse its cut identity. The cut captures each materialized relay’s fresh Arrow views and scalar identities under its assignment barrier. Paused generators release their admitted-work guard after flushing, so the drain can reach zero while their branch tasks remain paused.

Restoring A Backup

Before admission, restore verification measures the manifest and obtains a separate retained restore_metadata reservation for archive descriptions, parsing, planning copies and native conversion scratch. The default per-node ceiling is 2 GiB; raw resources and guest saves stay on disk. The verified description, parsed Models and reservation share one owner that encoding jobs retain until they finish, including cancellation. Parsing and the pure restore planner run on admitted bulk CPU workers. Native checkpoints stream into quota-owned files and use the same bounded installation path as guest saves. The ordinary bulk budget stays at 32 MiB, with fixed 2 MiB conversion I/O and installation jobs. Backup And Restore owns the preparation estimate and its allocation limits.

A restore is a persistent administrative command whose progress is replicated state; the operator view is in Backup And Restore. The leader admits a restore under its execution reference only after the archive verified and the whole restore planned, and admission takes the mutation lease of every domain the restore creates. The admitted execution records the RESTORE statement, the size and digest of its archive, every step the restore has applied, and what its users step did. The terminal result stores the typed restore report beside the outcome, so a retry after the restore finished returns the same report.

Three consensus commands change the cluster on a restore’s behalf. Each is refused unless the execution it names is an applying restore:

  • Apply restore step applies one step’s effect and records the step in the restore’s execution, in one command. The users step imports every archived user with its password hash under the user policy, and checks every user before it writes any. A domain step creates the domain stopped, with its declared resources and their version sequences, only under the restore’s lease and only where neither the domain nor a catalog of those resources exists. Creating the domain also records a durable pending installation. The resource step records completion of its imports. The model step carries the exact installation authority and releases the start gate only when the currently admitted generation has published its complete state set everywhere. A step already recorded changes nothing, and a step whose prerequisite is not recorded is refused: a cluster restore imports its users before it creates a domain, and each domain is created, then given its resource versions, then its models.
  • Begin restore state installation admits a fresh generation for an applying restore after resources complete and before models complete. It requires the stopped domain and authoritative mutation lease and binds the generation to the committed entry, execution and leader tenure. Local and remote storage mutations validate this authority while holding the applied-state read guard through publication and handle clearing.
  • Import resource version publishes one completed version under its archived number, as Restored Versions describes. It is refused before the domain is created, and, for a version not imported yet, once the domain’s resource step is recorded.

A domain’s models are applied as one direct model batch under the restore’s lease, with full graph validation and the leader’s content checks, and without the statement and source-byte limits of a transaction. The lease keeps every other command from changing the domain while the restore holds it, so a leader that finds exactly the archived models already committed knows that an earlier attempt applied them. It stages all compatible state before any node replaces its published set, then publishes one durable batch per node. Only after every publication succeeds does it record the model step and release the start gate. For RESUME, this same consensus effect sets Running and installs the archived clock mapping, preserving the archived start generation and start point. State installation includes materialized checkpoints bound to that generation before any branch or generator activates. Default restoration remains stopped. Terminal failure releases mutation leases but retains the gate, so an incomplete restored domain cannot start after failure or restart.

A step’s effect and its record commit together, and an import or a model batch that committed before its step was recorded is recognized rather than repeated, so a leader that resumes a restore never applies a step’s effect twice, whether or not an earlier leader’s last proposal committed. Only the node a client streamed the archive to holds it. Reconciliation therefore leaves an applying restore alone on a leader without its archive, and the restore resumes from its first step not recorded when a retry streams the archive to that leader. Once the retry validity of the execution reference ends without one, the leader finishes the restore as failed, and the steps it recorded stay applied.

Planned Ownership Handoffs And Failover

Node drain, the ownership move of a graceful-shutdown drain, placement consolidation, and RELOCATE are planned ownership handoffs. Nervix computes the complete target schedule before it engages a hold. It then fences dispatch at the affected subgraph boundary on every live node, stops new intake for each moved ingestor, and drains work already admitted to the moved unit. Ownership-handoff intake does not consult ON QUIESCE: an already admitted payload continues through its routes, while polling and endpoint admission stop. The drain includes relay rings, processor work, moved-ingestor ACK roots, emitter buffers and active publishing, and an Iceberg emitter’s staged commit. Internal relays whose every producer moves with the same hard group remain open so admitted work can reach the group’s output boundary.

Before publishing the ownership freeze, each participant requests a force flush and drains its local affected work with the intake and boundary gates already closed. Routes and processors can therefore finish the obligations that make their state ready to capture. A held handoff denotes that completed drain and the installed freeze; frozen owners retain their state until capture and activation finish. Preparation retains its original deadline, distinct from the gate lease that protects activation. Repeated engagement requests use the same coordination identity and scope; a bounded reply timeout does not create another flush or restart the lease.

Nervix writes the new schedule only after that drain succeeds. A drain writes and activates one hard group or independent node at a time. Placement consolidation gates all of its moved groups together and publishes its model and assignments in one schedule update. Only moved runtime nodes restart; unaffected nodes and branches keep their sessions, buffers, state, and in-flight work. The gate is released after the destination owners activate the published runtime revision. A fence or drain timeout releases the old graph without writing the candidate schedule. If activation fails after a schedule commit, the gate stays closed until its lease deadline rather than opening before the destination is ready.

Pending REQUIRED WAIT materialized records cannot finish a planned drain because the dependency is absent. They do not block the handoff; destroying the old task negatively acknowledges their attached work. Replicated state is available immediately when the destination was already a replica. Otherwise the state kind starts from its normal empty or local recovery boundary. When the schedule has a replica slot, the live former owner is the first replica candidate after the new primary.

RELOCATE differs from the others in one respect: it holds and commits its whole unit at once, because the unit is the plan the operator inspected and approved. A hold that cannot complete leaves the domain on its previous schedule and moves nothing. It holds the domain’s exclusive alteration lock from planning through release, so it and a concurrent model change, placement change, or DRAIN NODE of the same domain are mutually exclusive. RELOCATE is immediate, non-transaction content, like CORDON, UNCORDON, DRAIN, and DROP NODE, because its plan depends on live cluster state that a queued transaction cannot pin. DESCRIBE RELOCATION is read-only content served by any cluster node. See Placement Policies for the statements, the selection forms, and the plan output.

DRAIN NODE cordons first, visits domains and schedule units in canonical order, and continues with independent units after one times out. Its result lists every successful move and failure. Any failed unit makes the command unsuccessful, while a later DRAIN NODE retries the units still owned by the cordoned node. Endpoint and Syslog listeners bind on every live node and are not schedule units.

Shutdown And Recovery is the complete account of stopping a node and recovering from a forced ending. This section states how a graceful shutdown uses the planned handoff above.

A server process begins graceful shutdown when it receives its first SIGINT or SIGTERM. It registers both signals before it starts any other work and supervises them until it exits, so a signal received during startup takes effect once startup completes. Every later SIGINT or SIGTERM abandons graceful shutdown: the process logs the phase it had reached and exits at once with status 128 plus the number of the signal that forced it, without running its remaining phases. The rest of the cluster observes that exit exactly as it observes a crash.

Graceful shutdown has one deadline, set by --shutdown-timeout (NERVIX_SHUTDOWN_TIMEOUT, default 50s) and measured on the process monotonic clock from the first stop request: the first SIGINT or SIGTERM, or a public listener that fails. A later request never restarts or extends it, and domain pacing never changes its physical length. Every shutdown step described below waits at most until the deadline. When it passes, the process logs the phase it had reached, reports shutdown deadline expired; abandoning graceful shutdown, and exits at once with status 1 without running its remaining phases, so the rest of the cluster again observes a crash. Whichever comes first, the deadline or a repeated signal, decides how the process exits.

When graceful shutdown begins, the process advertises that its current incarnation is terminating. The incarnation remains live for Raft and for ownership handoffs already in progress, while placement and explicit relocation exclude it as a new destination. The advertisement is transient state of that process incarnation, so a restarted incarnation is eligible again unless the stable node name is cordoned in Raft. The process then stops accepting on its public gRPC, connector, observability, and console listeners and closes the client connections they had accepted. Closing a connection cancels the requests it carries, such as a session stream or a resource upload that is waiting for its client, so no client can delay the drain or the process exit. Work that a session command had already started, such as a transaction commit, keeps running, and terminal teardown waits for it until the shutdown deadline.

Graceful shutdown records whether that stable node name was already cordoned before it invokes the drain. Its cleanup clears the drain cordon only when shutdown began with an uncordoned node, and it runs after a successful, failed, timed-out, or unanswered drain attempt. A pre-existing operator cordon therefore remains set across shutdown and restart. When the drain timeout or the shutdown deadline passes before the node observes a leader to request its drain from, nothing was cordoned and no cleanup runs. The cleanup runs beside the completion of admitted work in place; Releasing The Drain Cordon owns its bound and what a cleanup that cannot complete leaves behind. A follower requests both the drain and the cleanup from the leader over the cluster interconnect, authenticated by its node certificate rather than by a user credential; see Topology Cases.

A graceful-shutdown drain has two parts that share one drain timeout, and the drain also ends when the shutdown deadline passes first. When another live, schedulable Raft voter exists, the node first moves its scheduled work there through the planned handoff above. It then completes the work it has already admitted in place. That second part is the whole drain when no replacement exists, such as on a single node or on the last schedulable node of a cluster, and it also covers listener ingestors, which bind on every node, and any unit whose move failed. The terminating node stops new intake on all of its ingestors, whatever their ON QUIESCE mode, and its generators stop producing. Work already admitted keeps flowing while the node’s relays, processors, emitters, and acknowledgement paths stay alive: Nervix repeatedly force-flushes ingestor routes, processor collections and route buffers, message-error routes, reingestors, and emitters, whatever their FLUSH EACH cadence, until no relay batch, node work item, emitter buffer or publish, or admitted acknowledgement root remains. One more force flush then confirms that nothing is still moving, so work an upstream flush publishes after a downstream node finished its own flush is not left behind. Source acknowledgements and commits, such as Kafka consumer-group offsets, complete before the source session stops.

Terminal teardown stops the node’s tasks only after that drain completes or its timeout passes. It gives each background task at most two seconds to stop, waits for work that sessions and commands started until the shutdown deadline and cancels whatever still runs then, and finally stops the runtime, consensus, cluster membership, and the interconnect and releases the node’s storage. A timeout reports the drain as abandoned, and teardown negatively acknowledges the remaining work: a source with external acknowledgements redelivers it after restart, and a MODE NO ACK source loses it. A sink that stays unavailable therefore holds the drain until its timeout. Pending REQUIRED WAIT records do not hold the drain open; every force flush retries them against the state that is present, and teardown negatively acknowledges whatever still waits. Payloads that other live nodes publish into this node’s relays are admitted work as well, so an owner that keeps publishing extends the drain until its timeout. When every node terminates at once, each node completes its own admitted work within its own drain timeout, and work that reaches a peer after that peer finished its drain is negatively acknowledged.

DROP NODE records the stopped process incarnation before removing its Raft membership. It reads the newest identity from both live gossip and the failure detector’s dead process identities, so a node can still be removed after Chitchat stops reporting it as live. Delayed gossip cannot admit that process again. Starting the node again creates a newer incarnation, which can join the cluster normally.

Unexpected owner loss remains a termination and uses the failover path. The failed task and its volatile buffers disappear immediately, attached work is negatively acknowledged, and the scheduler promotes a live replica or chooses a fresh owner. Failover does not wait for the planned handoff gate. If a former owner disappears while a planned hold is active, that hold aborts without publishing its candidate; ordinary failover then relocates from the last committed schedule.

Failover acts on a voter that gossip no longer reports live. A leader whose reconciliation has just started, as the first node to lead after a whole cluster restarts does, can reach a quorum before gossip has observed the other voters live. Each process’s reconciliation task retains its live observations, including those made while following. For its first ten seconds, it makes no automatic scheduling decision while any current voter has never been observed live by that task. Chitchat can classify a voter’s first relayed heartbeat as dead because it has too few heartbeat intervals to estimate liveness; that initial verdict does not satisfy the live-observation requirement.

A voter that returns within the grace keeps its work and the state that node holds. A voter already observed live can undergo ordinary failover if it subsequently becomes unavailable once every current voter has been observed. The grace expires after ten seconds even if a voter never returns, so genuine owner loss still makes progress. Leadership changes retain the process’s observations and its original deadline. A process restart starts a fresh observation history; after the grace the task discards that history.

Forced recovery stages the destination’s checkpoint inventory under the destination process incarnation and the complete target-schedule fingerprint. Applying staged checkpoints accepts only that exact preparation. A missing or mismatched preparation does not imply a reset; without an exhaustive reset outcome in the schedule, activation fails and leaves every saved checkpoint unchanged. After activation, the durable completion belongs to the ownership transition itself, so reapplying its retained schedule after a destination restart or a later domain rebuild preserves any newer checkpoints the destination has published. A state reset occurs only when the accepted recovery decision either stages the recreated checkpoint inventory or reports a reset outcome for every state component owned by the entity.

A forced recovery of a WASM processor also starts a new guest-state generation for every branch, in the schedule publication that names the new owner, and that schedule is the one the recovery preparation is fingerprinted against. The destination stages the checkpoints of the generation being replaced and activation publishes them in the new generation. A snapshot of any earlier generation, whether it is held by the lost owner, a replica that was offline, or an older preparation, is never selected, installed, or restored again, even when its revision is higher than every current one. A planned handoff keeps the generation. Generation transitions are published only by a committed schedule, so they are serialized with every other mutation of the domain through the same lease or automatic-decision fence as the schedule itself.

For a planned handoff, each prepare destination becomes a tracked participant before the side-effecting request is sent. A lost response and cancellation of the coordinating future are therefore cleaned up like acknowledged preparations. Cleanup retries an exact discard and never uses gate release as evidence that the durable preparation was removed.

The surviving leader reconciles preparations after it orders all inherited schedule proposals with a committed consensus barrier. A destination preserves a preparation when its exact transition is the committed owner change, regardless of coordinator or participant restart, so normal schedule application can activate it. It also temporarily preserves uncommitted work from the same leader process while the bound source and destination incarnations remain live, the base schedule still owns the entity on the source, and the exact handoff gate remains held. It durably removes every other preparation. Replacement, discard, activation, and reconciliation compare the complete operation identity, so duplicate or reordered work is idempotent and cannot affect another operation’s preparation.

Entity-gate leases are deadline-bound. They release their relay fences and ingestor holds at the configured entity-gate deadline even if the coordinator disappears. A node that joins during a hold applies the published schedule through its normal revision path.

An unchanged candidate contributes no aspect. An all-no-op batch therefore performs no storage write or schedule publication and reports DYNAMIC, even when the running domain has work that could not currently drain. A DROP followed by CREATE of the same key in one batch is compared as one modification, so recreating a relay with a different schema cannot bypass domain quiescing. An immediate model command reports the level it executed. A queued model command reports its own preflighted model level before execution. COMMIT reports the maximum effective level actually executed and the total planned relocations when the transaction moved owners. Nervix recalculates the effective level and target schedule from the complete candidate at commit time.

For an immediate model alteration, local registry persistence and schedule publication are separate steps. If schedule publication fails, Nervix restores the previous models and republishes the previous schedule at every quiesce level; a domain-paused batch additionally resumes the domain. A model batch that creates, changes, or drops a VHOST also waits until the HTTPS listener of every live node has installed the resulting certificates, and fails with the node and reason when one cannot. A batch that did not pause is then rolled back, and the command waits until every listener presents the restored certificates before it reports the failure. A paused batch has already resumed by then, so it keeps its committed models like any other failure after activation. During a replicated transaction commit, the new schedule and transaction progress become visible in one Raft operation. The leader rolls back an unpublished local registry candidate, and every node synchronizes its registry cache from the committed schedule across leadership changes.

What it does not do is provide transactional semantics for the actual records flowing through the graph. Message batches and ACK state are data-plane hot-path state and are never persisted by the control plane.

Execution Plans

An execution plan is the in-memory boundary between a committed domain schedule and the tasks that run it. NSPL becomes a semantic Model, registry validation establishes its contracts, and scheduling commits the graph and its placement. The decision layer then converts the committed schedule into one complete typed execution revision. The runtime binds node-local resources and executes that revision. No production data-plane task reads a semantic Model, a scheduled node’s Model configuration, or the active registry graph.

This chapter follows that boundary through construction, installation, changes, and recovery. Control Plane owns schedule publication and transaction quiescence; Data Plane owns record movement and state. The chapters linked below own the detailed contracts of their respective engines and integrations.

Ownership And Persistence

LayerOwnsPasses to the next layer
Language and vocabularyStructured semantic Models, typed names, branch declarations, resource versions, schedules, and state identitiesA Model whose expressions are structured rather than executable text
Registry and decisionsReference, type, sensitivity, capability, branch, and placement validation; pure schedule classification; construction of typed node and domain plansA complete execution revision and a typed change decision
Control planeCommitting models, domain state and schedules through consensus; ordering activation, quiescence, ownership handoff, and local installationThe committed schedule and domain state to the planner; the resulting revision to the runtime
Engines and infrastructureVM lowering and compilation, schema and codec engines, connector contracts, resource storage, WASM preparation, and state storagePrepared programs or local resources when the runtime binds a plan
Data planeBinding a revision to local capabilities, publishing routing state, starting and refreshing tasks, branch-local execution, and ACK trackingArrow batches and external source or sink operations

Models, domain lifecycle, and schedules are strongly persisted control-plane state. Execution revisions and their compiled programs are reconstructed in memory from the committed schedule; they are never persisted as a second graph definition. Selected node state has its own snapshot, replication, or checkpoint contract. In-flight batches, payload attempts, ACK guards, tokens, and maps remain volatile. Typed States And Validation Boundaries explains why absence, identity, and conversion failures are decided at their owning boundary.

From A Schedule To One Revision

The application reads a coherent committed cluster state. Its planning boundary compares the new cluster schedule with the last schedule successfully applied on this node. For every domain in the desired schedule, it builds a complete ExecutionRevision before asking the runtime to apply anything. The revision holds:

  • the domain and one source digest derived from the complete scheduled domain;
  • typed nodes with identity, schema fingerprint, resolved branch contract, placement, ownership transition, gate relays, and applicable state generations;
  • activation, resource, entrypoint, emitter, processor, and message-error plans from that same schedule; and
  • the ownership-handoff fingerprint computed from the committed schedule’s established bytes.

The source digest identifies which schedule produced the installed revision. A planned change also carries its predecessor digest, so an incremental application is attempted only when the currently installed revision is the predecessor it was planned against. The handoff fingerprint has a separate purpose: ownership preparation and activation must agree on the exact committed schedule, including its assignments and state generations. Planning preserves its established encoding and does not substitute the source digest for it.

Registry validation rejects invalid candidate Models before they become an active graph. Execution-revision construction resolves references and lowers the remaining schedule-specific contracts before local installation. A missing relay, schema, codec, branch, source, client, or error destination fails at that planning boundary with the owning node or route identified. The runtime never repairs a missing decision with a Model lookup or an implicit default. Only a committed schedule supplies the placement and state authority used for installed execution.

Process startup has an earlier registry path: RuntimeChanges carries each recovered active graph inside the registry and application boundary. Before runtime application, it projects that graph into an unplaced typed revision; the runtime receives only the revision, never the graph or its Models. This prepares the local runtime boundary before consensus admission. The subsequently admitted committed cluster state supplies the schedule, placement, and ownership authority for normal running or passive execution. The cluster-state listener and local WASM-reset application both use the same schedule-to-revision planning boundary.

Typed contents

Revision partDecision made before installationNode-local binding or execution
Domain activationCompiled schemas; wire formats and codecs; relay schema, branch retention, capacity, and materialized-state presence; VHOST, endpoint, and signaling referencesLoad pinned descriptors, compile codecs and signaling protocols, install relay services and endpoint routes
EntrypointsIngestor source, client, codec, ACK and quiesce contracts; ordered routes, filters, branch construction, and reingestor input edgesBind VM programs and connector resources; open sources and attach relay consumers only after preparation succeeds
Processor specificationsNode topology, exact branch policy, ordered outputs, materialized dependencies, and schedule residue that affects a revisionBind prepared VM, window, inferencer, and WASM artifacts into a published processor plan; instantiate one task per concrete branch
ResourcesPinned lookup file and codec, UDF program, generator source and ordered routes, WASM module file and scheduled assignmentLoad the named local version, build lookup indexes, prepare modules, and start branch-local generator or processor work
EmittersTyped sink and optional connector client or codec, ordered source relays and predicates, construction, request and ordering expressions, flush, batching, ACK, and error policiesResolve local mounts and schemas, bind programs, register local or remote consumers, and open the sink
Message errorsSource and optional partial-output schemas, target relay and branch, route flush contract, and ordered SET programBind once for the installed revision; execute it for failed records using their captured state

The tables describe distinct decisions in one revision, not independently selectable runtime configurations. Every node, including one placed elsewhere, is planned from the same domain schedule. This lets a local owner, its replicas, a remote relay consumer, and a future owner agree on the same route and state identities.

Expression lowering produces engine-level programs in the decision layer. Node installation compiles those programs against the exact installed schemas, sensitivity, lookups, and UDFs and retains the prepared result. Validation may compile an expression earlier to reject an invalid statement; that validation result is not a persisted executable program. Branch tasks share their installed processor plan and its prepared artifacts. A newly appearing branch and an existing branch refreshing between batches take the same published revision. VM Functions owns compilation lifetime, selected-row execution, and VM failure behavior.

Installation And Publication

Application serializes local installations. It plans against the last successfully applied schedule, then passes the typed cluster revision, domain lifecycle state, and clock authorities to the runtime. The runtime records a cluster revision as applied only after its schedule application succeeds. An unsuccessful attempt leaves that revision unapplied and keeps the same predecessor for a retry, including after a local WASM reset operation. A node starting after a forced ending first establishes its committed-state catch-up fence before installing execution; an earlier owner’s local state cannot make it act under an obsolete assignment.

A full running build binds the domain clock, pinned resources, codecs, relay boundaries, state placements, processor and error plans, connectors, and node tasks from the revision. Its DomainExecution retains the revision and node-local task ownership. The domain routing snapshot holds the coherent relay services, schemas, branch declarations, materialized-state ownership, lookups, UDFs, codecs, signaling protocols, and processor plans that record and batch paths need. Lifecycle code stages changes privately and replaces the published snapshot with one pointer operation. A reader sees one complete routing snapshot; long-lived tasks cache its handle and refresh their typed plan identity between batches. The endpoint routing index and other lifecycle resources are installed at their own cutover points under the application sequence. This is a node-local publication guarantee, not a distributed atomic transaction across cluster nodes.

The routing snapshot also retains each materialized relay’s installed-state publication and the domain’s relay-state epoch. Assignments are registered before these handles are staged; a missing required publication is an installation invariant failure. A materialized origination capability moves once into its concrete branch runtime or scheduled relay-state task. That task owns the mutable selections, while dependency readers, generators and captures retain immutable row views. Lazy branch installation binds every materialized relay independently within an unchanged routing epoch; having installed one relay does not suppress another. Eviction withdraws the branch’s materialized record before releasing its writer. Read and replica-installation capabilities remain independently retainable.

A stopped domain uses a passive build from the same activation and resource decisions. It keeps schemas, codecs, relay descriptions, materialized-state identities, lookups, bound error routes, and endpoint definitions available for inspection and recovery, while routing is marked passive and its graph intake and node execution tasks do not run. Configured server listeners remain bound on every live node regardless of domain status, leadership, or placement; a stopped domain does not admit its graph’s traffic. Resuming or changing the domain start version builds the appropriate running revision rather than treating passive state as an active task graph.

Schedule planning assigns endpoint and Syslog sources from live voter membership, independently of the eligible placement set. A cordoned restore coordinator therefore still serves its local endpoint intake after RESUME, while placed relays and processors execute on eligible nodes. Removing a voter recomputes listener assignments from the remaining live voters before publication.

WASM ownership preparation follows the installed revision’s execution mode. A passive revision retains and transfers the validated checkpoint inventory without instantiating guests or reading its stopped clock. Guest restoration is deferred until START installs a running revision.

Server endpoint installation replaces that domain’s complete set of host-and-path definitions in the node’s immutable intake table. Source starts bind prepared intake lifetimes to the same table; source ending and domain teardown end those lifetimes before withdrawing them. Other domains’ definitions and intakes survive the replacement. Passive execution keeps endpoint definitions for inspection but publishes none for request routing. HTTP resolves one route per request and a WebSocket retains the route and signaling protocol selected at upgrade. The detailed admission and publication contract remains in Data-Plane Concurrency.

Data-Plane Concurrency owns the publication and branch refresh protocols. Domain Clock owns the execution-time capability bound during installation. Client Session Protocol owns how a client observes an installed domain and recovers a command outcome.

Incremental Changes And Rebuilds

The decision layer compares the previous and desired schedules while both Model-bearing schedules are still available there. It translates the result into an ExecutionDelta that contains only typed runtime instructions:

ChangeRuntime application
UnchangedKeep the installed tasks and state when the predecessor, lifecycle, and start version still match.
DynamicApply the typed capacity, processor, WASM reset, emitter flush, or VHOST TLS update; bind and publish the desired processor and error plans.
Entity swapFence affected input relays, force-flush as required, rebind reassigned placement and selected node tasks, then publish the desired revision and release the gates. Unaffected nodes and their branch state stay in place.
RebuildStop the preceding domain execution and install the complete desired running or passive revision. Domain-wide changes, a missing predecessor, and lifecycle changes can require this path.

The schedule classifier retains Model-based change-aspect and quiescence decisions in the registry. The runtime receives typed dynamic values, affected node identities, state purges, and gate relays. It does not reclassify the new schedule. An entity swap that fails locally falls back to a full rebuild of the desired revision; the actual wider engagement is reported as a recovery expansion. If that rebuild also fails, application fails and the committed revision remains eligible for retry. A plan is complete even for a narrow change, so fallback uses the same desired revision rather than reconstructing one from live tasks.

The swap sequence fences delivery before stopping an owner, prepares new local placement and programs, replaces relay owner and remote-consumer edges, and publishes the routing snapshot before waking materialized-state waiters. An owner that still executes locally across a placement change keeps its task; a moved owner rebinds only its affected runtime and state placement.

The affected relay gates remain closed through publication. An already buffered owner batch takes a nonwaiting permit before fan-out: it finishes under the prior consumer set if admitted before the fence, or fails its record ACKs so its source retries under the published consumer set.

Dynamic processor revisions reuse unchanged prepared plans, while a changed node gets a new typed plan identity. Transaction Quiescence And Impact Inspection owns planned versus actual pause scope; Control Plane owns schedule publication and relocation.

Sources, Sinks, And Error Routes

An ingestor start uses the scheduled source class, client configuration, codec, ACK boundary, and bound ordered routes from its entrypoint plan. A reingestor binds its source predicates and incoming-branch materialized dependencies before attaching relay inputs. Initial build, entity swap, reassignment, memory-pressure resume, and Kafka domain-offset placement all use those typed contracts. Route plans decide whether output is unbranched, preserves an incoming key, or constructs a new key; the actual branch state remains local to each concrete branch. Transport headers remain connector-owned and enter a relay only when copied into schema-backed fields.

The emitter plan pairs its ordered relay inputs with one typed sink and its applicable client, codec, expression, flush, batching, and error contracts. A native TO CLIENT sink has no external client or codec. Its plan carries the exact output schema, acknowledging sequential or parallel window, physical ACK timeout and retry policy, maximum rows and IPC bytes, construction program, and attachment boundary. Registry validation rejects an incompatible schema or missing batch contract before the plan can run. A flush-only dynamic change keeps that delivery contract; a contract change replaces the endpoint and ends existing consumers. The host binds mounts and programs before opening a sink; reconnects and retries reuse the typed configuration and retained prepared write. Swaps install consumer edges from the new plan. The connector receives source or sink settings and per-record outcomes, never graph placement authority, Models, ACK maps, or runtime state. Connector Crates And The Connector Contract owns each external transport’s header, retry, ACK, and commit behavior. Client Session Protocol owns native consumer delivery and settlement.

An error route is keyed by its domain, owning node, optional source route, and destination relay. The planner resolves the eligible original input, the optional all-nullable partial output, captured materialized-state shape, destination schema and exact branch, flush policy, and ordered assignments. Installation binds its program against local capabilities once. A failed record uses that bound plan with a structured, non-sensitive error reference, code, operation, fields, and timestamp. A handler failure cannot invoke the same handler recursively; an unavailable route is reported and does not acknowledge the source record. A route change replaces its bound plan with the installed revision. Buffered plans retain their prepared delivery queue and exact worker handle. Binding starts no worker and retires no active route. Successful running publication activates each prepared worker once, retires unselected/replaced workers and publishes the complete routing snapshot. Failed and passive builds cannot replace running delivery. Subsequent failed records send through the bound handle, selecting retirement against bounded queue admission; accepted deliveries drain through the worker that already owns them. Errors And Diagnostics owns the error taxonomy and public reporting contract.

Placement, Resources, And Recovery

Each typed node carries the scheduled primary, assigned nodes, branch and schema fingerprint, ownership transition, and the state components it may own. State placement and restoration use that identity, including a WASM processor’s branch generations. Relays have one owner; only their optional materialized state has scheduler-selected replicas. A configured listener is different: it runs on every live server node even when its graph node is not the local primary. Remote relay-consumer edges are derived from the same planned nodes and inputs as local tasks.

Planned handoff prepares state against the exact committed-schedule fingerprint. Reassignment then activates prepared state under the desired plan, fences the previous owner, and starts or retains only the tasks that should execute here. A crash or disconnected owner follows the control plane’s failover and state-recovery rules; in-flight batches and ACKs do not survive it. Restart reconstructs the plan from committed control-plane state and loads only state whose published identity and generation match the desired placement. A processor task restores its branches from its own plan: the plan carries the schema a window processor’s retained rows are read under, so the restore never reads the domain’s routing snapshot, which installation publishes only after it starts the tasks. The restore installs every branch its lifecycle names or none, and the task takes no input until it has; Restoring Processor Branches owns its retry and its failure semantics. A stopped domain reconstructs passive surfaces; a running or paused domain reconstructs the applicable execution and intake state. Shutdown And Recovery and WASM State And Recovery own the detailed handoff, checkpoint, duplicate-window, and restart guarantees.

An archive restore first publishes the restored models as a stopped domain and recomputes its schedule. Archived node assignments are never installation authority. The coordinator requires each archived entity’s schema fingerprint to match the new schedule and stages its checkpoints on the scheduled owner and replicas. Branch lifecycle is staged before WASM guest saves, and each save uses the state generation from the published schedule. Each node atomically publishes its complete replacement set through a small durable pointer to its verified generation namespace, including an empty set on unassigned nodes, then clears passive handles. Restart and passive-state loading select pointer, headers and checkpoint chunks from one database view. Checkpoint jobs queued for a different namespace cannot cross into the new installation. The replicated installation gate prevents START until all nodes finish. A retry of an unfinished model step admits a new installation generation bound to its leader tenure, execution and lease; a delayed preceding attempt cannot mutate the state or its handles after completion. Materialized restore resolves the scheduled Arrow schema once, verifies bounded archive groups, and installs the native checkpoint under the scheduled schema fingerprint combined with the archived domain start generation. Native loading retains one database snapshot while reading bounded sections. A RESUME completion changes the domain to running at that same generation and mapping only after publication and passive-handle clearing on every target node. The new running revision therefore binds generators and branch tasks to installed materialized rows. Backup And Restore owns archive cut modes, state selection, skipped sections, and the operator-visible restore guarantees.

Resource plans carry concrete pinned version numbers from the committed Models. Node binding loads exactly those versions from its local resource store; it never resolves LATEST or reads the catalog during execution. A rebinding is a validated model mutation that produces another schedule and plan. Resource Versions And Bindings owns upload completion, pinning, rebinding, TLS refresh, and load failures.

Failures, Guarantees, And Limits

Validation and schedule planning fail before an invalid candidate activates. A typed planning report identifies the domain and failing part of the revision, with node, route, or reference detail beneath it. Local binding can still fail when a pinned file is unavailable, a VM program cannot compile against installed capabilities, a declared route cadence cannot be parsed for its task, a module cannot prepare, or a connector cannot open. Materialized-state dependencies also bind to the local relay state at this boundary. A local failure to build or change a domain’s execution is reported as RuntimeError::BuildDomainExecution, naming the domain, with the ExecutionBuildError step that failed and that step’s own report beneath it, as Errors And Diagnostics describes. The runtime records the whole chain as the domain’s instantiation error, and an ingestor’s start report as its transient error. It does not mark a failed cluster revision applied. Source and sink delivery failures retain their existing ACK, retry, and message-error classification. Diagnostics identify operations and fields without including sensitive payload values.

The principal guarantees are one current plan shape, one complete typed revision per committed domain schedule, no Model interpretation by production data-plane tasks, coherent node-local routing publication, exact branch and schema identities, and retries of unsuccessful revision applications. These do not make all cluster nodes switch simultaneously, persist in-flight messages, guarantee an external source or sink is available, or turn a connector’s delivery contract into exactly-once end-to-end delivery. Engine-specific resource, batch, expression, and concurrency limits remain in their owning chapters.

Operators observe the committed schedule, domain lifecycle, applied node state, and typed diagnostics through the existing control-plane and session surfaces. Runtime metrics and events report node or connector failures without exposing payloads. A recovery expansion reports when local entity replacement needed a wider rebuild. The execution plan itself is an internal, reconstructible artifact, so it is not an independently persisted or user-editable graph.

Task Dependency Binding

Installation publishes bound message-error plans inside the domain routing snapshot. A failed record retains that snapshot while executing its prepared route. Entity state identity and checkpoint primary/executors/replicas publish through one stable assignment slot before reset callbacks; WASM states retain that slot across schedule replacements. Task startup also binds connector status, accounting, metric, freeze and domain-clock dependencies. Their runtime registries serve registration, teardown and observers; recurring operations use the retained capabilities. See Data-Plane Concurrency.

Replication Routing

State construction publishes resolved replication routes to the installed state and that existing assignment slot. Schedule replacement, branch eviction and entity or domain teardown retire those routes before removing registry entries. Frame handlers read the immutable route publication; announcers and entity catch-up tasks retain their route or lifecycle and never revisit execution or identity registries. See Data-Plane Concurrency.

Transaction Quiescence And Impact Inspection

Nervix plans the effect of a transaction before it changes the running graph. One typed impact report explains what each accepted operation contributes, what each atomic execution step requires, and what the transaction requires as a whole. The same report follows the transaction through commit, application, failure, and retained inspection. A planned pause and a pause actually engaged are separate facts.

This chapter owns impact planning, scoped quiescence, actual engagement reporting, inspection, and retention. Control Plane owns the broader transaction lifecycle and registry mutation rules; Command Completion owns when a command is usable and acknowledged. Domain Clock, Cluster Interconnect, Consensus Storage And Replication, and Shutdown And Recovery own their respective time, transport, durability, and process recovery boundaries.

From Accepted Operations To Execution Steps

BEGIN opens one transaction for an existing selected domain. Every queued statement must belong to that domain. A successful append receives a stable, one-based operation number in written order and advances the accepted position. Transaction controls, inspection, and rejected admission consume no number. An exact retry of an admitted append returns its original number and identified preview; it does not append again or extend transaction activity. The original source is retained for display, while execution uses the structured statement.

The planner and executor partition that ordered sequence the same way:

ContentExecution boundary
Consecutive model CREATE, supported ALTER, DROP, and REBIND RESOURCE operationsOne atomic model run, evaluated in written order against one candidate graph.
ALTER DOMAIN, START, STOP, CREATE RESOURCE, or RESET WASM PROCESSOR ... STATEOne step each; each ends a preceding model run.

A model run’s configuration and schedule are published in one authoritative step. Its base-to-final diff determines the effective impact: an intermediate change can be cancelled, a drop followed by recreation can change the schema or wiring, and several individually valid operations can jointly require a wider pause. The run is revalidated as a complete graph before publication. A failure before that publication writes none of its candidate models. Other steps retain their own atomic effect boundaries; the transaction as a whole is not atomic. Commit stops at the first definitive failure and retains its applied prefix. A step can have an authoritative effect while runtime activation is still APPLYING; completion is recorded separately.

Queue admission replays the accepted prefix in this exact order. An unfinished final model run may be admitted with an INCOMPLETE candidate and planning diagnostics when a later operation in that same run can make it valid. An intervening lifecycle, domain, resource-catalog, or reset step ends the run, so a later run cannot repair the earlier one. An incomplete candidate is never a complete empty scope, and COMMIT requires every step to plan completely.

Planning captures the restored domain’s installation gate with its authoritative control inputs. An incomplete restore refuses a queued or standalone START before lifecycle admission, naming the restore execution responsible for the gate. Consensus revalidates the gate at activation; releasing a failed restore’s mutation lease does not make that domain startable.

stateDiagram-v2
    [*] --> OPEN: BEGIN for one domain
    OPEN --> OPEN: admit numbered operation / inspect coherent candidate
    OPEN --> OPEN: reject incomplete or stale commit / no effect
    OPEN --> COMMITTING: validate complete identified preview / freeze plan
    COMMITTING --> COMMITTING: record authoritative effect, then application completion per step
    COMMITTING --> COMMITTED: every step completed and terminal result visible
    COMMITTING --> FAILED: first definitive failure / retain applied prefix
    OPEN --> REVERTED: REVERT or clean session close
    OPEN --> EXPIRED: unbound idle timeout
    COMMITTED --> [*]: tombstone retention expires
    FAILED --> [*]: tombstone retention expires
    REVERTED --> [*]: tombstone retention expires
    EXPIRED --> [*]: tombstone retention expires

Three Levels Of Impact

An operation contribution records its typed operation, ordered reasons, and effects attributable to that statement. It has no independent effective quiesce level or execution outcome. In particular, the ordered clauses of one ALTER remain ordered reasons, while shared affected items can name several contributing operation numbers.

An execution step records its inclusive operation range, completeness, required pause, planned effects, actual engagement history, actual effects, and UNATTEMPTED, APPLYING, APPLIED, or FAILED outcome. A range’s last operation never comes before its first: a stored or transmitted range that puts it there is refused as it is read, with OperationRangeReversed. The effective requirement comes from the whole model run or the single non-model step, including schedule changes. The transaction summary combines the effective requirements of all steps: no pause contributes nothing, entity subgraphs combine their nodes and gates, and any domain pause makes the summary domain-wide. This is the planned maximum. A commit result reports the highest level actually engaged by executed steps, which can be lower after a pre-engagement failure or higher after recorded recovery expansion.

Required levelTyped scopeWhat it requires before the step’s effect
DYNAMICNO_PAUSENo execution gate or domain drain. Configuration, lifecycle, listener, or activation work may still be required.
ENTITY_PAUSEOne named subgraph, its concrete branch coverage, and admission relay gatesHold new admission at those boundaries and drain the affected work while unrelated graph traffic continues.
DOMAIN_PAUSEThe owning domainStop domain intake and generators and drain all attached execution work before publication.

The pause requirement is a typed NO_PAUSE, SUBGRAPH, or DOMAIN value. The displayed level is derived from that value, so a domain level cannot carry an entity-only scope. An entity gate is installed on every live node for the affected relay and branch coverage. The planner includes downstream consumers and every member of an affected hard placement group. Engaging any entity gate also requests a force flush of the full current execution graph; force-flushed work is not the set of paused entities. A domain pause covers all execution nodes on both sides of the change. Data Plane explains the runtime work these gates protect; Control Plane lists the model changes that normally contribute each level. An ingestor’s ON QUIESCE mode controls its external-source behavior under a hold; it is distinct from the step’s quiesce level.

A client ingestor under a hold suspends admission: its producers are told admission is suspended, batches that were queued but not admitted are refused as suspended, and batches that arrive during the hold are refused the same way. The drain waits for the acknowledgement roots of the batches admitted before the hold, which the admission fence guarantees it counts: a batch either has its root tracked before the hold is observed, or is refused with nothing dispatched. Whether producers survive the step is decided by the ingestor’s endpoint contract, not by the quiesce level: an alteration that keeps the contract reopens admission with every producer attached once the step is applied, one that changes it ends them as endpoint changed, and a failed drain releases the hold and reopens admission with the previous execution and every producer still attached.

When a model alteration pauses an ingestor and gates a shared downstream relay, the coordinator first engages an ingestor-only admission hold on every node. It force-flushes the current graph and waits for those ingestors’ route work and admitted ACK roots while the relay gates remain open. A partial branch batch can therefore reach its relay and acknowledging sink. The full subgraph hold is engaged before the intake hold is released, so admission never reopens between the two phases. Both phases share the alteration’s physical deadline and retain separate exact coordination identities. Failure releases the attempted holds and keeps the committed contract; dropping the coordinator leaves their existing owned release and lease cleanup responsible. For a planned subgraph pause, transaction inspection records the intake-only scope separately from the full subgraph scope, with each scope’s actual engagement, drain failure, and release outcomes. An already engaged domain pause covers its ingestors throughout these phases.

A native client emitter keeps its prepared IPC bytes, source members and application ACK wait as emitter work. Force flush releases rows into that same wait; a read or network delivery does not finish the drain. A consumer settlement runs beside the session’s ordered command lane, so an engaged transaction can receive the ACK that allows its own drain to finish. A flush-only ALTER changes only the running task’s cadence. A contract-changing replacement or failed drain follows the same retained-work and rollback boundaries as other emitters: the previous execution remains the owner until the change commits, and a canceled attempt cannot resolve a new owner’s ACK.

The report keeps separate effect sets for configuration creation/change/drop, resource catalog and version bindings, domain lifecycle, ownership moves, activations and deactivations, rebuilds, state resets, force flushes, and affected topology. An HTTPS VHOST version refresh can therefore be DYNAMIC while every live node still installs a new listener certificate. A running-domain primary move raises an otherwise dynamic model or placement change to ENTITY_PAUSE; replica-role changes alone do not. A schedule rebuild can raise a narrower model change to DOMAIN_PAUSE. In a stopped domain, a model or resource rebinding has no running work to pause and reports DYNAMIC, although the changed configuration still matters at the next start. Explicit START and STOP are individual NO_PAUSE steps with lifecycle effects; they determine whether later model steps plan against a running or stopped domain. Resource Versions And Bindings owns what each pinned resource version loads and which rebindings reset state.

An automatic schedule change that moves work away from a node the leader has marked unavailable retains its planned and actual impact. Completion waits for the leader’s current required runtime participants, including the connected local node, and does not wait for preparation by that unavailable node. If the node rejoins, its later catch-up is a separate application of the current revision; it does not change the committed step’s impact report.

Concrete joint and mixed-scope examples

Accepted sequence in one domainEffective plan and interpretation
On a running graph, change a relay’s capacity, then add an optional field to its schema in the same consecutive model run.Capacity alone is dynamic, but the run changes a schema. Its single step is DOMAIN_PAUSE, with both operations attributed to the affected result.
Add an optional schema field, then drop that field in the same model run.The base and final graph match. The run is a DYNAMIC no-op even though the operations keep their individual contributions and ordered reasons.
Change the keys of two deduplicators on independent relay paths in one run.Both contribute ENTITY_PAUSE; the step’s subgraph is the union of the two path scopes. If both paths share an admission relay, that gate appears once with both operation numbers. Unrelated paths stay outside the paused subgraph.
Reset one concrete branch of a WASM processor, then change an unrelated deduplicator key across its declared branch.These are separate ENTITY_PAUSE steps. The reset retains selected-key coverage while the model step covers every key of its own branch; the transaction summary unions their two subgraphs without broadening the reset to all branches.
STOP, change a schema, START, then change that schema again.Four boundaries: stop, a stopped-domain dynamic model step, start, and a running-domain model step requiring DOMAIN_PAUSE. The summary is domain-wide even though the earlier schema step needed no pause.

The qualification scenarios in tests/features/runtime/nspl_transactions.feature and the planner checks in src/registry/transaction/tests.rs exercise the first, second, and lifecycle patterns. The scoped topology and shared-gate checks also exercise branch and relation identity.

Affected Topology And Attribution

Each step carries affected topology before and after its effect. These are snapshots of the relevant graph at that step, not a lookup into today’s live graph. The before side retains dropped nodes and disconnected or rewired relations; the after side shows the graph activation installs. A transaction view composes steps in order, so an entity created and dropped in different steps can be transient even if absent from the transaction’s final graph.

Nodes have typed model-kind and name identities. Execution nodes additionally state whether their coverage is all executions, the unbranched execution, every key of one declared branch, or selected concrete keys. Selected key values are represented by fingerprints; the report does not expose raw, potentially sensitive branch fields. Configuration-only nodes have no execution coverage. Relations distinguish configuration dependency, dataflow, message-error, correlation- timeout, and materialized-state edges. Parallel relations between the same two nodes remain distinct. A configuration dependency explains a model relationship; downstream pause traversal follows record or state effects, so it is not simply a traversal of every dependency in the displayed graph.

The topology includes shared admission gates, branch coverage, hard-group members, moved owners, and the source and destination of each move. Every affected node, edge, gate, and effect retains the operation numbers that contributed to it. This is why a change to an emitter sink, for example, can show a changed emitter, upstream gate, force-flushed work, and replacement activation as different roles rather than as one ambiguous list of “affected nodes.”

Coherent Preview And Guarded Commit

The planner uses one captured view of the domain, models, resource versions, schedule, placement, membership, and liveness inputs for an ordered prefix. Its planning-basis fingerprint identifies the inputs relevant to that report. The report also carries the accepted-operation position and COMPLETE or INCOMPLETE with non-sensitive diagnostics. An identified preview combines the transaction id, accepted position, and planning basis. It names the whole transaction, even when inspection focuses on one operation. A coherent preview is current for the inputs it read; it does not promise that no other command will change those inputs after inspection.

The captured schedule eligibility records every live voter incarnation and the destination candidates after applying termination and consensus cordons. A cordoned voter’s terminating advertisement changes neither set; its loss or restart changes the live identity set and makes the plan stale. The committed topology fence separately rejects changed membership or cordons.

COMMIT can name the expected preview. Before engaging a gate or applying an effect, admission checks its position and captured inputs. A stale expected preview is refused with both expected and current identities; the transaction remains OPEN with the same binding and no effect. The caller inspects the attached transaction again and decides whether to retry. A commit with no expected identity applies the transaction’s current complete preview. Once admitted, consensus stores the report, exact execution plan, captured inputs, and domain mutation fence together with the transition to COMMITTING. Every step then executes that frozen plan; leader replacement or restart does not replan it against later configuration. A conflict discovered after admission is reported as a step failure, not a silent widening or substitution of the frozen plan.

sequenceDiagram
    participant C as Client
    participant S as Session and planner
    participant R as Replicated control plane
    participant G as Runtime gates and owners
    C->>S: append statement with request reference and expected position
    S->>S: plan coherent ordered prefix
    S->>R: durably admit numbered operation and preview identity
    R-->>C: operation number and whole-transaction identity
    C->>S: DESCRIBE TRANSACTION or OPERATION n
    S->>S: read and plan without mutating transaction
    S-->>C: one typed report, text or JSON projection
    C->>S: COMMIT with reviewed identity
    alt identity stale or report incomplete
        S-->>C: refuse before effects; retain OPEN transaction
    else complete and current
        S->>R: freeze report, plan, inputs and mutation fence
        loop each ordered execution step
            S->>R: record requested engagement
            S->>G: engage exact gate or domain pause
            G-->>S: confirmed, failed or uncertain
            S->>R: record engagement and authoritative effect progress
            S->>G: activate or rebuild, then release held scope
            S->>R: record application complete or failed
        end
        R-->>C: terminal outcome and actual aggregate
    end
    C->>S: inspect retained transaction by id
    S-->>C: frozen plan plus recorded actual outcomes

Actual Engagement And Application

The actual history records each pause attempt in order: REQUESTED, then CONFIRMED, a diagnostic FAILED, or UNCERTAIN when a remote response cannot prove whether the gate engaged; cleanup records RELEASED. A request followed by a definitive failure before engagement contributes only DYNAMIC to the actual aggregate. A confirmed or uncertain engagement contributes its possible scope even if the step subsequently fails or the gate is released. The report never rewrites the planned requirement to make a later outcome appear expected.

Ownership-handoff engagement confirms the closed admission hold and requested flush. The coordinator observes each exact operation’s pending drain before capture; a drained response publishes the participant’s capture freeze. A drain timeout keeps the pending node and work counts and releases attempted holds before independent schedule units continue. Retrying a two-second engagement reply timeout waits on the same receiver-owned identity, adds no pause attempt and extends no lease. Preparation retains its original deadline, and cleanup releases every attempted participant.

The step changes from UNATTEMPTED to APPLYING when its authoritative effect transition is recorded. It becomes APPLIED only after required activation, source readiness, handoff, and gate release complete. A failure records a diagnostic and the transaction’s completed prefix; it does not erase a pause already observed. A committed model effect can remain visible despite a later activation failure. The special case is a model step that took no pause and whose HTTPS listener installation fails: its failure record restores the preceding models and schedule before the command reports failure. Command Completion owns the durable completion boundary; Errors And Diagnostics owns typed failure propagation and public diagnostic rendering.

A new leader resumes a COMMITTING transaction from its recorded applying step and frozen plan. Completed effects are not repeated. If an owner timed out, inspection can show UNCERTAIN rather than falsely declaring that no gate engaged. Recovery may have to rebuild a whole domain after an entity swap cannot complete; the actual history then appends a DOMAIN_PAUSE attempt and recovery rebuild effects beside the original entity plan. The runtime retries the same complete typed schedule revision during that rebuild, and reports its wider node scope to the transaction. The actual aggregate rises accordingly. Node shutdown and restart use their own intake, drain, and former-owner fences, described in Shutdown And Recovery; the transaction report does not redefine them.

What inspection showsOperator interpretation
INCOMPLETE on an OPEN final model runThe candidate is stageable but does not yet supply a complete commit scope. Inspect its planning diagnostics, finish the same run, and inspect again.
Stale-preview refusalNothing engaged or applied. The transaction remains open; refresh the attached preview before another identified commit.
Planned ENTITY_PAUSE, actual DYNAMIC, REQUESTED, FAILEDGate engagement failed definitively before pausing work. The planned scope remains useful for diagnosing the attempted change.
Planned DOMAIN_PAUSE, actual DOMAIN_PAUSE, REQUESTED, CONFIRMED, FAILED, RELEASEDA domain drain failed after the pause engaged; the hold was then released.
UNCERTAIN remote engagementThe remote node may have gated admission. Include that scope in actual impact and inspect the retained result after leadership or connection recovery.
A failed later step after earlier APPLIED stepsThe earlier steps are the applied prefix. Inspect both their historical topology and the failing step; do not infer transaction-wide rollback.

Persistence, Retention, And Delivery

The control plane persists report identity, operation summaries, step plans, applying and terminal outcomes, and the topology needed to reconstruct before and after views. Header, operation, and step records are keyed; topology is content-addressed into bounded node and edge records shared across report revisions. A finished transaction retains its frozen report with its tombstone for the configured retention period, 15 minutes by default, including after subsequent graph changes. Retention cleanup removes the transaction’s report records and topology content no other retained report references. Once its tombstone is reclaimed, inspection by id returns unknown.

Transaction positions and operation numbers, commit-plan step counts, accepted and applied operation counts, report operation and execution-step counts, and topology node and edge counts use fixed-width 64-bit archives with checked native decoding. Durable record replay and snapshots retain every bit of those counts. Their representation is owned by Archived Counts, and their current storage namespace is defined in Consensus Storage And Replication.

These are control-plane facts. Arrow record batches, payload attempts, relay or connector buffers, handoff data, ACK guards, tokens, and maps remain volatile data-plane state. Inspection cannot recover or reconstruct any of those payloads. Bounded Raft log reading and snapshot transfer carry large stored reports without imposing a 2 MiB semantic cutoff; an inspection reconstructs the complete referenced report before returning one result. Text, JSON, typed API, and console responses do not paginate or silently truncate nodes, edges, operations, or steps. Encoding or transport failure yields an error rather than a successful partial report. Consumers should budget response memory and rendering time in proportion to the accepted operations and affected graph. The multi-mebibyte replay, restart, and response evidence is recorded in tests/transaction-quiesce-qualification-ledger.md.

Inspection Through NSPL And Clients

The public form is:

DESCRIBE TRANSACTION [ '<id>' ] [ OPERATION <positive-number> ] [ FORMAT TEXT | JSON ];

An omitted id reads the transaction attached to the caller’s session. An explicit id can read another transaction owned by the same authenticated user, including one in another domain, without attaching or taking it over. The owner check is the same one attach uses. Inspection never changes the inspected transaction’s activity time, queue position, or content. Explicit-id inspection also cannot change the caller’s binding or selected domain, or the commit basis for the caller’s attached transaction. The statement runs on its own beside an open transaction, before queue admission; it consumes no operation number or durable execution reference. Combining it with another statement in one request is refused before either runs.

OPERATION n selects an accepted number for presentation, leading text with that operation’s contribution and containing step. The typed result and JSON still contain the whole report. The server renders TEXT (the default) and JSON from the same typed inspection. That envelope holds the inspected transaction’s status, optional selected operation, and report; it is distinct from the command outcome’s transaction-status field, which continues to describe the caller’s own session binding. The session protocol carries the envelope as typed FlatBuffers fields, including in the command outcome; text and JSON are server-rendered projections, not a second semantic wire report. Subscription Row frames and ordinary live-graph snapshots have separate contracts. A refusal reports no attached transaction, unknown or expired id, wrong owner, unknown operation number, or unavailable report, including a transaction that ended before any operation was planned. A report is complete or the read fails; no refusal carries a partial graph.

The Rust client’s inspected attached report refreshes its cached whole-transaction preview only at the attached transaction’s current accepted position. An explicit inspection of another id, an older position, or a stale-commit refusal cannot replace that basis. Request correlation also prevents delayed or out-of-order responses from updating the wrong waiter. Reconnect and leader redirect recover the same admitted command by execution reference; a side-effect-free inspection can instead be read again. Rust Client Library, Command Line Client, and NSPL Overview own usage; Client Session Protocol owns client reconnection, correlated response handling, and the exact recovery of transaction requests.

The web console uses the typed envelope directly. Its outline selects the whole transaction, an effective execution step, or one operation’s contribution. The graph combines each step’s before and after topology at stable positions, then offers Before, Changes, and After and Planned or Actual views. Details show reasons, operation attribution, branch coverage, parallel relations, shared gates, ownership moves, rebuilds, and state resets. A domain-wide pause gets an explicit domain outline; a force flush outside the pictured subgraph remains visible in the domain summary. An incomplete or stale preview is marked, and a refresh obtains a new basis. The console reads a report again only when the inspector opens or changes its target, when the attached transaction’s position, state, or applied count changes, or when the operator refreshes; a delivered report never requests itself again. Historical retained topology is drawn from the report rather than a live graph snapshot. Selecting one operation still sends the whole-transaction preview identity on commit. See Web Console for the controls.

Observing A Transaction

For an open transaction, inspect its accepted position, planning basis, completeness, effective step scopes, and transaction summary before deciding to commit. For a committing transaction, compare the frozen planned effects with the applying step’s engagement history and application outcome. For a finished transaction, use its retained report to distinguish the applied prefix from a failed or uncertain later step. SHOW TRANSACTIONS gives a compact state and progress listing; DESCRIBE TRANSACTION gives the full historical explanation. The report’s diagnostics name planning, topology, quiescence, ownership, activation, application, or recovery failures without carrying sensitive payload values. Per-record details and hot-path buffers are outside this inspection contract. Ingestor quiesce buffer metrics and current live observations remain in Metrics And Observability; retained impact is the source for what this transaction required and actually engaged.

Consensus Storage And Replication

Nervix uses Raft to replicate the control-plane state described in the Control Plane. Each node keeps its own durable log, applied state, votes, membership, and snapshots. This chapter defines when those records are durable, how a leader paces replication, how storage and memory stay bounded during catch-up, and what recovery may observe.

Consensus traffic uses the authenticated pools and admission classes described in Cluster Interconnect. Consensus storage is node-local: a quorum makes a command committed, while every node independently persists and applies the committed log.

The replicated transaction report and its content-addressed topology use this durable path; Transaction Quiescence And Impact Inspection defines which planned and actual facts that report retains. This chapter owns log reading, snapshot transfer, and the storage bounds that carry large reports.

Consensus request and append-stream responses carry the fixed ConsensusRequestError wire outcome, classified at its source owner for the compiler failure-contract gate. Local storage and Raft failures retain their contextual reports before this projection. Opening the node database also preserves the typed storage cause below startup context; a consensus keyspace in that database returns the separate layout context. Errors And Diagnostics owns the compiler and propagation rules.

Durable Write Path

Every consensus mutation uses a full synchronization barrier that asks the filesystem to persist both data and filesystem metadata. The device and filesystem must honor that request for the host-power-loss guarantees in this chapter to hold.

The path from a proposal to its reply has four distinct boundaries:

client        leader log             follower logs          leader state machine
  |               |                       |                         |
  |-- propose --->|                       |                         |
  |               |-- SyncAll append ---->|-- SyncAll append       |
  |               |<----- quorum has the committed entry ----------|
  |               |------------------------------------------------>|
  |               |                      SyncAll applied range       |
  |<------------------------- applied response ---------------------|
BoundaryWhat it proves
AppendedThis node synchronized the complete log batch. It does not by itself mean that a quorum committed the entry.
CommittedA Raft quorum durably appended the entry. A node that has not applied it can replay it from the log or receive it again from the leader.
AppliedThis node atomically synchronized the semantic changes, final applied position, membership, transaction progress, and revision for the applied range.
Client acknowledgementThe leader durably applied the command and produced its semantic response. Nothing acknowledged at this boundary is lost by process or host failure on storage that honors synchronization.

Appended Batches

Entries already ready for storage are encoded into the fewest reservation-bounded atomic writes. One write performs one synchronization. A larger submission is split only when the next complete entry would exceed the write’s admitted memory; an entry is never split between writes.

The append call returns to Raft after the storage job has been admitted to the single ordered consensus worker. Its I/O-completion signal is delivered later, after the write and synchronization finish. Raft can keep its core responsive while storage works, but it cannot count the append as durable before that signal. A vote, truncation, purge, or later append queued behind it observes the same worker order.

A storage failure marks that node’s consensus store failed. Later writes return consensus storage stopped after a failed write; restart the node to recover. The error can arrive after the device completed the write, so a failed or disconnected request is an uncertain outcome, not proof that the command was absent. Recovery reads the durable state to decide.

Consensus startup, proposal, and membership operations carry contextual error reports locally. Database open failures retain their I/O cause; Raft configuration, startup, and handler registration failures retain the failure beneath the startup context. A rejected proposal keeps its Raft cause while classifying a known redirect as leadership loss, a fatal storage failure as storage, and other Raft write failures separately. A state-machine refusal remains a typed conflict, distinct from a storage or leadership failure. These classifications determine whether the control plane redirects, retries, or reports an uncertain failure; it does not parse the error message to decide.

Transaction mutation checks also create local reports. Raft’s applied response retains its existing serialized TransactionMutationError outcome, so report frames are local to the state machine evaluation. The proposer creates a new report from the exact typed outcome received over Raft and adds transaction context when the control plane takes ownership. The response encoding, client acknowledgement boundary, and recovery rules in this chapter are unchanged.

Applied Ranges And Client Replies

Committed entries are applied in order. Entries already available together share an atomic write until adding the next entry’s changes would exceed the reservation. That write contains the final semantic records and metadata for the whole range. After synchronization succeeds, the node publishes the resulting coherent revision and then answers every entry in the range.

One command may expand when its archive becomes independently keyed transaction report, commit-plan, and topology records. An applied command therefore reserves 16 MiB from the Commands class: up to 8 MiB for the normalized records and keys, with the other half retained for Fjall’s journal encoding and moving batch descriptors. The reservation is released after synchronization. This keeps a multi-mebibyte report atomic without making storage memory proportional to retained history.

A crash after a log write and before application does not lose an acknowledged command. If the entry was committed, recovery streams it from the log and applies it. If it was not committed, a later leader may discard it. In either case, the client had not yet received an applied response. A crash or lost connection after durable application but before delivery of the response can leave the client uncertain even though the command took effect; persistent administrative requests use their stable execution reference to join or retrieve that result.

Transaction application completion persists the actual quiescence history and actual effects in the same applied state-machine boundary that changes the step from applying to applied or failed. Recovery therefore sees ordered requested, confirmed, failed or uncertain, and released outcomes without inferring them from a client timeout. These records contain control-plane scope, attribution, and non-sensitive diagnostics only. Runtime payloads, acknowledgement maps, connector buffers, and handoff bytes remain volatile data-plane state.

Restore Checkpoint Reclamation

Restore checkpoint reclamation borrows the applied state through with_restore_state_reclamation and retains its read guard across node-local bounded storage deletion. Its caller takes the checkpoint installation barrier after this guard, in the same order as staging and publication. The retention view keeps the current generation of every applying restore and any generation ahead of the applied log; without an applied log it keeps all generations. Leadership and lease expiry alone do not end an applying execution’s retry lifetime. Terminal, expired, missing or superseded executions no longer retain an applied unpublished generation. The runtime store protects its selected durable publication independently. Within the selected initial or restored namespace, the store retains only chunk sets referenced by the same view’s segmented header and exact checkpoint revision; segmented or inline replacement, header purge and incomplete unpublished revisions permit bounded chunk deletion. Snapshot retention preserves preexisting readers, while the installation barrier serializes ordinary, replica and ownership writes with cleanup. This adds no consensus record or log mutation and never removes a replicated incomplete-installation gate. See Backup And Restore for the quota, metrics and node-local cleanup boundary.

Coordinated WASM Reset Publications

An NSPL reset first records a typed ordered transaction step with its captured planning inputs, statement identity, and reset scope. That record changes no processor Model or schedule by itself; the coordinator uses its retained execution reference for the Publishing and Ready mutations. If the leader stops after recording the step, transaction recovery resumes that same reference. An expired outer command reference is refused at admission rather than starting a new step.

A coordinated WASM state reset stores its command execution reference, exact branch scope, phase, and advanced guest-state generation in the domain schedule. The first applied schedule phase, Publishing, is the irreversible authority boundary: after that applied response, restart and leader failover continue the new generation even when its fresh initial checkpoint is not usable yet. Repeating the same request rejoins that publication rather than appending another generation.

The initial guest checkpoint is runtime state and follows its separate local and replica synchronization path. Live replicas install the published generation before the owner starts its initial checkpoint. Reset preparation never publishes the temporary absence of a selected branch through the branch-lifecycle checkpoint, and reinserting the branch advances that checkpoint’s revision before the new guest checkpoint is offered. Once the guest checkpoint is durable on the owner and every assigned replica, a second consensus mutation changes the same reset to Ready. Success is returned only after every live node applies that phase while the selected relay scope remains fenced. A follower or offline replica that skips the intermediate runtime activation still catches up through the ordered schedule records and cannot accept a checkpoint from the replaced generation. Thus a pre-publication failure leaves the old schedule authoritative, while a post-publication failure is recovered as committed but not yet usable rather than rolled back or acknowledged early. See Coordinated Reset.

Replication Pacing

The leader keeps one ordered append stream to each follower. That stream uses the replication pool; heartbeats and leadership probes use a separate management connection so a slow or flow-controlled log transfer cannot consume the path that maintains the leader lease.

Before opening an append stream, the network adapter consumes the caller’s first request. OpenRaft applies its retry delay and cancellation while producing that input; even an immediate local admission rejection therefore passes through the caller’s gate. An ended input opens no transport. Leading empty requests, including finite catch-up probes, use the management heartbeat operation and its deadline. Each answer confirms that request’s previous-log boundary; a conflict, higher vote or incomplete acceptance ends that input before any data stream opens. The first request that carries entries remains charged while stream setup runs and joins the remaining requests exactly once after setup succeeds. Subsequent empty commit updates stay on that ordered stream alongside data batches. Setup retains its five-second deadline; waiting for the caller’s next request belongs to the caller’s lifecycle, rather than to connection setup.

One replication batch targets 1 MiB and stops at 1,024 entries. Commands remain whole, so the first command may take a batch past the target; the encoded replication operation is bounded at 2 MiB. Submissions and answers stay in log order. A conflict, higher vote, partial acceptance, stream failure, or liveness failure ends that stream generation after its first result. Raft resumes from the last progress the follower confirmed.

The leader permits at most 16 unacknowledged batches for one follower. It also waits whenever its unacknowledged bytes plus the next 1 MiB target would exceed 16 MiB. Because one complete command may cross the target, measured outstanding bytes may exceed that target budget by the excess of one encoded batch; the 2 MiB operation limit remains the hard bound on that batch. The node’s Commands memory budget separately bounds the aggregate encoded and decoded copies across all peers.

Progress-Based Liveness

Opening an append stream has a five-second deadline. Once open, the stream has no age limit. With a batch outstanding, it is considered stalled only after five continuous seconds in which both of these are true:

  • no answer arrived;
  • the follower accepted none of the leader’s bytes.

An answer or transport flow-control progress restarts the idle window. A stream with no outstanding batch can remain idle indefinitely. The time required for a follower to synchronize one batch is therefore not treated as a failure while the transfer is still moving. The interconnect frame writer uses the same progress contract: every nonzero flow-control grant restarts its write idle bound, including grants within one large append frame. Catch-up at a limited byte rate can therefore preserve its append stream across many five-second intervals. The native writer checks both a moving and a stalled receiver. The public rate-limited follower scenario sends opaque TLS bytes through a bounded test proxy at 32768 bytes per second and checks the applied domain backlog within its assertion bound and the bytes carried by the paced path. Append stream openings observe actual data-stream setup; initial empty probes do not open those streams. Native transport regressions cover finite probes, the transition to data, ordered empty commit updates and rejected probes. The public catch-up scenarios retain their stream-count and time assertions and change no product deadline.

Heartbeats keep the shorter deadline derived from the configured heartbeat interval, capped at five seconds. With the default 250 ms heartbeat, the current soft deadline is 187.5 ms. Replication answers also prove leader contact, so sustained writes suppress redundant heartbeats for an automatically derived window. The window is no longer than the heartbeat interval or half the gap between that interval and the minimum election timeout. This keeps the heartbeat interval plus the suppression window below the minimum election timeout.

The independent paths matter when a follower has slow storage: its append answer may take much longer than a heartbeat deadline, while management traffic still prevents an election. Changing the heartbeat interval does not change the append stream’s five-second progress bound.

When only a bare quorum of voters is reachable, such as two of three while the third is down, every commit needs all of them and the leader’s link to each. When such a link loses packets but stays up, TCP recovers them by retransmission under the host kernel’s retransmission timeout, which grows exponentially while the losses continue; Nervix does not tune it. An exchange whose deadline passes before its retransmission arrives fails and is retried: a heartbeat at its soft deadline, and an append stream at its five-second setup or progress bound. Commits can therefore stall, leadership can move between the reachable voters, and a control command, which needs several commits in sequence, can time out.

Nervix sets no bound on such a stall. It lasts until the loss ends or another voter becomes reachable, and it loses no committed entry.

Follower Memory And Backpressure

A follower takes a Commands-budget reservation before it decodes an append batch. It retains that reservation until Raft has durably appended the batch and produced its answer. The default resident window is four decoded batches per live stream. Reaching the window stops frame reads before another batch is decoded, closes the HTTP/2 flow-control window, and makes the leader wait.

The default 24 MiB Commands budget is validated to hold the complete resident window beside one maximum-size 2 MiB batch being encoded. Other command and replication work shares that aggregate budget, so concurrent or superseded streams cannot grow memory without admission. Ending a stream releases its queued batches and reservations; batches from a generation the leader abandoned do not remain uncharged in the Raft core.

Follower Catch-Up And Learner Promotion

A follower whose required log suffix is still retained receives bounded append batches over its stream. If the leader has already purged part of that suffix, the follower receives the current snapshot and then the remaining log. Client proposals and management heartbeats continue while the catch-up runs.

A discovered node joins as a learner. Each membership reconciliation gives a blocking learner catch-up attempt ten seconds. Promotion to voter is proposed only after that attempt reports the learner caught up; the membership change has its own ten-second bound. A timeout leaves the node a learner, records raft membership reconciliation failed, and the one-second reconciliation loop tries again while ordinary replication continues.

When discovery reports a different interconnect endpoint for an existing member, reconciliation replaces that member’s recorded Raft address. A returning voter keeps its voter role; it is not re-added as a learner. Once the committed membership contains the advertised address, subsequent reconciliation passes propose no further address change. The stored address supplies recovery contact hints; Raft traffic resolves the authenticated node identity through the interconnect.

The consensus event stream and info log record raft add learner, raft wait for learner, raft update address, raft promote voters, and raft membership updated transitions. SHOW CLUSTER STATUS reports the local raft.last_log_index, raft.last_applied, and each member’s learner or voter role and recorded address.

Bounded Log Reading

Committed-range application, startup replay, and leader-bounded replication streams read the log in successive admitted chunks. A chunk targets 1 MiB or 1,024 entries, whichever is reached first, and one oversized first entry travels alone. The reader releases the ordered storage worker between chunks and retains the chunk’s memory charge until its entries have been handed to the consumer.

There is no range-size failure at 2 MiB or at any other aggregate backlog size. A committed range and a startup backlog may span any number of chunks. The state-machine worker applies the entries already read before requesting another chunk, so a large backlog does not have to exist as one allocation. A replication read also revalidates the leader’s durable vote at every chunk boundary; it stops rather than reading through a truncation performed under another leader.

Log Retention And Admission

retained_bytes is the sum of the encoded values for the log entries the node currently holds. It includes entries still in Fjall’s memtable and excludes purged entries, tombstones, stale segment space, snapshots, and other keyspaces. Append adds encoded entry bytes; purge and truncation subtract the removed entries in the same ordered storage operation. Opening the database rebuilds the counter by scanning the current log, so a process failure between a durable mutation and its in-memory counter update does not skew the recovered value.

A node starts one snapshot after either of these thresholds is crossed:

  • 10,000 committed entries since the completed snapshot;
  • 64 MiB of appended entry bytes since the completed snapshot.

Only a completed snapshot permits covered log entries to be purged. By default the node retains the newest 1,000 covered entries or 64 MiB of covered suffix, whichever bound is reached first. Entries newer than the snapshot remain regardless of those settings. A failed or unfinished build therefore does not remove the log needed to recover.

When retained_bytes is already above the 1 GiB admission cap, a new control-plane mutation waits up to 30 seconds for snapshotting and purge to reclaim space. If it remains above the cap, the write fails with:

the retained raft log holds <retained> bytes against a <cap>-byte cap and did not reclaim within 30s

The check occurs before admitting the next mutation, so the mutation that crosses the cap may complete. Reads, health checks, replication, snapshotting, and recovery remain available while new mutations wait.

Snapshot Lifecycle

Sealing And Publication

A snapshot generation is a sequence of sections containing keyed state-machine records plus a manifest that names the generation, applied log position, membership, section count, and total bytes. The builder opens one consistent database view and seals it one section at a time. The default section limit is 8 MiB. Each section acquires the Bulk budget for one consensus-storage turn, is encoded and synchronized, and releases that reservation before the next section starts. Concurrent state-machine writes can proceed between sections without changing the pinned view. The section builder charges each key and value together with its archived record envelope, root, and alignment before accepting the record. The encoded section therefore stays within 8 MiB even when it contains many small keyed report records; the complete generation grows by adding sections.

The manifest is synchronized only after every section is durable. Publishing that one record makes the generation active atomically with its applied position and membership. A build interrupted before manifest publication leaves the previous generation active.

Generations And Pins

The newest published generation is always retained. An outgoing transfer pins the generation it is reading. Multiple readers can share one pin, and at most one older generation may remain pinned beside the active generation. If publication would leave a second older generation pinned, the oldest transfer is made obsolete; its next read fails and Raft restarts it from the newest snapshot.

A superseded generation with no reader becomes unreferenced. Its sections are removed as part of a later durable consensus batch. Startup discovers sections that no published manifest names and marks those generations for the same deletion path. This keeps deletion ordered and durable without treating an unfinished build or abandoned transfer as active state.

Transfer And Installation

One complete snapshot transfer has a 30-second deadline. The sender reads one section at a time and sends it in 64 KiB chunks through the Bulk pool. The receiver holds at most one section in memory, validates its declared size and offsets, and synchronizes each completed section into a newly claimed generation. A restarted transfer abandons the earlier staged generation.

The receiver stages at most one transfer from each sending node, and a new transfer from that node supersedes it. Every transfer carries an identity its sender allocates, and the receiver refuses a chunk or a finish whose identity is not that of the transfer it is staging, so a late chunk of a superseded transfer never lands in its successor. Because the receiver only compares identities from one sender, an identity is unique among the transfers one node sends: every Raft connection of the node draws from the node’s own sequence, which starts again when the node starts.

After every declared section arrives, Raft revalidates the vote and whether the snapshot still applies. Installation then synchronizes the new manifest together with an installation marker. That write is the recovery boundary: before it, the old snapshot and state machine remain authoritative; after it, startup must finish installing the marked generation.

The installer clears the prior state-machine records, writes each staged section in order, and finally removes the marker while publishing the new generation. Every step is idempotent. A process that stops after the marker was published redoes the complete replacement on its next start, regardless of how many clear or section writes had finished. It exposes no recovered state until the replacement and current-shape validation succeed.

Storage Layout And Compatibility

PathContents and ownership
<db-path>Registry records and runtime-state checkpoints in the node database.
<db-path>/consensusThe dedicated consensus database and journal: log entries, votes and log metadata, applied state-machine records, snapshot manifests, installation markers, and snapshot sections.

The databases have independent journals, memtables, rotation, and synchronization. A consensus SyncAll neither flushes runtime journal bytes nor waits behind a runtime journal write. Both use the bounded storage executor, but consensus has its own single ordered worker.

The dedicated database owns exactly four current keyspaces: raft_count_logs, raft_count_meta, raft_count_state, and raft_count_snapshots. Opening it validates the complete keyspace namespace before reading any records. Its state metadata requires the current RestoredLifecycle encoding, including the durable restore installation gate. Native counts in commands, queued transactions, progress, outcomes, and plan, report and topology headers are archived as fixed-width 64-bit values with checked native decoding. Log replay, state recovery and snapshot installation therefore retain their complete magnitudes. See Archived Counts.

This layout has one current shape. A consensus database containing an unknown keyspace, a malformed current archive, or incomplete or inconsistent current state fails with invalid consensus record storage; recreate the node's stored state. Recreate the node’s stored state and let it rejoin from the cluster.

Archive shape and nesting are validated before a durable batch or state record is read back. Vocabulary values inside a valid shape are checked as each value is read. If one is refused after earlier list or fixed-array elements or a boxed or shared value have been read, the archive reader drops those values and releases their allocation. The whole record fails; no partial record is installed or retained during replay, catch-up, or snapshot installation.

Recovery accepts exactly the records the state writer stores. Each state record sits under the canonical encoding of its key, so a key spelled another way, or one with bytes after its encoding, is refused instead of being read as a neighbouring record. A record whose value names its own identity must name the key it is stored under: a domain, a schedule’s domain and each of its nodes, a user, a resource version, node status or upload, a transaction, its commit plan header or impact report header, and a command execution. The state keyspace holds those records and the metadata and nothing else, so a record no family owns is refused as well. Recovered resource records hold at most one upload for each resource version, and an expired command execution’s reference carries the issue time its expiry is ordered by. A transaction is in a state its transitions reach: an open or committing transaction queues exactly the statements and source bytes it accepted, a finished one queues none, a commit’s results apply consecutive statements from the first and its progress stays inside the queue, and a failure names a statement the transaction accepted. Every log read checks that a log record is stored under its own entry’s index.

Shutdown And Forced Endings

Consensus remains available throughout stop admission and graph drain. During terminal teardown, the node stops Raft, which ends its append stream generations, then submits an idle barrier to the ordered storage worker. Reaching that barrier proves every earlier submitted durable write returned and released the consensus store before its database handle is dropped. Dropping it closes the dedicated database, which finishes even when one of its background workers failed during the close; see Shutdown And Recovery. The interconnect is stopped after consensus, so peers first observe the append protocol ending and later the transport closing.

A repeated signal, an expired shutdown deadline, or SIGKILL skips that barrier. Recovery then sees the last complete atomic storage boundaries: an append batch is present or absent; an applied range contains its records and final metadata together or not at all; and a marked snapshot installation is redone in full. See Shutdown And Recovery for the exact SIGTERM, SIGKILL, stream-teardown, and restart behavior.

The recovered applied state includes the last stored membership and its peer endpoints. Startup discovery reads that membership before Raft has published its live topology metrics, so a former bootstrap leader can use surviving peers as authenticated gossip seeds. The stored endpoints are contact hints; current peer identity, incarnation, and endpoint still come from discovery. See Cluster Interconnect.

Observability

SignalWhat catch-up or retention looks like
SHOW CLUSTER STATUSOn each node, raft.last_log_index advances as entries arrive and raft.last_applied follows durable application. Membership lines show learner or voter state.
nervix_consensus_log_last_indexThe highest entry held locally. Compare nodes to see a log follower converge.
nervix_consensus_log_snapshot_index and nervix_consensus_log_purged_indexThe snapshot coverage and removed prefix. A follower behind the purged index must use a snapshot.
nervix_consensus_log_retained_bytesThe exact encoded bytes in current log entries and the value used by admission.
nervix_execution_memory_reserved_bytes{class="commands"}Includes decoded follower batches held until durable answers and replication payload admission. It stays bounded by the Commands capacity.
nervix_interconnect_pending_operations{operation="append"}Live append-stream operations on the exporting node.
nervix_interconnect_stream_resets_total{class="replication"}Abnormal replication stream endings, separated by deadline, capacity, peer, shutdown, or malformed reason.
nervix_interconnect_requests_total{operation="snapshot"} and nervix_interconnect_request_seconds_total{operation="snapshot"}On the sending node, the snapshot transfer requests a lagging follower answered or failed: the transfer’s start, its 64 KiB chunks, and its finish. A transfer moves as these bounded requests in the Bulk pool rather than as a streamed body, so nervix_interconnect_bulk_bytes_total does not count it.
nervix_consensus_snapshot_pinned_generations, nervix_consensus_snapshot_pinned_readers, nervix_consensus_snapshot_unreferenced_generationsGenerations retained for outgoing transfers and generations awaiting durable deletion.

Normal membership changes appear at info. Append generation endings and invalid or foreign batches appear at debug; a stalled generation reports that the stream neither answered nor accepted bytes for five seconds. Snapshot requests caused by the byte threshold appear at info, and byte-retention purge requests appear at debug.

Tuning

The server exposes heartbeat, election, snapshot, and retention policy as command-line arguments and matching environment variables:

ArgumentEnvironmentDefaultEffect
--raft-heartbeat-intervalNERVIX_RAFT_HEARTBEAT_INTERVAL250msPeriod for leader liveness probes and basis for their soft deadline. It does not set the append-stream idle bound.
--raft-election-timeout-minNERVIX_RAFT_ELECTION_TIMEOUT_MIN1500msLower end of the randomized follower election timeout. Must exceed the heartbeat interval plus the derived suppression window.
--raft-election-timeout-maxNERVIX_RAFT_ELECTION_TIMEOUT_MAX3000msExclusive upper end of the randomized election timeout. Must exceed the minimum.
--raft-snapshot-entry-thresholdNERVIX_RAFT_SNAPSHOT_ENTRY_THRESHOLD10000Applied entries since the last completed snapshot that request the next build.
--raft-snapshot-byte-thresholdNERVIX_RAFT_SNAPSHOT_BYTE_THRESHOLD64MiBAppended entry bytes since the last completed snapshot that request the next build.
--raft-covered-log-entries-retainedNERVIX_RAFT_COVERED_LOG_ENTRIES_RETAINED1000Newest snapshot-covered entries retained for log catch-up.
--raft-covered-log-bytes-retainedNERVIX_RAFT_COVERED_LOG_BYTES_RETAINED64MiBNewest snapshot-covered encoded bytes retained; the entry or byte bound reached first decides.
--raft-retained-log-capNERVIX_RAFT_RETAINED_LOG_CAP1GiBCurrent-log byte level above which later mutations wait up to 30 seconds for reclamation.

Replication batch and memory limits are fixed by the current server execution policy; the server does not expose flags for them:

LimitCurrent value
Replication batch target1 MiB or 1,024 entries; whole commands are never split
Encoded replication operation2 MiB
Leader window per follower16 batches and a 16 MiB target byte budget
Follower decoded resident window4 batches per live append stream
Commands memory budget24 MiB per node
Applied command storage8 MiB normalized payload in a 16 MiB working reservation
Append stream setup / progress idle bound5 seconds / 5 seconds
Snapshot section / transport chunk8 MiB / 64 KiB
Complete snapshot transfer30 seconds

Use the timing settings as one policy. Lowering the election minimum without lowering the heartbeat interval can make the configuration invalid at startup. Raising the heartbeat interval does not allow slower append storage because append liveness is progress-based and fixed at five seconds.

Snapshot and covered-log settings trade storage and catch-up method. Smaller thresholds build and transfer snapshots more often; smaller covered suffixes make a lagging follower switch to a snapshot sooner. Larger thresholds and suffixes retain more log and require a retained-log cap large enough for the working set. The cap should leave room for entries newer than the latest completed snapshot, because those entries cannot be purged regardless of the covered-log settings.

Restored Domain Installation Gate

The consensus state machine persists each restored domain’s pending or installing state alongside its restore execution and mutation lease. Creating the domain establishes a pending start gate; admitting state installation records leader tenure, execution, lease revision and the committed log index as its generation. Recording the model step removes the gate only for that exact authority. The record is included in snapshots and restored after restart, independently of lease release or terminal execution failure. Runtime state bytes remain in the node-owned state store. A local storage mutation holds the applied-state read guard through its authority validation, checkpoint mutation and handle clearing; a newer applied authority or start-gate release cannot cross that boundary. The current state encoding requires these records and rejects a database whose encoding does not match, requiring recreation under the alpha persistence contract. The current encoding also requires a restore’s explicit lifecycle policy and its installed-state effect’s clock. RESUME changes the domain to running and removes its complete-set installation gate in that single applied effect; it preserves the archived generation and mapping.

Command Completion

Nervix treats a successful NSPL administrative response as a completion boundary. When a command returns OK, its declared effect is durable, applied, and usable through every current live node that participates in that effect. A caller can issue the dependent command immediately through another session or node. Runtime data flow, subscription delivery, connector acknowledgements, and timer-driven work continue asynchronously under their own contracts.

A command can therefore remain pending while Nervix validates configuration, replicates control state, distributes resources, prepares runtimes, starts sources, drains old ownership, releases entity gates, and makes the final outcome visible. Transport loss only detaches the waiter after admission. The cluster continues the work and retains its terminal result for the command retry validity, 15 minutes by default.

Lifecycle and ownership

Every persistent ordinary command carries an execution reference. The reference is a UUIDv7, and its embedded creation time bounds how long the request may be retried. The reference is scoped to its authenticated owner and selected domain and is bound to one semantic request while its record remains present. Reusing it with changed content, credentials, owner, or domain fails. Repeating the same request joins the applying execution or returns the retained terminal result.

A new reference is admitted only when its creation time lies after the cluster’s retry fence and no more than five minutes ahead of the leader’s clock. The fence trails the leader’s clock by the retry validity. It is part of the replicated state and never moves back, so restart, leader change, and snapshot installation all preserve it. A reference without a UUIDv7 creation time, one created at or before the fence, and one created too far ahead are refused before any effect.

An applying execution never expires. A terminal result is retained for the retry validity after the command finishes. The record then shrinks to a tombstone holding only the reference, and the tombstone is removed once the fence passes the reference’s creation time. From then on the fence refuses the reference by itself, so a reclaimed reference returns the typed ExecutionReferenceExpired disposition and never starts its effect again. A conflicting reference found during replicated admission returns ExecutionReferenceConflict with the kind that differed. Report retention is a separate contract: a transaction’s report follows the transaction tombstone retention, so inspection can still read it after its command reference has expired, and a report that inspection no longer knows does not make its reference executable.

Finalization accepts a terminal command result. An OutcomeUnknown, leader redirect, or detached transaction leaves the execution applying and preserves the disposition and diagnostics for its current waiter. Reconciliation and repetition under the same reference resume that execution; an interrupted attempt never supplies a definitive failure for the execution ledger.

The retained history is bounded. Once it holds as many applying, finished, and expired executions as its capacity allows, a new reference is refused with an explicit capacity error. Admitted work is never evicted to make room, and repeating an admitted request still joins it or returns its retained result.

SettingEnvironment variableDefault
--command-retry-validityNERVIX_COMMAND_RETRY_VALIDITY15m
--command-execution-capacityNERVIX_COMMAND_EXECUTION_CAPACITY65536

An ordinary configuration command applies through a frozen internal transaction attempt. If its captured planning inputs change before the effect is recorded, the command retains that failed attempt and starts a revision-derived attempt under the same execution reference. An explicit transaction remains frozen after COMMIT admission and reports the conflict instead of replanning.

Resource uploads follow the same replay rule with an upload identity instead. It is scoped to the user, domain, and resource, bound to the verified archive digest, and retained without expiry; see Resource Versions And Bindings. Transaction appends additionally carry their expected queue position. This makes a replay an exact append check rather than a request to append another copy.

stateDiagram-v2
    [*] --> Admitted: durable identity and semantic effect
    Admitted --> Applying: execution owner starts or recovers
    Applying --> Applying: durable effect progress / retryable attempt
    Applying --> Completed: all required application and visibility complete
    Applying --> Failed: definitive failure recorded
    Completed --> Expired: retry validity elapsed
    Failed --> Expired: retry validity elapsed
    Expired --> Expired: replay reports expired
    Expired --> Reclaimed: retry fence passes the creation time
    Reclaimed --> Reclaimed: retry fence refuses the replay

The control plane owns command identity, durable effect progress, terminal outcomes, and recovery. The registry and scheduling decisions validate and produce authoritative state. Each runtime owner reports preparation and readiness for the exact revision it applied. Gossip carries acknowledgements with the process incarnation, so a restarted process cannot inherit its predecessor’s readiness. Sessions and clients route requests and wait; they do not decide completion.

flowchart LR
    Client[CLI / Rust client / web console] --> Session[Session service]
    Session --> Identity[Replicated execution identity]
    Identity --> Decision[Registry and scheduling decisions]
    Decision --> Raft[Durable authoritative state]
    Raft --> Runtime[Per-node runtime application]
    Raft --> Resources[Per-node resource installation]
    Runtime --> Gossip[Revision + incarnation acknowledgements]
    Resources --> Replicas[Replicated per-incarnation replica records]
    Gossip --> Barrier[Command-scoped completion barrier]
    Replicas --> Barrier
    Barrier --> Outcome[Replicated terminal outcome]
    Outcome --> Client

Commands in different domains and independent resource uploads have separate execution ownership. Conflicting work in one domain uses that domain’s alteration or handoff ownership. Other sessions, health, subscription delivery, and transport control frames continue while a command waits, and so do the waiting session’s completion, choice, domain, and inspection requests. That session’s later commands wait for it, because a session runs its commands in order; see Requests, Lanes, And Cancellation.

REBIND RESOURCE completes only after its entire selected model set has been validated, committed, and activated under this same barrier. The successful response is therefore the boundary at which every selected usage observes the new pinned version. A validation or activation failure cannot report a partially rebound set. Resource Versions And Bindings defines the rebinding contract.

BACKUP uses the same durable execution reference and retained outcome. Its success means the archive was assembled from each domain’s own applied revision, verified, and retained on the leader under that reference, and its outcome carries the archive’s size, digest, and per-domain revisions. The execution reference is also the key the client downloads the archive by. Its request binding includes the original selected domain, scope, resource inclusion and capture options. The client’s local archive destination is excluded from this binding, so recovery may choose another file or stdout. A bounded client wait can end while the command remains applying; the original reference recovers that work without admitting a second backup. Repeating the reference returns the recorded outcome, and the client downloads the archive again while it is retained, which lasts until a download collects it or the reference’s retry validity ends. See Backup And Restore.

RESTORE uses the same durable execution reference and retained outcome, and its request identity includes the size and BLAKE3 digest of the archive the client streams with it, so the same reference with another archive is a different request. The leader admits a restore only after the archive verified and the whole restore planned, and records every step the restore applies in the restore’s execution as the step’s effect commits. Its success means every step applied: the users, each domain created stopped, its resource versions completed under their archived numbers on every live node, its models applied, and its complete compatible state set durably published on every target node. A failure names the step, and the steps before it stay applied. An unfinished domain retains a replicated start gate even after failure releases the execution’s mutation lease. Retrying the reference while the restore applies on the leader returns OutcomeUnknown with the StillApplying cause at once, and after it finished returns the recorded outcome with the typed restore report. Only the leader the archive was streamed to holds it, so a new leader resumes an applying restore from its first step not recorded when a retry streams the archive again, and ends it as failed once the reference’s retry validity passes without one. See Backup And Restore.

RESET WASM PROCESSOR ... STATE uses the same durable execution reference and retained outcome. The ordered transaction records the reset as an effect even though it changes no Model. Success waits for the selected guest-state generation to be durable and its replacement execution usable. Retrying an admitted reference resumes or reads the original outcome; an expired reference cannot start another destructive reset. See Coordinated Reset.

All-live-node barriers

A completion barrier continuously derives the required set from the current leader’s effective application-health view. Followers request that view through an authenticated management operation and accept it only while their Raft leader and term still match the response. If the leader or its view cannot be confirmed, they keep waiting. Every node also requires its own current incarnation to finish locally. The set keys each member by node name and process incarnation. A node joining while the effect is still applying joins the required set. A process restarting under the same name must apply the effect in its new incarnation. A node leaves the set only when the leader’s availability policy retires it; a stale observation or one failed probe does not waive its work. This shared view lets a connected follower finish control commands even when its own health probes still see another follower that the leader has already retired. The retired process catches up on reconnection before it can participate again.

Runtime activation has two explicit phases. First, every required node applies the authoritative models, schedules, clocks, resource bindings, and stopped or running lifecycle state and reports prepared for that revision. Only after the preparation barrier may running-domain sources and listeners start. Each node then reports ready, and success waits for the readiness barrier. This prevents a source from publishing into a peer that still has the preceding graph.

Revision progress is cumulative. If a newer runtime revision arrives while a node is waiting at either barrier, that node applies the newer coherent state instead of waiting to finish the older revision first. Preparing or becoming ready at the newer revision also completes every earlier revision for that process incarnation. This lets a newly admitted process catch up to the current state without forming a cycle with peers that entered adjacent revision barriers before it joined.

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant R as Raft state
    participant A as Node A incarnation
    participant B as Node B incarnation
    C->>L: command + execution reference
    L->>R: admit semantic effect
    R-->>L: committed revision
    par prepare exact revision
        L->>A: apply authoritative state
        L->>B: apply authoritative state
    end
    A-->>L: prepared(revision, incarnation)
    B-->>L: prepared(revision, incarnation)
    par activate sources/listeners
        L->>A: activate
        L->>B: activate
    end
    A-->>L: ready(revision, incarnation)
    B-->>L: ready(revision, incarnation)
    L->>R: retain terminal outcome
    R-->>L: outcome visible on live set
    L-->>C: OK + same execution reference

The barrier targets the command’s authoritative revision and required effect. Later changes in an unrelated domain do not move that target. Local observations never repair state as a side effect; recovery and application owners perform repair before declaring readiness.

Transactions

Transaction Quiescence And Impact Inspection defines the frozen step plan, required and actual pause scopes, applying progress, and historical report. This chapter defines when each authoritative effect and its runtime application finish.

BEGIN creates an OPEN transaction for one existing selected domain. A queueable statement is validated by planning the ordered candidate formed by the existing prefix, then durably appended with its request reference, expected position, admitted result, stable operation metadata, and identified preview revision. The plan uses one captured set of relevant control-plane inputs and the exact execution-step segmentation used at commit. Queue success means validation and staging only. It does not replace a live graph, start or stop a domain, install a resource, or engage a runtime gate. Other sessions continue to see the committed configuration. Retrying the exact append returns the retained admitted result and preview without touching the transaction.

COMMIT first computes a side-effect-free complete preview from replicated content and a new coherent snapshot. Its admission validates the identified basis and atomically freezes the report, ordered plan, and captured inputs before changing the transaction to COMMITTING. A commit may name the identity it expects to apply, which a client obtains from an accepted append or from inspecting the transaction. A stale basis leaves the transaction OPEN and returns a typed refusal naming both the expected and the current identity, so the caller can decide again against the transaction as it is. That refusal is part of the recorded outcome, so recovering the same request by its reference still names both identities rather than only reporting that the commit failed. Reading a transaction through inspection is itself side-effect-free: it changes no binding, domain, activity time, or queue position. DESCRIBE TRANSACTION is such a read, so it records no execution: repeating the request under the same reference reads the transaction again rather than returning a retained result, and it completes as soon as the report is read. Consecutive model mutations are one frozen step; lifecycle, domain, and resource statements each end a model run and form their own step. Durable effect progress changes the retained step from unattempted to applying, while completed application records applied or failed separately. A step whose authoritative write is committed remains applying until activation, handoff, drain, source readiness, lifecycle work, and command-owned gate release are complete. Only then can the next step advance. The transaction becomes COMMITTED after the final application record and terminal visibility barrier.

For a transaction containing a WASM state reset, the frozen preview describes the planned reset effect, while the retained actual step separately records quiescence engagement and whether application completed. DESCRIBE TRANSACTION renders those same typed facts in text or JSON; an uncertain engagement remains uncertain on inspection. DESCRIBE WASM PROCESSOR is a separate read-only observation of the current scheduled lifetime and owner checkpoint progress. Its client outcome carries the typed state inspection beside the text message, and reading it does not advance checkpoint durability or change a retained command outcome. FORMAT JSON renders that same typed state inspection as JSON while retaining it in the client outcome.

sequenceDiagram
    participant C as Client
    participant S as Session
    participant R as Raft transaction
    participant E as Effect owner
    C->>S: statement + reference + expected position
    S->>S: validate against staged prefix
    S->>R: append semantic statement
    R-->>C: staged OK
    C->>S: COMMIT + execution reference
    S->>R: OPEN -> COMMITTING
    loop each ordered step
        S->>E: prepare and apply
        E->>R: durable effect progress
        E->>E: activate, drain, release gates
        E->>R: application complete
    end
    R->>R: record COMMITTED outcome
    R-->>C: terminal commit result

A leadership change resumes a COMMITTING transaction from its recorded applying step and frozen plan. It does not replan, repeat a completed effect, or advance past an incompletely applied effect. A reconnecting commit waiter attaches to the transaction and continues waiting for that terminal result. A definitive failure records one FAILED outcome, the failing step, and the committed prefix. The final operation and topology report remains with the transaction tombstone until the configured retention boundary.

Effect-specific completion

Domain, user, resource-catalog, model, and lookup creation waits for authoritative visibility. Stopped domains and empty graphs still apply their definitions on every live node. Hash-map lookup creation also waits for local resource loading and index construction, so its first LOOKUP may be issued immediately after success. A malformed source resource fails the creating command.

Model creation, alteration, and removal waits for the classified quiescence and activation work. Dynamic changes preserve live execution, entity changes drain only affected entities, and topology changes pause and drain the domain. Success follows schedule application, ownership handoff, runtime readiness, and gate release. A no-op reapplies the current required state before returning.

START waits until every listener and assigned source has completed its startup boundary. STOP waits for remote source and listener teardown, including when it stops the cluster’s last running domain. Paced-domain clock authority is part of the same revision. TLS-changing model effects wait for every affected listener to install the configuration. Each node installs the TLS VHOSTs of a runtime revision before it reports that revision prepared, and the command then confirms the installation on every live process incarnation. A failed installation fails the command. A batch that did not pause is rolled back by the same record that stores the failure, and the command waits until every listener presents the restored certificates; a paused batch keeps its committed models like any other activation failure. See The DYNAMIC TLS Refresh for what the listener presents and when.

An upload is complete after the entire declared body is admitted, its archive and manifest verify, and the exact digest is atomically installed on every live node incarnation. A complete admitted upload continues after caller disconnect. An interrupted partial body has not admitted an effect and may be retransmitted with its identity. The final result reports one assigned version and the same upload identity. Resource descriptions expose current per-incarnation installation diagnostics for observation, rather than serving as an extra completion step. Resource Versions And Bindings defines how the live set is derived, how nodes that join while an upload applies are included, and what a completed version may be bound to.

Cordon and uncordon wait for the eligibility change to become authoritative. Drain and relocation wait for ownership transfer, destination activation, source drain, and handoff release. Node removal waits for membership and all resulting schedules to become authoritative and usable.

Disconnects, deadlines, and recovery

The gRPC and WebSocket transport loops keep control frames, server events, subscription delivery, and close detection moving while an ordered command worker waits. Disconnecting either transport ends the session’s waiters. An unclean end releases the session’s transaction binding, and a clean close with no request in flight reverts the open transaction bound to it. Service-owned command, commit, and fully admitted upload tasks keep running. Client Session Protocol defines how a client recovers each of them by its identity.

sequenceDiagram
    participant C1 as Original connection
    participant S1 as Leader before change
    participant R as Replicated progress
    participant S2 as Current leader
    participant C2 as Reconnected client
    C1->>S1: command + stable reference
    S1->>R: admit and begin applying
    C1-xS1: disconnect / deadline
    S1-xR: leadership lost
    R-->>S2: applying effect and progress
    S2->>S2: resume remaining application
    C2->>S2: same command + same reference
    S2->>R: join existing execution
    S2->>R: record terminal outcome
    R-->>C2: exact retained result

A caller deadline limits that caller’s wait. It does not cancel admitted work or create a terminal failure. Clients preserve the same reference through redirects, reconnects, and uncertain transport outcomes. An expired reference produces an explicit result and never causes automatic re-execution under a new identity.

The public behavior is specified in NSPL command completion. The corresponding public scenario inventory is maintained in the repository’s command-completion acceptance ledger.

Resource Versions And Bindings

A resource version is an immutable directory tree that the cluster verifies and installs on every live node before anything can use it. A binding is the part of a model that selects one of those versions: a TLS VHOST, a protobuf codec or signaling protocol, an inferencer, a WASM processor, a hash map, or a client mount. This chapter defines the contract that joins them: who owns each piece of state, how a version becomes usable, what each consumer loads and when, how VERSION LATEST resolves, how REBIND RESOURCE moves bindings, and what a failure leaves behind.

The central rule is that a version change is a model change. Every stored model names the concrete completed version it binds, and its consumers load exactly that version. An upload only adds a version to the catalog. A running consumer moves to another version only when a validated, classified, and atomic model mutation rebinds it.

This chapter describes the architecture. Resources owns the NSPL statements, the upload format, the per-version limits, and examples.

Ownership By Layer

LayerOwnerWhat it owns
VocabularyResource identitiesA version is identified by its domain, resource name, and number. A statement writes VERSION <n> or VERSION LATEST; a stored model holds only a number.
Control planeThe resource catalog in consensusFor each domain: its declared resources and their version sequences, the metadata of every published version, upload outcomes keyed by upload identity, and one replica record per version and node process incarnation.
Control planeUpload installation and replicationAdmitting an upload, assigning its number, installing it on every live node, and recording its terminal outcome.
Engines and infrastructureThe resource store on every nodeStaging an archive, verifying its digest, quotas, and manifest, and atomically promoting a complete version into its local directory.
Engines and infrastructureThe interconnectStreaming a version’s archive from a node that holds it to a node that does not.
DecisionsRegistry validation and the transaction plannerResolving LATEST, rejecting a version that is not completed, rebuilding the models a rebinding selects, and classifying the change into the plan a commit applies.
Data planeRuntime consumersLoading the version their execution plan names from the local store: descriptors, an inference session, a compiled module, a hash-map index, or a client mount.
EdgesThe HTTPS listenerPresenting the certificate every TLS VHOST pins, on every live node.

The catalog is domain-owned like every other entity. The same resource name in two domains is two resources with independent version sequences, stored content, and replica records, and a model resolves a resource only in its own domain.

The resource store keeps each version under <db-path>/resources, in one directory per domain, resource, and version number. It installs bytes and reports what it holds. It does not know how a version was uploaded, which node sent it, or which models use it.

Every resource keeps a directory of its own. A resource name may begin with a dot, as . and .. do, and the store names its own staging directories with a leading dot, so such a name’s directory writes that dot as %2E: the versions of a resource named .. sit in %2E.. % is outside the name alphabet, so no other resource’s directory has that name. A domain name holds no dot, and its directory is its name.

Registry validation and the transaction planner decide from the catalog inputs captured with a plan: the declared resources of the domain and its completed versions. Before a plan commits, the leader also checks three kinds of content against its own copy of a version: a VHOST’s TLS bundle must load, a hash map’s file must exist at its path, and an inferencer’s ONNX model must load and match the tensors the inferencer declares. The rest of a version’s content is proven by the consumers that load it.

Domain activation resolves codec, signaling-protocol, and VHOST bindings into typed specifications with their pinned resource identity before a node installs them. Runtime compilation loads that exact version from the local store; the HTTPS listener does the same for the VHOST certificates. Neither path reads the catalog to choose a version. Running and stopped domains use the same activation decision, so a stop, restart, or reassignment cannot select another version.

The HTTPS listener runs on every live node, independent of Raft leadership and graph placement, like every other entity that binds a configured listening port. It presents the TLS VHOSTs of the runtime revision its node applied.

Visual client-mount and VHOST-TLS forms select a resource and one of its completed versions through typed session choices. Their drafts may submit LATEST; neither form resolves it locally or creates resource content. The server applies the same version validation and pinning as the NSPL command path, so the rendered stored Model and every listener use a concrete version.

Version Lifecycle

Assignment

CREATE RESOURCE declares a resource in the selected domain and starts its version sequence at 1. Uploads are leader operations. A client-protocol upload declares the exact size of the archive it streams, and a follower redirects it to the leader. A web-console upload streams the selected files to the leader, which builds the archive itself; a follower refuses it.

The native client’s directory archive is completed and flushed locally before its upload call opens. A failed or cancelled construction discards its partial temporary archive and assigns no version. Once built, that exact archive is retained for every retry within the upload call.

A number is assigned when the leader admits an upload. Once the complete body has arrived, the leader holds the archive’s digest and records the upload under its identity in one consensus command, which takes the next number of the sequence. A body that ends early or exceeds its declared size is not admitted, assigns nothing, and leaves its identity unused.

A number is never reused. A failed upload keeps the number it was assigned, so the completed versions of a resource can have gaps. A sequence that reaches the largest representable number rejects further uploads rather than wrapping.

An upload identity is scoped to the authenticated user, the domain, and the resource. Repeating an upload with the same identity and the same archive digest continues or reports the upload already recorded under that identity, with the number already assigned; the same identity with a different digest is rejected as a digest conflict. An upload admitted through the client protocol keeps installing when the uploading connection closes. The identity rules and client retries are covered in Resources and Command Completion, and the upload stream, its typed failures, and its recovery in Resource Uploads.

Installing One Copy

The leader’s first copy and every replica go through the same verification in the resource store:

  1. The archive is staged under the archive quota and hashed with BLAKE3 as it is written, and its digest must match the version’s root checksum, the BLAKE3 digest of the archive. An archive that arrived with a declared size, over the client protocol or from another node, has already been rejected if its length differed.
  2. Entries are extracted into a staging tree. Only directories and regular files are accepted, every path must stay inside the version, and no path may appear twice. The extracted-byte and file-count quotas are checked from each entry’s tar header before that entry is written.
  3. The store records a manifest of every directory and file, with each file’s size and checksum. The leader’s manifest defines the version’s metadata: its root and manifest checksums, file count, and extracted and archive byte counts. A replica must reproduce exactly the metadata the leader published.
  4. The manifest, the extracted tree, and the archive are synchronized to disk, and the staging directory is renamed into place as the version’s directory. An earlier copy of the same version in that place is removed first.

A failure at any step removes the staging tree, so a partially installed version is never visible. Each node keeps the verified archive beside the extracted tree, which is what it serves when another node fetches the version. The quotas and their defaults are listed in Resources; each node enforces them itself.

Publication And Transfer

After the leader installs its own copy, one consensus command publishes the version: it stores the version’s metadata together with the leader’s ready replica record. From then on every node knows the version and can see which nodes hold it.

Every node reconciles its store with the catalog in repeated passes, starting each pass a second after the previous one finished. For each published version its current process incarnation has not recorded as ready, it first reads its own store: a local copy whose manifest matches the published metadata is recorded as ready without another transfer, which is how a restarted node reclaims what it installed before. Otherwise the node records a pending replica, fetches the archive from a live node whose replica is ready with the same digest, installs it through the steps above, and records a ready or failed replica with the reason. A node fetches at most four versions at a time, and when no live node holds a ready copy it records a failed replica. The leader accepts a replica record only from the node that record describes.

Archives travel as streamed bulk transfers with an exact declared length, in bounded chunks under HTTP/2 flow control, so neither end holds a whole archive in memory. An interrupted transfer is not resumed; the next reconciliation fetches the archive again from the beginning. Cluster Interconnect defines the bulk pool, its reserved resource streams, and the resource transfer quota in Traffic And Resource Isolation, and the streamed response and its deadlines in Exchange Forms.

Completion

The leader completes an upload when every current live node process incarnation has recorded a ready replica with the version’s digest. It derives that set from effective live membership each time the catalog or the membership changes, keyed by node name and process incarnation:

  • A node that becomes live while the upload is applying joins the set, and the upload waits for its copy.
  • A node that restarts under the same name is a new incarnation and must record its own ready replica. Its reconciliation normally does so from the copy already in its store.
  • A node leaves the set only when the cluster’s availability policy marks it unavailable. A missed probe or a stale observation does not waive its copy.

The first failed replica recorded by a live incarnation fails the upload at once, with that node’s reason. Otherwise there is no completion deadline: an upload stays applying until every incarnation in the set has recorded a ready replica, and a caller that stops waiting does not stop it.

The leader records the outcome in consensus. A failed upload is reported as soon as that record commits. A completed upload is reported only after the outcome is visible on every live node, and after the leader has evaluated the live set once more, so that a node that joined while the outcome propagated also holds the version before UPLOAD RESOURCE returns uploaded resource version <n>. If either of those final waits fails, because visibility timed out or a live incarnation recorded a failed replica, the command reports that failure although the version stays completed. The recorded outcome is terminal: a failed version never becomes completed, and a completed version never fails.

If an applying upload loses the task that was installing it, because leadership moved or a console upload’s connection was aborted, the leader resumes it at its next reconciliation. An upload whose version was never published fails with the admitted archive is unavailable before durable version installation, because only the node that admitted it held the archive. A retry of the same upload identity that reaches the leader before that reconciliation carries the archive again, so it installs the version and completes the upload instead. A published version keeps waiting for the live set as above.

A node that joins after completion does not change the outcome. Its reconciliation installs every published version it does not hold from a live node that holds it, and DESCRIBE RESOURCE <name> VERSION <n> shows its replica converge. Nothing else waits for that installation. Until the node holds a version, building a domain that needs it fails on that node, so the node does not report the runtime revision prepared, and commands that wait for every live node wait for it. The node builds the domain again the next time it applies the cluster’s runtime state, such as after a later schedule or domain change.

Bindable Versions And Latest

Only a completed version can be bound. An explicit VERSION <n> must name a completed version of the resource in the statement’s domain. An applying or failed version is rejected as resource '<name>@<n>' is not a completed version in domain '<domain>', and a number with no upload there as resource '<name>@<n>' does not exist in domain '<domain>'.

LATEST is the highest completed version. A newer version that is still applying or has failed never displaces it. A resource with no completed version cannot be bound through LATEST either: resource '<name>' has no completed versions in domain '<domain>'. DESCRIBE RESOURCE reports the version LATEST would select at that moment as latest:.

stateDiagram-v2
    [*] --> Assigned: leader admits the complete archive and takes the next number
    Assigned --> Failed: the leader's own installation fails or its installing task is lost
    Assigned --> Published: the leader installed its copy and published the metadata
    Published --> Published: a live incarnation installs, restarts, or joins
    Published --> Completed: every live incarnation holds the same digest
    Published --> Failed: a live incarnation records a failed replica
    Completed --> Completed: a later node installs its copy
    note right of Completed
        Bindable. The highest completed number is LATEST.
    end note
    note right of Failed
        Never bindable. The number stays assigned.
    end note

Nervix does not delete or expire versions. Every published version, including one that failed after publication, stays in the catalog, and every node keeps installing each published version it lacks.

Restored Versions

A restore brings a domain’s catalog from a backup archive instead of uploading it; see Backup And Restore. When it creates the domain, it declares every resource the archive declares with the next number of its archived sequence, so the next upload of the resource receives the number the source cluster would have assigned next. It then imports each completed version of the archive under its archived number, one at a time:

  1. The leader installs the version’s original upload from its section of the restore’s archive, through the same verification as any first copy. The archive must have the version’s archived root checksum, and the manifest the leader records must reproduce every archived checksum and count exactly.
  2. One consensus command publishes the version under its archived number, with its archived metadata, creation time, and creating node, together with the leader’s ready replica record. It records the import as the upload of an identity derived from the version’s number, owned by the user who ran the restore. It refuses a number at or above the declared sequence, a number already published, and a replica other than the leader’s ready copy with the version’s digest. Importing the same number again under the same identity changes nothing, which is what lets a resumed restore repeat an import whose outcome it did not see.
  3. The version completes as an upload completes: when every live node process incarnation has recorded a ready replica with its digest. The restore waits for that before it imports the next version, and applies the domain’s models only after every version is completed.

A number the archive records for an upload that failed, or was still installing when the backup read it, is not imported. It stays below the declared sequence, so it stays a gap that no upload receives and no binding can name. A restored catalog therefore holds only completed versions, and DESCRIBE RESOURCE in the restored domain shows the same numbers, gaps, and checksums as in the source, with the replicas of the restoring cluster’s own nodes.

Bindings

Seven model forms bind a resource version, and each requires a VERSION <n> or VERSION LATEST clause. Omitting the clause is a parse error.

BindingWhat it consumes from the versionWhen it loads it
VHOST ... WITH TLStls.crt, tls.key, and ca.crt at the version rootWhen a node installs the TLS VHOSTs of a runtime revision into its HTTPS listener: on every live node, for running and stopped domains
Protobuf CODECThe .proto sources its configuration selects, or every .proto file in the version when it selects none, compiled into descriptorsWhen a node builds the domain’s execution: on every live node, for running and stopped domains
Protobuf SIGNALING PROTOCOLThe .proto sources it selects the same way, compiled into the descriptors of its send and wait messagesWhen a node builds the domain’s execution: on every live node, for running and stopped domains
INFERENCERThe ONNX model file it namesWhen a branch instance first flushes output; each branch instance holds its own inference session
WASM PROCESSORThe module file it names, compiled into machine codeWhen a node builds the execution of a running domain or replaces the processor; a node keeps the compiled module only while the schedule assigns the processor to it as owner or replica, and each branch instantiates it when it first needs its guest
HASH MAPThe file it names, decoded line by line through its codec into an in-memory indexWhen a node builds the domain’s execution: on every live node, whatever the schedule assigns, for running and stopped domains
CLIENT ... MOUNTThe version’s content directory, linked into a temporary mount root; the connector reads the files it names from thereWhenever the client is instantiated, such as by an ingestor or emitter when it starts, or once per node for a pooled client; each client instance keeps its mount root for its lifetime

Registry planning derives typed lookup and WASM resource inputs from each committed schedule. The lookup input carries the exact resource identity, version, file path, codec, and key field. The leader rejects a missing file during candidate binding validation. Running and stopped domain builds use the same pinned input; the loader reports invalid decoded content before publishing the new runtime. A WASM input carries the exact module resource identity, version, and file. Running builds and entity swaps prepare that module from the scheduled input before replacing execution. The guest-state generations remain in the same scheduled processor revision and are part of its prepared-plan identity.

An HTTP emitter validates its referenced client’s origin, attempt timeout and paired client certificate/key settings before its candidate graph activates. A TLS path rendered from CLIENT ... MOUNT still names the pinned version in the client Model; validation does not fetch a newer version or probe the remote HTTP endpoint. Client instantiation reads the mounted CA or identity files at the existing load boundary above.

Replacing only the emitter’s client reference takes an entity pause: admitted requests finish through the existing client’s pinned mount before the replacement opens the newly selected client. Changing that client’s definition or rebinding its mounted resource takes the ordinary configuration-entity domain pause. If the drain cannot complete, the candidate definition and its resource binding are not installed.

The Pinning Invariant

Three rules together guarantee that a consumer uses exactly the version its model names:

  1. The persisted model names the number. Planning resolves the written form before a model is stored, and a stored model holds only the resulting number. The written LATEST is kept only in the transaction record of the statement that carried it, which is never planned again once that statement is applied.
  2. The execution plan carries it. The decision layer derives each typed consumer plan from the committed schedule, including the pinned number. Runtime installation binds that plan to the node’s local store without reading a Model; see Execution Plans.
  3. No consumer consults the catalog. A consumer resolves its number to a directory in the local store and loads it. It never asks which version is newest.

A rebuild, a restart, a failover, and a relocation derive typed consumer plans from the same committed models, so they load the same versions. Only a model mutation can change a number.

LATEST Resolution

LATEST is resolved by the planner when a statement is applied, against the catalog inputs captured for that plan. It never resolves at runtime.

  • A standalone statement runs as a one-statement command transaction and resolves LATEST when it is applied. The result names each resolution: resolved VERSION LATEST of resource '<resource>' to version <n> for <kind> '<name>'.
  • A statement queued inside BEGIN is planned at admission against the transaction’s prefix so that it can be validated. That resolution is provisional and is reported as provisionally resolved VERSION LATEST ...; the queued statement keeps the written LATEST. COMMIT plans the whole transaction again as one preview from a fresh snapshot, and admission freezes that plan, so LATEST binds the highest version completed when the admitted preview was planned, which may be newer than the provisional one.
  • REBIND RESOURCE ... TO VERSION LATEST follows the same timing and resolves its target once for the whole statement. Its summary line marks the resolved target with (latest).

The captured inputs include the domain’s declared resources and all of its completed versions, and they are compared with the current catalog twice: by the leader just before it applies a step, and again at the replicated apply boundary. An upload that completes in the domain in between, for any of its resources, makes the plan stale. The step is then rejected as domain '<domain>' resource inputs changed before any model is committed. The leader’s check runs before anything pauses; a rejection at the replicated boundary first releases the pause, gates, and handoff the step had engaged and rolls back the leader’s local registry.

What follows depends on who owns the plan. An ordinary command retains that failed attempt and starts another attempt, derived from the newer revision, under the same execution reference, so it plans again and LATEST binds the version that completed. An explicit transaction is frozen from COMMIT admission onwards: a basis that is already stale at admission leaves the transaction OPEN with a refreshed identity; the Rust client requires an inspection of the attached transaction before retrying against that identity. A conflict found after admission ends the transaction instead of replanning it.

Either way, a plan never commits a LATEST it resolved against an older catalog. Control Plane defines preview identity, admission, and the frozen plan.

Rebinding

REBIND RESOURCE is a model mutation. It changes the version the selected models bind and nothing else: it does not touch the catalog, and every other field of each model stays as written. Its operation reasons, effective model-run pause, resource-binding effects, and retained attribution use the shared Transaction Quiescence And Impact Inspection report. This chapter owns version selection, pinning, activation, and rebinding failure semantics.

Selection And Validation

The planner resolves the statement against the part of the transaction that precedes it, so it sees models that earlier statements of the same transaction create or drop.

  1. The resource must be declared in the domain, or the statement fails with resource '<name>' does not exist.
  2. The target resolves to one completed version exactly as a binding does: an explicit number must be completed, and LATEST selects the highest completed version.
  3. Without FOR, the selection is every model in the domain that binds the resource. With FOR, it is exactly the listed members. Each member must exist (<KIND> '<name>' does not exist in domain '<domain>') and bind the resource (<KIND> '<name>' does not bind resource '<resource>'). A repeated member is a parse error. Written order has no effect.
  4. The planner rebuilds each selected model with the target number. A usage that already binds the target contributes no change and is reported as unchanged.
  5. The rebuilt models pass the same checks as CREATE: registry validation of the complete candidate graph, and the leader’s content checks for each changed VHOST, hash map, inferencer, and WASM processor against the target version. One failing model rejects the whole statement, and the error names it, for example INFERENCER '<name>' binding validation failed in domain '<domain>': ....

A rejection at any of these steps happens before any effect: no model is stored, nothing pauses, and every usage keeps its version.

A WASM processor’s module is compiled before the statement can commit, because activating a module binding also replaces the guest state of every branch: a module discovered to be uncompilable afterwards has already invalidated the state its predecessor saved. The compiled module the check produces is the one activation installs, so nothing is compiled twice.

The remaining content that only a consumer can prove is proven when nodes build the consumers from the committed schedule’s typed plans: the protobuf sources a codec or signaling protocol compiles, and every line of a hash-map file. A client mount only needs the version’s directory; the connector reads the files it names when it uses them. A version that fails during activation fails the command after its models were committed, as described in Failure Semantics And Recovery. A stopped domain builds only its codecs and hash maps, so an unusable version bound by one of its other consumers surfaces when the domain starts.

Classification And State Effects

The rebuilt models are classified like any other model change, and the statement runs at the highest level among the usages it changes:

UsageLevelState effect
VHOST TLSDYNAMICEvery node’s HTTPS listener installs the new certificate. No execution node pauses or restarts.
Protobuf CODEC, SIGNALING PROTOCOLDOMAIN_PAUSEThe domain pauses and rebuilds with descriptors compiled from the new version.
HASH MAPDOMAIN_PAUSEThe domain pauses and every live node builds the index of the new version before the command succeeds.
CLIENT mountDOMAIN_PAUSEThe domain pauses and rebuilds; its ingestors and emitters instantiate their clients with a mount of the new version.
INFERENCERENTITY_PAUSEThe inferencer and its downstream pause. The replacement’s branch instances load new inference sessions from the new model file.
WASM PROCESSORENTITY_PAUSEThe processor and its downstream pause, and each branch is flushed. Every branch starts a new guest-state generation, so the replacement instances, created from the new module, initialize without guest state. The candidate module is compiled before the statement commits.

Control Plane defines what each level pauses and drains. A rebinding in a stopped domain has no running work to pause, so it runs and reports DYNAMIC. A stopped domain still keeps its codecs compiled and its hash maps loaded on every node, and its VHOSTs in every listener, so those move to the new version before the command returns; signaling protocols, inferencers, WASM processors, and client mounts load it when the domain starts. A WASM processor’s new guest-state generations are published with the rebinding even while the domain is stopped, so the processor starts without guest state.

The shared impact report records the rebinding as one operation. It contributes one rebinding reason, carrying the previous and the target number, for every selected usage, including unchanged ones, and the changes, pauses, activations, and state resets it causes are attributed to that operation.

Activation, Atomicity, And Rollback

All changed models are committed together with the resulting schedule in one step, under the domain’s alteration lock, after the quiesce their level requires. Activation then follows the ordinary model-change path: each consumer is rebuilt or refreshed from the committed models, and the command completes only when every live node has applied the revision, every live node’s listener has installed the certificates of an affected VHOST, and every gate the step engaged is released. The successful response is the boundary at which every selected usage uses the new version; see Command Completion.

The models of a rebinding are committed all together or not at all. A failure after commit follows the rule for any model step:

  • An HTTPS listener failure in a step that did not pause, such as one that moves only VHOSTs, rolls the step back in the same consensus entry that records the failure: the previous models and schedule are restored, and the command waits until every listener presents the restored certificates before it reports the failure.
  • Every other activation failure, and a listener failure in a step that paused, keeps the committed models. The step releases any pause it engaged and reports the failure, like any other model change whose activation fails.

Transactions And No-Ops

REBIND RESOURCE is ordinary transaction content. Inside BEGIN, it joins the consecutive model run it is written in, so a transaction can create a model and rebind it, or rebind several resources, as one atomic step, and the run is classified as a whole. Its target and selection are provisional at admission and are planned again in the preview that COMMIT admits and freezes.

A rebinding that changes no usage, because every selected usage already binds the target or the resource has no usages at all, writes no model. On its own it reports DYNAMIC and lists every selected usage as unchanged:

rebound 0 of 1 usage(s) of resource 'lookup_bundle' to version 2
quiesce level: DYNAMIC
- kind=hash_map name=lookup_by_id from=2 to=2 unchanged

Certificate Rotation

Rotating a VHOST certificate combines both halves of the contract: an upload that changes nothing, followed by a rebinding that moves the VHOST.

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant R as Raft state
    participant N as Other live nodes
    C->>L: UPLOAD RESOURCE tls_bundle (complete archive)
    L->>R: assign version 2 (applying)
    L->>L: verify, install, and promote its copy
    L->>R: publish version 2 with the leader's ready replica
    N->>L: fetch the archive over the bulk class
    N->>N: verify, install, and promote
    N->>L: ready replica for this incarnation
    L->>R: record the replicas, then version 2 completed
    L-->>C: uploaded resource version 2
    Note over L,N: every listener still presents version 1
    C->>L: REBIND RESOURCE tls_bundle TO VERSION LATEST
    L->>L: resolve LATEST to 2, rebuild the VHOST, load its bundle
    L->>R: commit the rebuilt VHOST and schedule (DYNAMIC)
    R-->>N: runtime revision
    L->>L: install listener certificates, report prepared
    N->>N: install listener certificates, report prepared
    L->>N: https_listener_installation(revision)
    N-->>L: installed
    L-->>C: rebound 1 of 1 usage(s) ... quiesce level: DYNAMIC
    Note over L,N: new handshakes present version 2, established connections continue

Uploads Change Nothing That Runs

An upload is a catalog operation. It adds one version and ends with that version installed on every live node, and it refreshes nothing. Catalog changes do not advance the runtime revision that nodes apply, so no listener, compiled descriptor, index, session, module, or mount is rebuilt because a version completed, and no binding follows an upload. DESCRIBE RESOURCE shows the new latest:, while every model keeps the number it pins.

This keeps two concerns apart. An upload proves that the bytes arrived intact everywhere; whether they are usable is a property of the model that binds them. A version whose content no consumer can use, such as a malformed TLS bundle, completes like any other upload, and every existing binding keeps its version. The problem is reported only when a statement binds that version: the leader’s content checks reject it before any effect, for example with invalid TLS resource for VHOST '<name>' in domain '<domain>' from '<resource>@<version>': no certificates found in TLS CA certificate, and content that only a consumer proves fails the statement’s activation. TLS failures identify the certificate, private key, or CA certificate by kind without exposing its filesystem path or material in the command result or server log.

The DYNAMIC TLS Refresh

Every node’s HTTPS listener presents the TLS VHOSTs of every domain in the runtime revision that node applied, including domains that are stopped, because a stopped domain keeps its schedule. For each revision the decision layer supplies the listener with VHOST specifications whose TLS resource identities already include the pinned version. Endpoint host and path decisions use those same VHOST hostnames, while stopped domains keep their routes inactive until they start. For each TLS VHOST the listener loads the pinned version’s certificate chain and private key and presents them to clients whose SNI names one of the VHOST’s hostnames. The certificate must be valid for every one of those hostnames; the leader’s content check does not compare them, so a certificate that does not name a hostname fails at installation. ca.crt must parse, but the listener neither presents it nor asks clients for a certificate. Until a node has installed its first revision after it starts, and whenever no TLS VHOST exists, its listener has no certificates and refuses TLS connections.

A node installs the listener certificates of a runtime revision before it reports that revision prepared. The installation replaces, in one step, the configuration that new connections handshake with:

  • Established connections keep the session they negotiated and are not interrupted.
  • New handshakes present the new certificates.
  • A revision whose set of TLS VHOSTs is unchanged keeps the configuration already presented.
  • The configuration is built from every TLS VHOST at once, so a bundle that the node cannot load, in any domain, fails the whole installation on that node.
  • Each revision is attempted once. A failed installation leaves the previous certificates in place, is recorded with its reason for that revision, and is attempted again at the next revision.

A command that creates, changes, or drops a VHOST confirms the installation on every live process incarnation through the https_listener_installation management request described in Cluster Interconnect. A failed installation on any node fails the command with failed to install the HTTPS listener TLS configuration on node '<node>'. When the step did not pause, as for a version-only change, the creation or removal of a VHOST, or any VHOST change in a stopped domain, that failure rolls the step back as described above: the error states that the model batch was rolled back, and every listener presents the restored certificates before the command returns.

Of the changes to an existing VHOST, only a move to another version of the resource it already binds is DYNAMIC; creating or dropping a VHOST is DYNAMIC as well. Endpoint routing reads a VHOST’s hostnames but not its certificate, so a version move is applied as a dynamic schedule update: a running domain’s buffered work is force-flushed, but no execution node pauses, restarts, or is rebuilt. Changing hostnames, adding or removing WITH TLS, and binding another resource change the VHOST’s configuration rather than only its certificate. They stay DOMAIN_PAUSE, like the other configuration entities whose consumers read them when the domain is built, and the domain rebuilds around the new model. Every listener still installs the resulting certificates before such a command succeeds.

Guarantees And Limits

The contract provides six guarantees:

  1. A running consumer uses exactly the version its model names. The number is stored in the model, carried in the execution plan, and loaded from the local store.
  2. A version change is a validated, classified, atomic model change. It is planned from one captured snapshot, validated like a CREATE of the rebuilt models, classified at the level of the usages it changes, and committed for every selected usage or for none.
  3. An upload changes nothing that is running. It adds a version to the catalog and installs it, and no consumer reloads because of it.
  4. Only completed versions bind. CREATE and REBIND RESOURCE accept a version only after its upload completed on every live node incarnation, and LATEST means the highest completed version.
  5. Rebuild, restart, failover, and relocation cannot move a binding. They rebuild consumers from the stored models, which name the same numbers.
  6. Rebinding never crosses domains. A rebinding resolves the resource, its versions, and its usages in the selected domain only.

Nervix does not provide:

  • Guest state carried across WASM module versions. A generation of guest state belongs to the module binding it was published for. A model change that binds a processor to another resource, version, or module file, such as a rebinding, starts a new generation for every branch in the same schedule publication, so no checkpoint saved under the previous module can attach to the new one and every branch starts without guest state. A rebinding that leaves the processor’s version unchanged keeps its state. See State Generations and Rebinding And Rollback.
  • Version deletion or retention. Versions accumulate in the catalog and in every node’s store. Plan storage for every version ever uploaded.

Failure Semantics And Recovery

FailureOutcome
A statement binds a missing, applying, or failed version, uses LATEST with no completed version, names an unknown resource, or lists a missing or non-binding FOR memberThe statement is rejected before any effect. No model is stored, nothing pauses, and every usage keeps its version.
A VHOST’s TLS bundle does not load, a hash map’s file is missing, an ONNX model does not match its inferencer, or a WASM module does not compileThe leader’s content checks reject the statement before any effect.
An upload completes in the domain between capture and applyThe step is rejected as stale before any model is committed, and whatever it engaged is released. An ordinary command plans again as another attempt under the same execution reference; an explicit transaction reports the conflict instead of replanning.
An HTTPS listener cannot install a VHOST change in a step that did not pauseThe failure record restores the previous models and schedule, and every listener presents the restored certificates before the command reports the failure.
A consumer cannot load the version during activation, such as protobuf sources that do not compile or a hash-map line that does not decodeThe step releases any pause it engaged, the committed models remain, and the command or transaction reports the activation failure. Nothing returns the models to their previous versions; a later model change, such as a rebinding to a usable version, repairs the binding.
One node cannot install a version during its upload, including a transfer that is interruptedThe upload fails with that node’s reason. The number stays assigned and is never bindable.
A node lacks a published versionIts reconciliation fetches the version from a live node that holds it, from the beginning of the archive. Until it holds the version, that node cannot build a domain that needs it.

Pinned bindings are what make recovery reproduce the same versions. After a process restart, a leader failover, a cluster restart, or a relocation, every consumer is rebuilt from the stored models and loads the number they name from the local store. The catalog is committed control-plane state, and a restarted node reclaims the versions already in its store by checking their manifests against the published metadata. Its HTTPS listener presents certificates again once the node has been admitted back into runtime execution and has installed the revision it applies.

Startup removes staging trees abandoned by an interrupted process, so an interrupted installation leaves no partial version, and closing a connection cancels an upload that is still waiting for its client. Shutdown And Recovery and its Stopping Intake section define both, together with what survives each kind of ending.

Observability

SignalWhat it shows
DESCRIBE RESOURCE <name>latest: as the highest completed version or (none); every published version, including one still applying or failed after publication, with its checksums, sizes, and the entries of the answering node’s copy; and usages:, one line per model that binds the resource with its pinned version. Session clients receive the same facts as a typed resource description beside the text: an absent latest version stays absent, each entry keeps its exact path, and an unreadable manifest is reported as unavailable rather than as an empty version.
DESCRIBE RESOURCE <name> VERSION <n>The version’s checksums, file count, size, and entries; one line per node with its topology, replica state, incarnation, checksum, verification time, source node, and error, taken from its live incarnation or, for a node that is not live, from its latest one; and the models pinned to that version, or none.
SHOW CREATEThe stored number for every binding. A statement written with LATEST renders the number it resolved to.
DESCRIBE HASH MAPresource: <name>@<n> for the loaded version, together with the path, codec, placement, key field, and entry count.
DESCRIBE WASM PROCESSORThe module’s resource: and pinned resource version: beside the current guest-state generation and checkpoint status. A completed reset of the previous binding is not presented as a reset of the newly pinned module.
Command resultsuploaded resource version <n>, every resolved VERSION LATEST ... line, and the rebinding summary with one from/to line per selected usage.
Web consoleThe highest completed version of each resource in the sidebar and, in the resource dialog, every version’s entries and the models bound to it, read from the typed resource description. See Web Console.
Server logrebound resource usages at info once the leader has applied a rebinding’s step, before the step’s outcome is recorded, with the domain, resource, target version, and the counts of selected and changed usages; installed HTTPS listener TLS configuration at info whenever a node’s listener starts presenting a changed set of certificates.
Interconnect metricsArchive fetches in flight and fetches refused for lack of capacity appear in nervix_interconnect_pending_operations and nervix_interconnect_quota_failures_total with operation="resource". Their bytes count toward nervix_interconnect_bulk_bytes_total{class="bulk"}. Consensus snapshot transfers share the Bulk pool but move as requests, which nervix_interconnect_requests_total{operation="snapshot"} counts instead. See Metrics And Observability.

A version’s upload outcome is not a line of its own. latest: names the highest completed version, and the per-node lines show which incarnations installed a version and why one failed; replica installation writes no log lines of its own. Descriptions are observations: an upload or a rebinding completes on its own completion boundary, and a caller never polls a description to finish one.

Data Plane

The data plane is the runtime execution engine.

It executes the typed revision of a committed schedule. Execution Plans describes the decisions, local binding, and publication that prepare its tasks.

Connector Crates And The Connector Contract defines how the host admits external source messages and publishes through external sinks, including their ACK and commit boundaries. This chapter follows the Arrow batches those boundaries hand to the graph.

It is responsible for:

  • receiving records from ingestors
  • decoding payloads through codecs
  • evaluating structured filters, construction expressions, and side-effect invocations
  • grouping records into isolated execution branches
  • batching rows into Arrow record batches at node boundaries
  • moving Arrow batches across processors and relays
  • materializing selected state
  • encoding and emitting outbound payloads

The data plane is intentionally non-transactional.

Transaction commit can gate admission, drain or force-flush graph work, and activate a new schedule. Transaction Quiescence And Impact Inspection explains which work a planned scope covers and how engagement is reported; in-flight batches and ACK state remain volatile here.

Errors And Diagnostics defines how branch-local failures, materialized-state outcomes, message errors, and recovery classes reach their reporting boundary.

Decoded messages are processed in memory as Apache Arrow batches, including batches with one row. That gives the runtime a columnar format suitable for vectorized processing and batch transfer.

SIMD Kernels describes the typed buffer operations used by JSON boundaries, delivery-latency recording, domain and window admission, failure and selection bitmaps, integer arithmetic, and text codecs. Kernel dispatch changes how those operations execute; schema checks, branch ownership, acknowledgement attribution, and persistence remain the runtime’s contracts.

Nervix has three separate persistence boundaries:

  • Execution graph configuration is control-plane state. NSPL models, domain lifecycle, and schedules are persisted with strong consistency guarantees before runtime nodes execute them. The control plane turns each committed schedule into a complete typed execution revision; the runtime retains its planned nodes, placement, and executable plans together and does not reread Models during application or recovery.
  • Execution node state is runtime state. Selected state such as domain offsets, deduplicator history, materialized relay entries, window accumulators, and metric summaries is persisted through periodic snapshot/replication mechanisms, and WASM guest state through a durable checkpoint at the end of every guest callback. Each persisted state is keyed by the identity the committed schedule publishes for its node. Domain offsets and metric summaries depend on no schema and are keyed by their node alone, so they survive schema changes. Every other state is also keyed by the fingerprint of the schemas it is laid out by, so a schema change starts it anew, and a node without a published fingerprint for an entity cannot place that entity’s schema-bound state at all. A materialized relay’s snapshot is columnar: it carries the relay’s records as Arrow sections under the relay’s exact schema, with each record’s concrete branch key, watermarks, and the state revision, ownership assignment, and branch lifecycle it was captured at described beside them. A snapshot holds exactly the branches of the branch lifecycle it names and at least the committed revision it names: an update to an existing branch’s record that lands while the records are read may already be present, and the next revision carries it again. Updates to existing records continue while a snapshot is captured and written out; a branch that arrives or is evicted waits only while the records are read, and a snapshot taken before a branch was evicted never restores that branch. A snapshot larger than the transfer budget crosses the bulk pool in bounded chunks and lands on the receiving node’s staging disk, where its length and digest are checked before anything reads it. A cancelled, truncated, or corrupted transfer leaves the state it would have replaced untouched. A deduplicator’s keys and a window processor’s window belong to the branch task that processes the branch, which changes them without waiting on snapshots or replicas. While that state keeps changing, the task publishes an immutable copy of it at least once per replication poll interval, and it publishes again before an ownership handoff checkpoint and when the branch stops. Snapshots, replica synchronization, and the next task for the same branch read only the published copy, so a replica holds a branch’s state as of its latest publication. A branch task that is aborted after exceeding its shutdown grace period still has its latest publication persisted, but loses the changes it made after that publication. A published window carries the rows it retains together with each row’s aggregate arguments; restoring it re-admits those rows in order, which rebuilds every aggregate structure, and only a histogram percentile’s removals that are still waiting out their delay are carried beside the rows. A row’s acknowledgement completes when the window steps past it; until then its shares are parked, so neither a drain, an ownership handoff nor a backup cut waits for them. A quiesced backup’s lifecycle checkpoint also makes every deduplicator and window branch task publish, and the backup archives that publication as public Arrow sections: the deduplicator’s keys with the time each was first seen, and the window’s retained rows with their aggregate arguments and delayed removals; a restore rebuilds the native state from them, see Backup And Restore. A WASM processor checkpoints its guest state at the end of every guest callback and keeps the bytes the guest returned instead of copying them. Unlike the periodic snapshots above, a guest-state checkpoint is written to the node’s stable storage and synchronized, and when the schedule assigns the processor replicas, each of them writes and synchronizes it too before acknowledging it. The inputs a callback decided are acknowledged only after its checkpoint completes, and a checkpoint that fails leaves the previous one committed, negatively acknowledges those inputs, and recreates the branch’s guest from the committed checkpoint; see WASM Processor Guests and WASM State And Recovery. A guest saves only its computation state there, never the input it buffers or that input’s ACK tokens. Each branch’s guest state is keyed by its state generation, the lifetime the committed schedule names for it, so a checkpoint, a replica installation, or a recovered checkpoint of an earlier generation can never address the current state; the node’s state store writes guest state on its storage workers rather than on the async worker that runs the branch.
  • Message streaming is the hot path. In-flight records, relay batches, processor handoff, outbound emitter attempts, ACK guards, ACK tokens, and ACK maps stay in memory and are never persisted as runtime state.

The Data-Plane Concurrency chapter defines how this hot path avoids shared lock acquisition, publishes reconfigurable state, owns per-row mutation, and retains only the ordering fences required by delivery and ownership contracts.

Every relay has one scheduled owner. Producers on other cluster nodes use one fixed dispatch slot per relay and serialize each batch once for the owner. The owner alone maintains the bounded relay buffer, concrete branch presence, metrics, subscriptions, and fan-out. It sends at most one serialized copy to each remote consuming cluster node, where all runtime consumers and any local subscription share that delivery. Only a relay’s optional materialized records have scheduler-selected state replicas; the relay’s hot-path runtime is never replicated.

Nervix is not a durable event log for every in-flight row. If hot-path message or ACK state is lost, sources and ingestors react according to their delivery mode, offsets, and retry policy.

Branch grouping is native runtime isolation based on explicit CREATE BRANCH declarations. A branch declares the branch-key schema shape with SCHEMA <schema>, TTL, and optional eviction policy. The branch name is part of its identity: differently named branches remain incompatible even when they reference the same schema. Ingestor routes construct keys with BRANCHED BY <branch> SET ...; reingestor routes preserve the input key, construct another named branch, or become unbranched. Relays and branch-preserving processors use that exact named branch or declare UNBRANCHED. Relay presence, processor buffers, deduplicator state, window state, and materialized entries remain scoped to one concrete branch; batches for those branches share the declared relay’s owner buffer.

Structured Model expressions are compiled into typed VM programs. The leader validates them when a statement is applied, so invalid scopes, construction, types, nullability, sensitivity, or branch relationships fail at command time. Runtime nodes never reparse stored NSPL. A program executes over the Arrow batch its runtime node projects for it, reusing input columns for inherited or still-current values and constructing new columns only for rewritten or newly initialized output fields. VM Functions defines the compilation pipeline, columnar execution, row and batch errors, kernels, every function family, and branch-local window aggregates, and Expression Functions owns the public function contracts.

ACK Composition

Relay fan-out gives each attached runtime consumer a descendant of the incoming ACK state. Detached consumers receive the batch without an upstream ACK dependency. The source ACK succeeds only when all attached descendants succeed. Any attached failure fails the shared source attempt, even when another descendant has already completed an external side effect.

The relay owner holds a dispatch permit while it selects and fans out to attached consumers. If a schedule swap has already closed that gate before a buffered batch begins fan-out, the owner fails the batch’s ACKs and lets the source retry under the new schedule. This prevents a sibling attached consumer on the old node from completing the source ACK while a moving consumer is absent from the owner’s routes.

A batch that a relay owner routes to another node for its attached consumers carries record acknowledgements for them. If no attached consumer of the relay runs on that node when the batch reaches its runtime, because the consumer moved away after the owner routed the batch, the batch fails those acknowledgements instead of completing them, and the source redelivers the record along the current routes. See Consumers That Leave The Receiver.

A node that sent a record’s acknowledgement to another node waits for it only while that node keeps reporting it. When the other node reports nothing about it for fifteen seconds after admitting the batch, because its outcome was lost on the way back or its process ended, the acknowledgement fails and the source redelivers the record. An acknowledgement lost between two nodes on a record’s path therefore fails the source attempt instead of keeping it waiting. See Record Acknowledgements The Receiver Stops Reporting.

ACK guards, tokens, and maps remain in memory. They do not record a transactional per-sink commit ledger. After source redelivery, every attached path processes the record again. This is why an already successful non-idempotent sink can receive a duplicate after a sibling path fails. See ACK Semantics And Effective Delivery for the sink consequences and mitigations.

Every internal schema type has one Arrow representation, which relays, processors, and the VM share; Typed Batches And Registers lists them. The Expression Functions reference lists every operator and function.

Examples of replicated runtime state:

  • Kafka offsets when using OFFSET BY DOMAIN
  • deduplicator state
  • materialized relay state
  • metric summaries used by DESCRIBE output
  • WASM guest state

Kafka partition scheduling for OFFSET BY DOMAIN is control-plane state instead. The leader observes Kafka topology, commits the partition-to-instance assignment into the Raft-backed domain schedule, and the data plane executes only that committed assignment.

Examples of state that is not treated as a durable commit log:

  • normal in-flight relay batches
  • ACK guards, tokens, and maps
  • outbound emitter operations
  • intermediate processor handoff

For relay movement between nodes, Nervix uses Arrow IPC batch serialization on the interconnect path. A producer or owner dispatch slot is scoped to one concrete branch and remains held until the receiving runtime atomically admits or rejects that batch. Other branches continue concurrently, while the attached ACK chain reports downstream processing after admission. A body receipt alone does not mean the runtime admitted the batch. Control traffic such as lookups and state-sync RPCs still uses separate control-envelope formats.

Runtime graph metrics are maintained alongside the data plane. Prometheus export uses branch-aggregated series to keep label cardinality bounded, while DESCRIBE can report branch-local metrics where a concrete relay branch is being inspected. See Metrics And Observability.

The runtime ownership above produces a per-branch resource cost. See Capacity Planning For Branched Graphs for the operator-facing cost structure and the current signal gaps.

This design keeps latency low and avoids turning the runtime into a transactional storage engine.

Execution Primitives

This chapter owns the execution primitive boundary: its layer, governed API families, backend selection, target capabilities, permissions, and build contracts. Data-Plane Concurrency owns the protocols callers build from those primitives, source-owned synchronization contracts, production-owner Shuttle and Loom checks, and diagnostic deadlock detection. Deterministic Interconnect Simulation owns the simulated network and its evidence.

Ownership And Capabilities

Every execution-sensitive primitive Nervix uses comes from the nervix-primitives crate, which sits below the vocabulary and selects each family for the build’s execution mode: ordinary execution, Shuttle, Loom, Turmoil or the Deloxide diagnostic mode. A build uses one mode across its whole dependency graph. Every package that owns a shuttle, loom, turmoil or deloxide feature forwards it to the primitive crate, and Cargo unifies that crate’s features, so a vocabulary type, an engine and the server compiled into one test binary all use the same backend. Selection depends only on features, never on cfg(test). Enabling two modes fails to compile with a diagnostic that names both, including when two different dependencies each enable one.

The primitive crate depends on the standard library and the backend libraries it selects. It names no Nervix vocabulary, graph, policy or scenario. nervix-model-harness owns the exploration of caller models and their invariant identities, separately from backend selection. The server remains the composition root; enabling a mode changes how its dependencies execute without moving a decision or a protocol into the primitive layer.

Ordinary execution re-exports the governed library items directly, with no boundary wrapper, allocation, dispatch or scheduling point. Explicit mechanisms such as detached-thread creation perform their named operation through ordinary primitives. The atomic and shared-ownership families are portable and build for the browser console. Everything else is the explicit native capability, and asking for a capability or a mode the target cannot provide is a compile error rather than another implementation: a mode or the native capability requested for the browser’s target fails with the boundary’s own diagnostic as its first error, because the libraries of every mode and of the native capability exist only for native targets and none of them is compiled there.

Backend Matrix

FamilyPathOrdinary, Turmoil and DeloxideShuttleLoom
Atomic values, Ordering, fencesync::atomicThe standard library’sModeled: every operation is a scheduling point, and every ordering behaves as SeqCstModeled: explored under the C11 orderings Loom supports
Shared ownership: Nervix-owned references, and the standard strong and weak references a weak reference or an external API such as Arrow or a Tokio semaphore requiressync::Arc, sync::StdArc, sync::StdWeaktriomphe’s Arc, and the standard library’s Arc and WeakReal, with no scheduling point: no reference count is modeledReal, outside every model
Async locks, semaphores and one-time cells; the mpsc, oneshot and broadcast channelssyncTokio’sModeled: Shuttle’s TokioReal: Tokio’s, outside every model
Notify and the watch channelsyncTokio’sModeled: the boundary’s own, with Tokio’s semantics and a scheduling point before every registration, notification and deciding readReal: Tokio’s, outside every model
The waker registration of one waiting tasksync::AtomicWakerThe futures crate’sOpaque, with a scheduling point before and after each registration, wake and takeReal: the futures crate’s, outside every model
Cancellation tokens and their guardssyncTokio Util’sModeled: Shuttle’s token, wrapped for Tokio Util’s clone identity and owned operationsReal: Tokio Util’s, outside every model
Blocking locks and their condition variablesync::blockingparking_lot’s; under Deloxide, the boundary’s tracked adapters over Deloxide’s locks, described in Diagnostic deadlock detectionModeled: Shuttle’s parking_lot locks, and the boundary’s condition variable over themReal: parking_lot’s, outside every model
Barriers, Once and the synchronous channelsync::blockingThe standard library’sModeled: Shuttle’sReal: the standard library’s, outside every model
OnceLocksync::blockingThe standard library’sOpaque, with a scheduling point before and after each read and write; no initializing readReal: the standard library’s, outside every model
LazyLocksync::blockingThe standard library’sUnavailableReal: the standard library’s, outside every model
Task spawning, joining, yielding, aborting, tracking and the cooperative budgettaskTokio’s and Tokio Util’sModeled: Shuttle’s Tokio, with the boundary’s abort-on-drop handleReal: Tokio’s and Tokio Util’s, outside every model
Blocking the runtime worker thread that calls it: the declared owners outside the executortask::block_in_placeTokio’sUnavailableReal: Tokio’s, outside every model
Running a CPU job the bounded executor admittedtask::spawn_cpuTokio’s blocking pool; under Turmoil, one task of the simulated host’s schedulerModeled: Shuttle’s spawn_blockingReal: Tokio’s blocking pool, outside every model
Running a job on the blocking pool itself: the executor’s storage jobs, and the declared owners outside the executortask::spawn_blockingTokio’s blocking poolModeled: Shuttle’s spawn_blockingReal: Tokio’s blocking pool, outside every model
Timers and the monotonic clock: sleeps, deadlines, timeouts, intervals and instantstimeTokio’s, following the clock of the runtime that polls them: the operating system’s, a test’s paused clock, or the simulated host’s under TurmoilShuttle’s: a sleep or an interval’s tick is one scheduling point that takes no time, and a timeout expires only when a check triggers it; Instant::now reads the operating system’s clockReal: Tokio’s, outside every model
Controls of a paused clock: pause, advance and resumetime, with the test-util capabilityTokio’sShuttle’s, which take no timeReal: Tokio’s, outside every model
Sockets: TCP listeners and streams with their owned halves, UDP and local socketsnetTokio’s over the operating system’s network; under Turmoil, Turmoil’s simulated sockets and lookup_host, which answers from the simulated DNS table, and no local socket or socket configured before it connectsReal: Tokio’s, outside every model. A Shuttle execution has no Tokio reactor, so a socket created inside a check panics and fails itReal: Tokio’s, outside every model
The runtime, #[nervix_primitives::test], #[nervix_primitives::main], select!runtime, crate rootTokio’sShuttle’s runtime and select!; the attributes build Shuttle’s runtimeReal: Tokio’s, outside every model
Streams over channelsstreamTokio Stream’sShuttle’s Tokio StreamReal: Tokio Stream’s, outside every model
Atomic reference publication and its cachepublicationArcSwap’sOpaque, with a yield before and after each load, store, compare-and-swap and read-copy-updateReal: ArcSwap’s, outside every model
Concurrent mapscollectionsDashMap’sModeled: Shuttle’s DashMapReal: DashMap’s, outside every model
Lock-free queuescollectionsConcurrent Queue’sOpaque, with a scheduling point before and after each operationReal: Concurrent Queue’s, outside every model
Threads: spawning, joining, building, scopes, parking, sleeping, yieldingthreadThe operating system’sModeled threads; sleeping and parking with a timeout are scheduling points that take no timeModeled: Loom’s threads to spawn, build, join, park and yield. Real: the operating system’s sleeping, timed parking and scopes, outside every model
A thread nothing joinsthread::spawn_detachedA named operating-system threadA detached Shuttle task, abandoned when the model’s main thread returnsReal: a named operating-system thread, outside every model
Thread-local storagethread_local!The standard library’sShuttle’s, one per taskModeled: Loom’s, one per model thread
The host’s parallelismthread::available_parallelismThe host’sThe host’sThe host’s
Deadlock findings, and the detector that reports themdeadlockThe findings in every mode; the detector only under DeloxideThe findings; no detectorThe findings; no detector
Real primitives outside every modelunmodeledRealRealReal

Every exposed operation has one of three classifications in each mode. A modeled operation is observed by the mode’s engine, and a check may make claims within that engine’s limits. An opaque operation runs a real primitive between explicit scheduling points: the scheduler can order an owner’s use of it against other tasks, but the primitive’s own instructions and memory ordering stay unmodeled, so a check claims nothing about them. An operation outside the modeled execution uses a real primitive and supplies no evidence about a checked protocol. The matrix identifies real families retained by a selected backend; an explicit use of nervix_primitives::unmodeled needs a named permission. An operation a mode cannot provide is unavailable in that mode and fails to compile; it never falls back to a real primitive. Shared ownership is real in every mode and on every target: no model checker counts references, so a check claims nothing about an ordering a reference count establishes, such as the one get_mut or try_unwrap relies on. Loom models isolated synchronous owners: atomics, the threads a model spawns, joins, parks and yields, and thread-local storage. It models no async family, lock, collection or publication, so a Loom build, in which the server and consensus compile for the models of their owners, takes the ordinary library for each of those, outside every model. just validate-primitive-boundary rejects every such family in Loom model code, an inline module or a module file whose declaration compiles it only for Loom, so a model never names a real primitive silently; what an owner a model drives uses internally is excluded from that model’s claim. The check reads each cfg of a module through its all, any and not: a module is Loom model code when one of them is false in every build without the loom feature, and a module whose cfg the check cannot read is rejected. Model code may name shared ownership and a permitted unmodeled primitive, because each says it is real. just test-primitives shows each mode’s selection.

Turmoil replaces the network and the clock, not synchronization. A Turmoil build runs the ordinary primitives on the simulated host whose task uses them: net selects Turmoil’s sockets, and net::lookup_host, which only that mode has, answers from the simulated DNS table; the timers and instants of time follow the host’s simulated clock; and task::spawn_cpu runs an admitted CPU job as one task of the host’s scheduler, so its synchronous body is one scheduling step. What the simulated hosts run, and what the simulation establishes, belong to Deterministic interconnect simulation.

Deloxide models nothing and replaces only the thread-blocking locks and their condition variable. A deloxide build runs every other family as ordinary execution does, and its tracked locks are real locks whose acquisitions a deadlock detector also observes, so it runs as a product does and claims no interleaving. What it reports, and what it cannot, belong to Diagnostic deadlock detection.

Policy Boundaries

The boundary supplies mechanisms and decides no policy. net never decides what a name means: a node resolves through its own resolver in nervix-dns, no mode but Turmoil’s offers a lookup, and the boundary check rejects tokio::net::lookup_host and the ToSocketAddrs traits, which resolve through the operating system. time grants no clock permission: actual UTC and physical deadlines keep the owners scripts/check_clock_boundaries.py declares, and a logical deadline stays in the domain clock’s coordinate, as Domain Clock describes. task::spawn_cpu belongs to the bounded executor, which admits, charges and cancels every job; the boundary check rejects it in any file but the executor’s worker pools, so it is no way around admission. task::spawn_blocking is the runtime’s blocking pool, which the executor’s storage workers run on, and task::block_in_place blocks the runtime worker thread that calls it, which no file owns by default. Work a node runs off its async workers goes through the executor instead, so the check rejects either in any other file unless a permission in crates/primitives/blocking-permissions.toml lists it for that file, with the owner, why the owner stays outside the executor, and what bounds its work instead, as Blocking work outside the executor lists. pause, advance and resume exist only with the test-util capability, which ordinary tests of elapsed-time behavior enable and no production graph has, because a clock that can be paused is checked on every read.

The runtime attributes build the selected runtime through a crate path fixed to the boundary: Tokio’s attribute builds whatever runtime its crate path names, and an attribute that named tokio at the call site would construct whichever crate a package happened to call tokio. Shuttle’s own test attribute finds its runtime by reading the calling package’s manifest for a dependency of a fixed name, so the boundary does not use it; in a Shuttle build a test attribute builds Shuttle’s runtime, which runs only inside a Shuttle execution.

Every async loop whose body performs async work calls nervix_primitives::task::consume_budget once per iteration near its beginning. The selected scheduler owns that yield opportunity; callers do not add their own backend choice. std::time::Duration remains a value in every mode. The bounded executor still owns job admission, class isolation, memory charges and cooperative cancellation; choosing a task primitive grants none of those permissions by itself.

Unmodeled Primitives

A modeled primitive exists only inside a run of its model. Using one outside that run is a test configuration failure the backend reports, and nothing falls back to a real primitive. A real primitive that must stay outside every model is reached through the unmodeled path, and each use needs a permission in crates/primitives/unmodeled-permissions.toml that names the file, the items by their paths below unmodeled, the owner, why a real primitive is required, and what that leaves unverified:

OwnerWhy the primitive is realWhat stays outside every check
The Shuttle and Loom runners of nervix-model-harnessTheir statistics span every model execution they start and are read after the last oneNothing a check claims; they are runner bookkeeping
The WASM runtime’s epoch driver and guest-callback timeThe epoch driver is an operating-system thread no model runs, which sleeps for real and is stopped by a flag; a guest callback reads its domain time from Tokio’s task-local storage, which no model isolatesWhen the epoch thread observes shutdown, and a callback’s time scope, which Shuttle tasks would share while polled
The C binding’s sessionThe binding owns the runtime its host’s threads enter and shuts it down in the background, which is its contract with the host and which Shuttle’s runtime does not provideEverything the binding runs; the Rust client it drives has its own checks
The cluster membership transportChitchat hands out Tokio’s own lock and watch receiver and runs on Tokio’s runtime, so the transport tasks it drives wait, sleep and select on Tokio’s primitivesCluster membership gossip
Process-wide one-time state: the SIMD instruction level, the interconnect’s cryptography provider, and the test and benchmark schema and certificate fixturesInitialized once per process and read by every later model execution in itDetection, installation and fixture construction
The browser console: its executor installation, and the futures channel, select! and abort handles its tasks use on the browser’s event loopThe browser target runs in no execution mode and has no native capability, so the console takes those families from the futures crate, which a native build takes from TokioEverything the console runs
The VM benchmarks’ allocation probeA global allocator counts allocations made on every threadNothing; the benchmark claims nothing about synchronization
The application unit-test fixturesTest databases and node ports must differ across every unit test the process runs in parallel, so their identities outlive each testNothing a check claims; no model builds the fixtures
The records of the relay gate and fan-out, entity gate, emitter record-write, durability barrier, WASM checkpoint, source host-loop, stream-slot, retained-archive and client ingestor Shuttle checksA record changes in the same scheduling step as the operation it records, so recording adds no scheduling pointNothing the owner does: records observe and never synchronize, and the owners’ own primitives are modeled
The Turmoil runner’s real-time bound and the scenario driver’s attempt deadlineA run’s bound must expire even when a host blocks the scheduler thread and simulated time stops advancing, and the driver kills an attempt process that outlived its bound; both read the operating system’s monotonic clockNothing a scenario claims: they decide only when a run fails, never how a simulated request proceeds
The integration-test harness’s port pool and the benchmark driverThey reserve free ports from the operating system for real node processes and containers, synchronously and before anything binds them, with the standard library’s blocking listenerNothing a check claims; the ports carry the traffic of real processes

A real primitive never carries the protocol under test, chooses its branches, supplies its wakeups or establishes an ordering an assertion relies on. A permission covers one Rust file the boundary governs and lists real items of the unmodeled path; a permission for a directory, a glob, a file outside the governed sources, or an item the path does not have is rejected, and so is a permission or a listed item nothing uses.

A selected atomic belongs to the model execution that constructs it, so it never lives in a static. A static is constructed once per process and would carry its state from one execution into the next, and Loom’s atomics have no const constructor, so a crate that declares one does not build with Loom at all. Process-wide state lives on the owner whose lifetime it has instead: a node’s session service owns the draws its subscriptions sample with, and a node’s Raft network owns the identities of the snapshot transfers it sends. A count a unit test reads is kept per thread, and state that must outlive every test and model is a real atomic under a permission, which may live in a static.

The rule binds the boundary itself. The boundary names every backend, so the import rules do not read its sources, but a static it declares holds only real atomics, named through crate::unmodeled::sync::atomic or the standard library, and the state it builds lazily for the life of the process is real too. The deadlock detector’s process-wide state is that kind: whether this process claimed its installation, whether order checking is on, and the order history its registry keeps are real atomics. One mechanism the detector reaches is shared rather than owned: its report queue is the ReportHandoff that Shuttle checks, whose refusal counter must stay a selected atomic for the check to explore it. The detector holds that queue in a static only in a deloxide build, which selects the standard library’s atomics and compiles no model, so no model execution ever constructs or outlives it.

Authored Source And Manifest Scope

just validate-primitive-boundary rejects every other path to a governed family however it is spelled: a direct, renamed, grouped or glob import, a fully qualified path, an attribute, a renamed crate, an imported sync, time or net module, or a path in a macro body or an inactive cfg branch. That covers shared ownership through triomphe or the standard library’s sync, the futures crates’ channels, locks, executors, waker registration, select! and abort handles, Tokio’s time and net modules, Turmoil’s net, Deloxide’s locks and detector, and the standard library’s Instant and sockets. Each rejection names the approved path; tokio::net::lookup_host and the ToSocketAddrs traits name the node’s resolver instead. It also rejects an unmodeled use without its permission, a stale or misplaced permission, a dependency on a library whose family the boundary selects from any package but the boundary, a manifest entry that renames a governed crate, task::spawn_cpu in any file but the executor’s worker pools, task::spawn_blocking in any file but those pools and the owners a blocking permission declares, task::block_in_place in any file but the owners a blocking permission declares, and a blocking permission that lists an item its file no longer names. The source and manifest rules read every tracked file and every new file Git does not ignore, whether or not the workspace lists its package: the isolated analysis workspace under tools/nervix-lint, with its driver, fixtures and fixture macros, is authored source like any crate. What a build wrote is not: Cargo tags each build directory it creates with a CACHEDIR.TAG, and the check reads nothing below a tagged directory, wherever it is nested. Turmoil is a runner as well as the network the boundary selects, so beside the boundary, a package whose harness drives a simulation may depend on it, only as an optional dependency its own turmoil feature enables. Deloxide is the boundary’s alone: no other package depends on it, and the boundary depends on it only optionally, so no ordinary graph contains it.

An execution mode is a feature of the boundary, never a global cfg: --cfg loom would reach every crate of a build, and Tokio and other dependencies read the same names. The check rejects a bare loom, shuttle, turmoil or deloxide in a cfg predicate, and --cfg with any of those names in any justfile recipe, Cargo configuration, workflow or build script. Tokio’s unstable runtime controls belong to the Turmoil build: the check rejects --cfg tokio_unstable in any justfile recipe but a Turmoil one, and in Cargo configuration, a workflow or a build script.

The analysis cfg of the source contracts, nervix_lint, is tooling only and selects nothing. Only the analysis driver sets it, for the crates it analyzes, and the analysis must read the code a product build compiles. The check rejects --cfg nervix_lint in any justfile recipe, Cargo configuration, workflow or build script, a cfg or cfg! predicate that names it, and a cfg_attr under it that holds anything but nervix:: contracts and lint levels, such as a path, a derive or a nested cfg. An annotation hides nothing from the other rules: a governed path is rejected in the code an annotation gates as it is anywhere else.

The check rejects a static, including one a thread_local! declares, whose declared type names a selected atomic, directly or through a wrapper, an array, a reference, a module path or a local type alias, and a static or const initializer, const fn or const block that constructs one. It applies this rule, and only this one, to the boundary’s own sources as well, where crate::sync::atomic names a selected atomic and crate::unmodeled::sync::atomic and the standard library’s and core’s atomics name real ones. It reads declared types and constructions, so a struct holding an atomic that a static builds lazily is left to review.

Guest code is outside the source rules: the WASM guest SDK and every guest library built on it are compiled into a user’s WASM guest, a single-threaded program in the host’s sandbox where no mode exists and no Nervix process runs, and a user’s guest takes Arrow’s standard references without the boundary. The rules still read their manifests. Tokio’s I/O traits, filesystem, process and signal modules are real in every mode, and so are the pure polling combinators of Tokio, pin! and join!, and of the futures crates, which poll futures without synchronizing anything of their own. The browser’s event loop, its executor reached through wasm-bindgen-futures and Leptos and its timers through web-sys, exists only in a target no mode runs. The cells of std::cell are confined to one thread. None of these is a family the boundary selects.

Builds, Modes And Product Binaries

Each mode is its own build invocation, with explicit features, because Cargo unifies the features of one invocation:

ModeLintTestsCoverage
Ordinaryjust lint: every package in ordinary mode, the server, the client, the formatter, the browser console and the wire crate for the browserjust test, just test-primitives-ordinaryjust test-coverage; ordinary extras and test-primitives-ordinary through just coverage-native-extras
Shuttlejust cargo-clippy-shuttle: every Shuttle library, and each package just test-shuttle explores in test mode, where its checks are compiledjust test-shuttle [filter], just test-shuttle-replay-check, just test-primitives-shuttlejust coverage-native-extras test-shuttle test-primitives-shuttle; just coverage-shuttle <output> [filter]
Loomjust cargo-clippy-loomjust test-loom [filter], just test-loom-qualification, just test-primitives-loomjust coverage-native-extras test-loom test-primitives-loom; just coverage-loom <output> [filter]
TurmoilThe Turmoil targets of just cargo-clippyjust test-turmoil, just test-turmoil-replay-check, just test-primitives-turmoilThe canonical suite through just coverage-native-extras test-turmoil, with separate CI artifacts and advisory Turmoil patch feedback; just coverage-turmoil <output> copies a complete report; native conformance through just coverage-native-extras test-primitives-turmoil
Deloxidejust cargo-clippy-deloxide: every diagnostic selection of the boundary, reporting owner, paced driver and server, including probes and scenariosjust test-deloxide, just test-deloxide-order, just test-deloxide-stress, just test-deloxide-stress-restore, just test-primitives-deloxide and diagnostic compile checks in just test-primitives-compileNative conformance through just coverage-native-extras test-primitives-deloxide; just coverage-deadlock collects the test-deadlock-evidence-order and ordinary test-deadlock-report producers in separate builds with canonical completion records and separately flagged diagnostic-evidence coverage; full diagnostic workloads retain separate evidence

Model coverage uses the canonical inventories and runners, including per-test process isolation, Shuttle random/PCT exploration and nondeterminism checking, and Loom InvariantIds and exhaustive completion. models.json retains discovered/selected/executed/completed counts and exact check identities; the coverage collector requires matching complete evidence before exporting LCOV. Loom weakening qualification runs independently outside instrumentation. Coverage bookkeeping runs in the command processes and supplies no ordering, wakeups or branches inside a model. The per-mode build directories and fresh attempt directories prevent one mode from replacing another’s executable or counters. Native source-line execution complements each model’s claim; it does not broaden the ordering or interleaving guarantee. Mode reports remain separate from ordinary coverage and its advisory CRAP report. See native extra coverage for collection, artifacts, filtering and replay commands.

The integrated ordinary cost probe is just bench-primitives. It measures the boundary’s atomic access, ArcSwap publication, an uncontended blocking mutex, and shared-reference clone/drop without a model or diagnostic build’s scheduling points. The production relay and admitted-work benches measure their owners, and just bench-task-handles retains timing and allocation samples. Compare uninstrumented builds on one host with the same profile and load; a model run or coverage build cannot serve as a throughput baseline. The integrated qualification procedure keeps those measurements beside revision-bound model and external fault evidence.

just validate-execution-mode-dependencies resolves the normal dependency graph of the workspace and of every package on its own, the way a consumer builds it, with default features and without them, and rejects Loom, Shuttle, a Shuttle wrapper, Turmoil or Deloxide in any of them, and an execution mode, the diagnostic one included, or the test-util capability enabled on the boundary. It also keeps the portable graphs portable: the vocabulary crate, and the browser console and the wire crate for the browser’s target, contain no async runtime or network library and never enable the boundary’s native capability. just validate-execution-mode-conflicts requires the combined-mode diagnostic, including for modes two dependencies select separately, the boundary’s own diagnostic for a mode or the native capability requested for the browser’s target and for the diagnostic mode requested without the native capability it tracks, and the product-binary diagnostics.

A build that selects a modeled mode is a test artifact. Every binary Nervix ships, the server, the CLI and the Plexus formatter, declares itself with nervix_primitives::product_binary!, which fails to compile in a build that selects loom, shuttle or turmoil and names the binary and the mode, so no modeled binary can be built, let alone published. A deloxide build is a diagnostic artifact instead, which runs but is never released: it compiles only a binary that declares a diagnostic form, product_binary!("nervix-server", diagnostic), and such a binary installs the deadlock detector at start-up. The server is the one binary that declares it; the CLI and the formatter fail to compile in that build, naming themselves. just validate-primitive-boundary requires the declaration, in either form, in the crate root of every binary the release image’s build compiles, and the release image builds the ordinary server.

Blocking Work Outside The Executor

A node runs its variable-size encoding, decoding, validation, hashing, compilation, program execution and synchronous filesystem or database work through the bounded executor, which admits each job into a CPU or storage class, charges its memory, and gives it the cancellation it checks between its bounded units. Operator-supplied code the node cannot bound, a Roto UDF call or a JAQ transformation, takes the extension class, so a program that never returns holds only that class’s workers. Password hashing takes the credentials class and its own budget, so anyone who can reach a listener spends only those, and saturated data, extension or bulk work never delays a login. The executor’s storage workers are the one built-in owner of the runtime’s blocking pool, and no file owns task::block_in_place, which blocks the runtime worker thread that calls it.

Everything else that blocks a thread is a declared owner in crates/primitives/blocking-permissions.toml, which names the file, the blocking items the owner names there by their paths below nervix_primitives, the owner, why it stays outside the executor, and what bounds its work instead. A permission declares its file for exactly the items it lists, so a file whose owners block in different ways, such as the CLI’s, has a permission for each:

OwnerBlocks throughWhy it stays outside the executorWhat bounds it
The resolver’s configuration load in nervix-dnstask::spawn_blockingA node reads its resolver configuration before it builds its executor, and the Rust client and the CLI build the same resolver without oneTwo small files, read once for each resolver built
The Kafka sink’s final producer-queue drain and the Kafka source’s partition inspectiontask::spawn_blockinglibrdkafka parks its calling thread until the broker answers; the call waits on the network and computes nothing, so it would hold a CPU or storage worker idleThe host’s physical flush deadline, and a five-second metadata timeout per request
The Rust client’s backup download and restore upload, and the CLI’s event printertask::spawn_blockingA client tool is not a node and has no executorOne rename of a written archive, one sequential read of an archive, and one printer thread for the life of the command
The CLI’s completion prompttask::block_in_placeA client tool is not a node and has no executor; its line editor asks for completions synchronously on the thread that runs the CLI’s main task, so the prompt waits there for the completion while the runtime’s workers keep the session runningOne completion at a time, only while the operator waits at the prompt; each page of suggestions ends by the client’s retry deadline, and a repeated continuation ends the completion
The benchmark driver and the scenario harnesstask::spawn_blockingA harness is not a node; it waits on brokers, databases, probe threads and the C binding from its own processEach call’s own timeout, or the probe or thread it joins

The boundary check rejects either item in a file no permission lists it for, and a listed item its file no longer names.

Thread Creation Outside The Executor

Starting a thread can bypass the executor’s admission, memory charges and cancellation just as blocking work can. Every caller of thread::spawn, thread::Builder, thread::scope, thread::spawn_detached or unmodeled::thread::Builder therefore declares its exact file and items in the same crates/primitives/blocking-permissions.toml. The declaration states its owner, why the work stays outside the executor, and what bounds the work, thread count and lifetime. No caller outside the primitive crate has a built-in permission. Naming the builder includes its ordinary and scoped spawn methods; a glob names every confined item it brings into scope. Reimported module aliases retain the same confinement regardless of import order. A glob over the boundary root is rejected; name its modules and items explicitly so confined and unmodeled paths remain visible.

OwnerThread itemsWork, count and lifetime bound
Process termination supervisionthread::spawn_detachedTwo supervisors per process, parked between signals or deadline changes, plus at most one forced-exit watchdog after the single exit claim; supervisors end with the process, and the watchdog waits only the forced-exit report budget
WASM epoch driverunmodeled::thread::BuilderOne thread per WASM runtime; one sleep and one engine epoch increment per configured tick; it ends on its next wake after the stop flag is set or the weak stop reference can no longer upgrade
Diagnostic finding recorderthread::spawn_detachedAt most one watchdog for the process’s one finding; it exits the process after the ten-second recording budget
Shared Loom participant launcherthread::BuilderLoom’s five slots, including coordinator and body, the branch limit and runner watchdog; participants end within one model execution
Benchmark load driverthread::Builder, thread::scopeOne joined summary poller per drain owner, and one scoped watermark query per configured partition, each with the request timeout within the driver deadline
Turmoil scenario runnerthread::BuilderOne scheduler per disposable attempt; physical run and cleanup deadlines, a normal join, and a failed process when either deadline is missed
Plexus completion walkthread::Builder, thread::scopeAt most the configured jobs per finite frontier; every worker is joined before the next level
Public C binding probes and external Kafka memberthread::spawn, thread::scope, thread::Builder as listed for each fileFixed probe participants joined within their bounded call and cancellation waits; one Kafka poller per member, stopped and joined when the member drops

Unit tests and Loom and Shuttle checks declare every file and its exact thread items too. Their permissions describe finite participants for each case, joins or model termination, and the registered exploration bounds where applicable. Diagnostic cycle probes instead use disposable processes and a parent watchdog, since their tested cycle intentionally cannot join. These declarations do not exempt a directory or evaluate away cfg(test), a model-only module, an inactive branch or a macro body. A new item needs its declaration, and a listed item its file no longer names fails as stale. A file whose product owner and tests use different items names both.

The real epoch builder also keeps its independent unmodeled permission, with its reason and verification limit. That permission does not supply the work and lifetime bound and cannot alone authorize thread creation. The ordinary re-exports and every modeled thread operation retain their existing execution semantics; this confinement changes source validation only.

Verification Contracts

The boundary and its callers have separate proof obligations. Choose evidence for the fact being claimed; a successful build, an executed test, a completed model search and a source coverage report establish different things.

QuestionOwner and entry pointWhat a passing result establishes
Did authored code obtain a governed family through the boundary, with the required permission?just validate-primitive-boundarySource and manifest provenance, including inactive configurations, aliases and authored macro fragments, within the check’s documented structural scope
Do ordinary and portable dependency graphs contain only their permitted capabilities?just validate-execution-mode-dependenciesThe resolved Cargo graphs inspected for the workspace, individual packages and browser targets
Do incompatible modes, unsupported targets and product binaries fail with the intended diagnostic?just validate-execution-mode-conflictsThe actual feature/target combinations built by each rejection probe, including transitive mode selection
Does one current Rust API accept its supported use and reject a forbidden operation?Paired compiling and compile_fail Rustdoc examples beside that API; just test-primitives-compile and just test-capability-docsThose snippets compile or fail in the configuration used for the doctest
Do resolved architecture diagnostics recognize their semantic facts and narrow exceptions?just test-typed-ratchet, with compiler contractsAllowed/rejected fixtures, precise diagnostics and spans, and invalid, unfulfilled and widened expectations in the declared compiler matrix
Does an adapter retain its ordinary backend’s contract?just test-primitives, once per execution modeThe selected conformance and race scripts actually complete under each backend
Is a caller’s interleaving or memory-ordering invariant exercised?Registered production-owner Shuttle checks or Loom models through just test-shuttle or just test-loomThe reported completed search and bounds for that invariant, with the model’s documented visibility limits
Does an integrated operation behave correctly?Public Cucumber, the supported Turmoil suite, or immutable-image Chaos under their owning chaptersThe public operations and fault cases actually exercised, within that harness’s scope
Did tracked real blocking locks deadlock in a diagnostic workload?The four canonical Deloxide selectionsQualifying detector evidence for the acquired locks and processes that completed; untracked paths remain outside the claim

A compile_fail example belongs beside the API that owns the restriction and is paired with a compiling example using the current imports and supported signature. An example that fails because its imports no longer resolve does not establish the intended rejection. Doctests use the configuration of their containing build; they cannot choose a different Cargo dependency graph for each snippet. Graph isolation, feature conflicts and browser capability rejection therefore keep their dedicated Cargo probes. Compiler fixtures keep their semantic diagnostic assertions. Actual model execution keeps its own inventory and completion records. None is replaced by a collection of compile failures.

Caller Models And Failure Evidence

A caller’s Shuttle check drives its production owner with scheduler-visible coordination and registers in crates/model-harness/shuttle-inventory.toml. Registration happens before the deciding read when waiting for notification. A memory-ordering claim uses the synchronous production owner under Loom, names its InvariantId, registers in crates/model-harness/loom-inventory.toml, and has a registered weakening that must fail and replay. Shared references, opaque dependencies, async I/O and real primitives never silently expand either model’s claim. Deterministic Concurrency Verification owns exact exploration, registration and replay requirements.

The canonical runners distinguish discovery, selection, execution and completion. A missing, ignored, unregistered or incomplete required check fails; selecting no check fails. Preserve the failure’s actual source, inputs, schedule or checkpoint, bounds, toolchain and output before changing its owner. A later passing run retains its own source and does not explain an earlier failure. Shuttle replay reproduces a recorded schedule; Loom qualification demonstrates dependence on the registered ordering; Turmoil replay reproduces its recorded simulation; external Chaos replays its action plan without claiming deterministic operating-system scheduling.

Deloxide installs once per diagnostic process before any tracked lock or runtime worker exists. Its applicability and evidence contract requires active, order, stress and stress-restore selections when tracked synchronization or a lifecycle path using it changes. The owner inventory records which workloads reach each lock site and which paths remain unexercised. Async synchronization, atomic publication and network waits need their own applicable evidence. A clean detector run does not establish their safety.

CI, Coverage And Toolchains

just validate includes primitive provenance, dependency and conflict checks beside the resolved architecture diagnostics. just test includes ordinary tests, public scenarios and paired API doctests. Primitive conformance and the complete model selections also have dedicated recipes; the development guide owns the full command catalog. A change affecting Shuttle, Loom or Turmoil carries the corresponding PR label so its dedicated CI jobs run. A change covered by the Deloxide applicability rule carries deloxide; membership, failover, recovery or delivery behavior exercised by external Chaos carries chaos. Labels enable evidence and do not replace the local checks or source-owned applicability review.

Registered Bolero properties use the same production path and complete assertion in ordinary randomized/corpus execution and sanitizer fuzzing. Ordinary execution is required when the Bolero workflow runs. Sanitizer CI requires a pull request carrying fuzz; an unlabeled, scheduled or manually dispatched run expects a sanitizer skip. Check may exclude the entire reusable Bolero call only after verifying its prose-only selection without fuzz. A skip contributes no fuzz execution or coverage. Property Testing And Fuzzing owns this policy, its inventory and failure accounting.

Use the toolchain files’ declared llvm-tools: stable instrumentation and exports use the stable compiler’s tools, and the isolated architecture analysis and sanitizer campaigns use their own pinned toolchains. Never combine incompatible raw profiles or substitute system LLVM tools for the producing compiler’s tools. Every Rust build, probe, doctest, lint, benchmark and coverage invocation retains the configured kache wrapper, including isolated compiler and qualification builds. A cache or compiler failure is reported at its owner; disabling the wrapper changes neither the requirement nor the validity of the evidence.

Native coverage collection records source-line execution only after its canonical producer completed, beside its completion and model or lane records. Keep ordinary coverage, model coverage, diagnostic coverage and sanitizer campaign evidence distinct. A source line being covered does not prove all interleavings, sufficient memory orderings, or absence of an unobserved deadlock. The CRAP report has advisory scores; its input validity and the independent just ratchet complexity verdict remain mandatory checks. Integrated primitive qualification binds ordinary costs and public/model/fault results to their actual revision and immutable images. Run records, audit registers and measurements belong on the owning task. Architecture documentation states durable guarantees and limits, without turning one passing snapshot into a permanent claim about later source.

Data-Plane Concurrency

Nervix keeps the paths that carry records, batches, remote relay frames, and acknowledgements free from shared coordination that is unrelated to the record being processed. This is the contentionless data-plane rule. It applies after a runtime task has started and its graph, routing, state, metric, and connector handles have been resolved.

Execution Plans describes the revision that installs and publishes those handles before record and batch work begins.

SIMD Kernels defines the pure buffer boundary that folds batch latency and computes admission, arithmetic, and byte masks. Its cached CPU level is process-wide detection under an unmodeled permission; it carries no protocol state. Batch-local reductions preserve this chapter’s ownership and bounded synchronization rules, including one accumulator lock and one wall-clock read per delivery-latency series per batch.

The hot paths are:

  • accepting one record from an ingestor
  • admitting and fanning out one relay batch
  • executing one processor batch for a concrete branch
  • receiving and admitting one remote relay frame
  • creating, sharing, and resolving one acknowledgement

These paths may apply bounded backpressure and preserve required ordering. Their steady-state work must not acquire a Mutex or RwLock, including a read lock, merely to discover configuration, ownership, topology, a service, or a metric series. A read lock still changes shared lock state, competes with writers, and can join an unrelated reader to a writer’s delay.

A shared concurrent map synchronizes on every access, not only on entry(). DashMap::get, contains_key, len and iteration take a shard’s read side, and get_mut, entry, insert, remove, retain and alter take its write side, whether or not the key is present; a borrowed Ref holds its shard until it is dropped. A steady hot path therefore does not look an established value up in a shared map at all, whether by borrowed lookup or by entry(). It retains the handle it resolved when its task, branch, channel, or attempt was created. A shared registry serves the cold paths around that handle: registration and first installation, where entry() gives racing installers a single winner, replacement, teardown, and observers.

Remote ACK And Admission Owners

just bench-remote-owners measures registration, admission, Alive, ordered parking/resume and terminal resolution with actual ACK roots at one and 64 rows per delivery, reports delivery tail latency and relay-memory reclamation, and measures an empty sweep of the positions its deliveries claimed beside the fixed position count. Its authenticated frame probe sends three current Arrow rows, admits them, returns the exact terminal reply and checks retired attempt counts. A separate 5,000-frame peer-owner probe completes while another peer’s protocol guard is continuously held and checks returned permits and memory. These native probes also run through the existing benchmark smoke producer; local debug timings describe their measured build and host load, rather than a production throughput guarantee.

Remote record correlation uses an immutable array of delivery and admission routing positions. Each delivery generation owns its mutable rows under one short synchronous guard; a row’s outcome is independent of the other rows. Each side claims unused positions on demand and returns retired positions to its own bounded free queue, preserving admission room when delivery positions fill. Shutdown seals first claims before closing the queues and visits only positions that were claimed. The silence sweep, which runs once a second on every node, has the same bound: it reads each side’s first-claim count and takes a guard only for the positions below it, so a node that forwards nothing takes none and a sweep’s cost follows the positions its deliveries have used, not the 16,384 positions a node is built with. The counts bound the scan and order nothing: a position’s guard publishes its correlation, and nothing relies on a sweep having observed a claim. A share whose first claim races a sweep is counted from that sweep or from the first one after its registration, so it still fails between the silence timeout and one sweep interval later. Runtime shutdown cancels the sweep task and waits for it. The task returns to its executor after every sweep, because a sweep that overran its interval finds the next tick already due and that tick does not suspend the task, so it observes the cancellation between sweeps and a sweep’s guard count also bounds how long a stopping node waits for it. A diagnostic build pays for every one of those guards: the acquisition-order selection enters Deloxide’s process-wide detector on each tracked acquisition and release, and the nodes of every scenario in one process share that detector, which is why a recurring pass takes guards in proportion to its live work and never to a fixed capacity. Record allocation and receiver batch watcher tasks reserve relay memory before creating their retained rows. A batch task multiplexes row progress and keepalive polls every 100 milliseconds; each row and the single task have fixed charges. The cadence remains shorter than a one-second source ACK timeout while task cardinality stays bounded by admitted batches. A receiver whose relay budget has no room for a batch’s watches holds the unadmitted batch on its own channel lane and reads the budget again every 10 milliseconds; it registers for no release notification and never queues in the budget’s semaphore, whose released permits would otherwise go to the held charge ahead of the relay work that frees it. The batch task returns each watch’s share as that watch ends, through its exclusively owned reservation, so the hold involves no shared state beyond the budget’s own atomic permit count. The registered wire number selects the position, exact generation and row without a concurrent map lookup. The process identity is checked before routing. Generation exhaustion seals the position, and cancellation, timeout, terminal resolution and shutdown compete to take each row once. The ACK tree resolves after the routing guard is released.

Relay services retain the domain drain tracker resolved during construction. Received rows create ACK roots through that handle; they never register or discover the tracker per row. Watchers read the membership writer’s immutable incarnation publication, so registrar retirement ends their charged task lifetime without a gossip lock.

One authenticated peer transport owns its ordinary grant, attempt, channel, watermark and outbound correlation collections. Connections and operation leases retain that owner. Its guard serializes identity and ordering decisions for this peer; there is no node-wide actor or map guard in the recurring protocol. The peer guard precedes the exact record guard, neither crosses an await, and permit release remains possible while runtime borrows an admitted record. Ordered attempt and grant retirement keeps cancellation effects deterministic. Cold binding and membership changes publish routing through CAS. Cluster Interconnect owns the capacities, grace periods and reconciliation deadlines.

Production-owner Shuttle checks cover delayed events against a reused position, registration against shutdown, admission waiter drop against a reply, final silence sweeps against terminal and Alive reports, a first claim against a sweep, a sweep that completes while an unclaimed position’s guard is held, body claim against cancellation, admission against peer ending, and another peer’s progress while one peer holds its guard. The production admission choice has a Loom model for one irreversible verdict; replacing its compare-and-exchange with an overwriting swap must fail and replay. ArcSwap, queues and external semaphore internals remain outside that atomic claim. Registered bounded Bolero sequences check complete verdicts, capacity and generation outcomes. The remote-correlation-lifetimes sequence uses an independent sequential model to predict registration identities and FIFO reuse before calling the production owner. It varies both pools’ capacity, receiver identities, attached and detached batch rows, admission receiver closure, single and bulk admission, Alive and sequenced parking/resume, terminal replies, clear, relay-memory refusal and shutdown. Every operation and every individual silence sweep checks exact root outcomes and progress, drain and handoff counts, admission updates, per-receiver expiry counts, retained memory and the positions ever first-claimed. Stale generations and non-record admission row numbers are also checked through the public routing operations. This is sequential representation evidence; the registered Shuttle checks above own races, and the runtime scenarios own the timed sweep loop and its public delivery behavior. Turmoil covers transport loss, retry, cancellation, reconnect and process restart within its documented scope; public one/three-node branch delivery and producer-restart scenarios cover host ACKs. External immutable-image faults remain separate evidence.

Deloxide applies to the new delivery, peer and record blocking guards and their acquisition order. Both diagnostic selections run the remote ACK scenarios alongside their existing workloads. It does not model publication, async channels, network waits or the atomic verdict; a clean run proves only the tracked acquisitions the diagnostic processes executed. Generated measurements and fault evidence belong on the task, and a failed required attempt remains recorded.

Leading empty consensus append probes use management capacity. One task owns their replies and the transition to an ordered data stream; subsequent empty commit updates stay on that stream. Rejected probes end the input before data admission. This transition adds no shared map, lock or memory-ordering claim. Native real-transport and public catch-up checks cover it; Deloxide does not track these async network waits.

Consensus append setup consumes its caller’s first request before opening the transport. The caller owns the async retry and cancellation gate; the adapter retains and submits that request once, without adding a lock or retry timer. Native admission and ended-input regressions exercise this boundary, and the public three-node scenario checks paced failed setup while the surviving quorum commits and the returning voter catches up. The test probe counts attempts and successful opens in its existing blocking map. Deloxide does not track the request-stream or network waits; the applicable Turmoil suite and real-process Chaos faults retain their separate claims.

Relay, Transport And Source Lifetimes

A concrete producer retains its branch channel handle. The relay publishes branch membership in a persistent immutable table; concurrent first binding selects one allocation with CAS. Each branch publishes an owner/consumer routing generation, whose cancellation parent fences ingress and outbound slots, including candidates created during replacement. Eviction cancels the exact branch before withdrawing its allocation. An independently expiring receiver branch permits a still-live producer to bind a fresh channel after invalidation. The producer’s own ending moves it to Ended and prevents rebinding. Domain terminal teardown cancels the relay publication itself.

Channel gates serialize encode, sequence assignment and dispatch for that channel only. Gate waits select cancellation, and transport admission retains its existing physical deadline. They do not serialize unrelated branches or peers. A subscription snapshot selects only live advertised peers, fenced by incarnation and interest version. Equal advertisements retain channels; changed selection cancels the preceding subscription generation. Branch and destination registration copy only a persistent changed-key path. Tables are never rebuilt for an established batch.

Transport targets publish immutable fixed pool arrays. A slot atomically claims one reconnect worker for its lifetime and publishes its authenticated connection through ArcSwapOption. Leasing reads these publications, scans the fixed class slots and retains the selected connection. The connection registry serves registration, exact retirement and observers. Endpoint, peer or TLS replacement cancels slots before publishing replacement handles; a changed DNS answer affects only the next dial. Cluster Interconnect owns quotas, deadlines and delivery rules.

A fresh intake selects its domain publisher from an immutable persistent table. Executing tasks retain the selected publisher and observe complete snapshots. Domain removal withdraws its table entry; terminal shutdown clears the table after ending executions. Buffered error routes similarly send through the worker retained by the bound plan, with no failure-path discovery acquisition.

Source polling writes a retained instance readiness scalar. Starting and ready may alternate; retired is final, so a late poll or drop cannot change a replacement instance’s readiness. These scalar transitions and worker claims use relaxed atomic operations and publish no other data. Shuttle checks production binding, refresh, retirement and polling races. ArcSwap internals remain opaque; this delivery adds no cross-location memory-ordering claim requiring a new Loom weakening. Existing memory-ordering claims remain with their owning models. Deloxide tracks the existing sequence and route-task blocking locks; it does not track ArcSwap, scalar atomics or async channel ordering gates.

just bench-retained-channels measures published versus retained branch selection, established stream leasing and bounded branch churn. Its ordinary native bodies also run in bench-smoke, whose shared native coverage producer prepares browser, guest and runtime dependencies before instrumentation. The measured bodies retain the usual allocation assertions; their timing output is workload evidence, not a portable latency threshold.

The resulting design uses four forms of ownership:

  • Reconfigurable read-mostly state is built privately and published by atomically replacing one immutable reference.
  • A task resolves stable services, state authorities, slots, and metric series when it starts and retains those handles.
  • Scalar progress and counts use atomics with an ordering chosen for the contract they enforce.
  • State that changes for every row or batch belongs exclusively to one task, branch, delivery channel, or source attempt. That owner mutates ordinary collections directly: a task-local HashMap, BTreeMap, IndexMap or JSON map and its entry API are not synchronization, and exclusive ownership does not pin the task to an operating-system thread. When other tasks need to observe such state, the owner publishes an immutable value of it; observers never reach into the owner’s collection.

A shared concurrent map is justified only for state that several owners register into or that observers read across owners, and only while its accesses stay on those cold paths. Hot-path synchronization that remains is an ordering fence or bounded protocol this chapter names, with the exact key that scopes it and the capacity or deadline that bounds its wait. The concurrent map inventory records every concurrent map on the data plane and its neighbours, how often each is reached, and the disposition of every map that is still reached on a record, batch, remote-frame, acknowledgement or steady-poll path. Until its named repair lands, such a site is debt, not accepted design.

The Data Plane chapter defines payload, persistence, branch, and ACK semantics. This chapter owns the concurrency contract for executing them. The Cluster Interconnect, Domain Clock, and Shutdown And Recovery chapters remain authoritative for their respective distributed and lifecycle protocols.

Publication Boundaries

An atomic publication is a complete immutable value. A writer derives the next value without changing the published one, then replaces the reference once. A reader keeps the reference it loaded for as long as it needs it and therefore observes either the complete preceding value or the complete replacement. It never observes fields being changed in place.

Publication is a node-local visibility mechanism, not a durability boundary. The control plane, runtime-state store, or gossip protocol still owns the source of truth. Runtime publication makes one already-decided revision available to the data plane without putting its readers behind the writer.

StatePublication owner and scopeReader contract
Domain routing snapshotEach domain retains one stable publication handle across execution rebuilds. Schedule application stages and replaces the relay services, schemas, branch declarations, materialized-state ownership, lookups, UDFs, codecs, signaling protocols, and the complete map of bound processor plans together. Each plan carries a typed identity and prepared VM and WASM artifacts for one installed node revision.A task sees the old routing revision or the new routing revision, never a mixture of their fields. Long-lived tasks use a local pointer cache instead of returning to the domain execution registry per batch. Existing processor branches compare typed plan identities between batches; new branches resolve their template from the same published map.
Ingestor and reingestor programsSchedule application installs the domain’s decision-layer entrypoint plans inside its complete typed execution revision. An ingestor start binds its node filter and routes against the staged routing revision into one immutable route set before its source opens. A reingestor start prepares the branched entrypoints of its routes and binds each input’s source filter, its node filter and its routes before any entrypoint or input task starts. A running relay registers the consumer of a swapped-in input only after that.Every source instance, endpoint request and ingest group of one ingestor shares the same bound route allocation, and a reingestor input task owns its bound programs. Neither compiles a program on the hot path nor returns to the domain execution registry per batch. A reingestor start that fails leaves no entrypoint, task or registered consumer behind, so a relay never refuses attached delivery for a consumer that has no receiver.
Emitter execution plansThe decision layer prepares the domain’s typed sink and ordered source edges with lowered expressions from one schedule revision; schedule application installs them in that revision. Startup, swaps and relocation bind a selected plan to local mounts, schemas and UDFs; relay inputs are registered during those transitions. Remote consumer edges come from the same plans.An emitter task owns its bound programs and sink configuration for its lifetime. Per-batch work does not read the schedule or client Models; sink retries reuse the typed configuration. A dynamic flush change sends only its new flush policy to the running task.
Message-error route plansThe registry selects each DLQ route from the committed schedule. Domain installation binds its schemas, branch declaration, flush contract, relay target and SET program before tasks start. Schedule application publishes the complete bound route map with the routing state of one typed revision.A failed record looks up its prepared route in the task’s retained immutable routing snapshot, which publishes the bound plans together with the routing revision, before running its VM program or delivery. A buffered route compares the bound plan allocation with its running delivery task; a replacement drains the preceding task and starts a task with the new target and cadence.
Node identity and remote dispatcherThe node runtime publishes this once after cluster join, when the authenticated interconnect and process incarnation are known. Relay boundaries created afterwards retain the same dispatcher handle.Readers borrow the stable node identity, incarnation, transport, admission service, and ACK registry without a write-once lock or repeated name allocation.
Relay owner stateEach relay boundary publishes its scheduled owner, installed owner buffer, remote runtime-consumer set, and immutable branch-reset gate set. Schedule and relay lifecycle operations replace these values at their cutover points.A batch borrows the current owner and buffer, then takes permits only from reset gates whose typed scope selects its branch. Multi-step ownership changes use the whole-relay dispatch gate described below so teardown cannot race an admitted dispatch.
Relay branch presenceThe relay’s owner task publishes the complete membership of the concrete branches it holds, and whether it admitted unbranched work, when that membership changes: a branch appears, is evicted or expires, or the owner starts or stops. The relay’s state placement keeps the same presence across execution rebuilds. See Relay branch presence.DESCRIBE RELAY ... WHERE, materialized reads, and the console’s dataflow graph load one membership without a lock. A batch for a branch the owner already holds publishes nothing.
Subscription interestThe cluster live-state watcher rebuilds an immutable index from domain and relay to interested node incarnations and advertisement versions whenever gossip changes.A relay owner performs borrowed lookups in one published index. It neither formats gossip keys nor waits on the gossip mutex per batch. Subscription creation waits until every live node has observed the exact subscriber incarnation and at least the current advertisement version before reporting success.
Clock installationEach domain-clock lifecycle on each node publishes the complete missing, stopped, uninstalled, unpaced, or paced installation.A read validates its bound lifecycle generation against one installation, then advances that installation’s nondecreasing timestamp watermark atomically. A same-generation replacement retains the watermark; a different generation cannot be clamped by a stale reader.
Accepted clock progressEach runtime domain publishes its newest accepted generation, tick id, logical boundary, and authority UTC observation through a watch.The watch serializes comparison and replacement, so concurrent progress deliveries cannot regress the id or its authority UTC observation. Generation changes and stops publish absence. A session observer subscribes before reading, retains a sender for as long as its attachment lives, so removing the domain wakes it instead of closing its wait, and uses an execution snapshot only to add its serving node’s logical reading to a tick frame.
Committed domain installationEach node’s runtime counts its installations of the committed domain states through a watch. An installation advances the count only after it has inserted, updated, or removed every domain.A session attach waits for the first count before it looks up a domain, so a node that is still starting never refuses a domain the cluster has. Because the count advances after the domains are in place, the wait never releases before they are observable.
Runtime-state assignmentEach state placement publishes one packed atomic binding containing its generation and capability. Replication roles are a separate immutable published snapshot.A per-message operation admits itself, compares the exact binding it was granted, and proceeds only while that generation still grants the required capability. It never takes the assignment barrier.
Ingestor quiesce decisionEach ingestor publishes the declared and pending modes, active causes, source support, and derived intake decision as one value. Concurrent lifecycle changes derive their replacement from the current publication.Polling and per-message intake make one load to decide whether to dispatch, suspend, skip, buffer, drop, or reject. A source host retains its last observed publication across dispatch awaits; its change wait registers before comparing that publication with the current one, so an engagement or release in the gap wakes it. The retained-payload lock is reached only after the published decision selects buffering.
Backup state snapshotBranch lifecycle publications, Kafka offset commits and WASM checkpoints register on a per-domain atomic generation before changing their published state. After the domain is paused and drained, the owner closes that generation with a Release RMW and waits with Acquire loads for registered publishers to leave. It then serializes Kafka positions and branch lifecycle into the runtime state store and opens one database snapshot for those records and durable WASM saves. The closing RMW is in the release sequence that a publisher entering the next generation acquires.A publisher that entered before the cut completes into the captured view; a publisher that enters after it waits for the next generation. An older periodic storage write cannot replace a newer forced publication. The backup reads the database snapshot off the record path and stages owned section bytes. A live backup does not close the generation and reports its weaker cut explicitly. The server’s Shuttle and Loom checks drive this production fence.

| Restore state publication | The control plane stages checkpoints under a replicated authority, then admits one fixed-size storage job, validates a complete generation namespace and durably publishes its active pointer. It holds the consensus applied-state read guard through authority validation, synchronous storage mutation and runtime-handle clearing. The state store reuses its existing latest-snapshot installation mutex for staging, pointer publication and bounded cleanup. Checkpoint jobs retain their selected namespace and validate it under that barrier; entity replacement and purge also select the active namespace while holding the barrier. | A newer applied generation and the release of the start gate require the state-machine write guard. Every node therefore replaces a whole state set before START is available, and stale coordinators cannot mutate a running restored domain. The store retains authority and inventory for exact retry and monotonic rejection. Database readers select pointer, header and chunk values from one snapshot, which retains its complete set across bounded deletion of obsolete namespaces and unreferenced active chunk sets. Publication runs on stopped-domain lifecycle paths. The admitted maintenance loop also follows running checkpoint replacement and purge under the installation barrier; record delivery adds no lock. |

Restore storage qualification includes the store’s cancellation, corruption, snapshot, queued writer, active chunk liveness and many-key cleanup regressions in just test-deloxide, just test-deloxide-order and just test-deloxide-stress, plus the public one-node and three-node @restore_installation workloads, which just test-deloxide-stress-restore also runs under the bounded scheduling disturbance, for large saves, many checkpoints, native lifecycle and Kafka records above the default 32 MiB bulk budget, active chunk replacement and purge, durable publication failure, restart and delayed coordinators. The many-checkpoint three-node case includes one replica per state; its one-MiB saves fit the replication operation’s limit. Deloxide tracks the installation barrier and other boundary blocking locks; Fjall’s internal locks, async authority ordering and memory ordering keep their separate ordinary, Shuttle, Loom and Turmoil checks. Measurements and diagnostic artifacts are delivered to the owning task. The registered large-WASM-handoff diagnostic scenario reaches replica installation and planned handoff with two branch saves above that message limit. It exercises the state store’s tracked installation locks in both Deloxide selections; the bulk stream’s async waits and transport faults remain outside the detector and have their own Turmoil and Cucumber evidence.

Materialized capture shares row carriers under the existing assignment barrier, together with the revision, ownership fence and branch generation. Membership insertion and eviction use that same barrier; ordinary updates of existing branches keep their existing admission protocol. Fresh backup capture admits its bounded row-view metadata before allocation. Archive conversion stages one identity/Arrow group at a time. Native loading independently bounds typed-key and row-view metadata to 8 MiB, charges retained decoded columns to relay memory through installation, and uses bulk memory for one section’s decoding scratch. A cumulative relay admission refusal ends loading instead of waiting while retaining earlier groups. A pinned database reader retains one snapshot and one stored chunk through all section reads. Replica installation checks monotonic revision under the existing exclusive assignment barrier before replacing any entry.

RESUME changes the domain to running at its archived lifecycle in the consensus effect that records complete publication and releases the installation gate. No atomic field or memory ordering in the capture epoch, assignment protocol or relay revision changed. Existing Loom models continue to qualify their ordering; new Shuttle checks qualify branch membership capture, replica revision races and publication before activation. Paused generators release their admitted-work guard after flushing and reacquire it before producing more output.

Native restore conversion retains the immutable verified-description owner and its preparation reservation while an admitted storage job streams the current checkpoint encoding into a quota-owned file. The job owns the disk permit until its file closes or becomes a completed artifact, including cancellation. Entry serialization and 64 KiB writes check cancellation between bounded units. Each bounded artifact read also retains its file and quota in the storage job until it exits. Local and remote installation retain their exact file source and use the existing authority and installation barriers; conversion adds no lock to record paths.

Stored branch capture also retains one immutable database selection, opens only the scheduled checkpoint, and streams it in cancellable 64 KiB units into a quota-owned file. Its admitted read-only mapping and size index remain owned through each bounded conversion job; cancellation does not release their memory or disk permits before that job exits. Keyspace and window codecs convert only the selected archive group. This adds no lock or publication protocol. Existing capture and restore lifecycle locks remain tracked by Deloxide: the stored-branch scenario runs in active and order selections and in the restore stress selection. Deloxide does not observe the mapping, CPU jobs or async admission; ordinary cancellation/refusal owner tests and complete Bolero byte comparisons cover those boundaries. The capture fence’s Shuttle and Loom models retain ownership of its unchanged publication and ordering invariants.

The backup coordinator waits for each node’s admitted-work counters to reach zero, then orders one confirming force-flush generation across the cluster. The generation’s obligations use the same atomic counters as shutdown; a parked REQUIRED WAIT batch stays visible separately and cannot hold the cut open. Rows a window retains park their shares too, and the cut captures them as window state. | Metric series handles | Each relay, node, ingestor, emitter, or concrete branch resolves its label set, internal series, and Prometheus child when its owning task or branch is created. | Recording uses the retained series directly. Counters update atomically; a histogram records through its already-resolved per-series accumulator without a registry lookup, key construction, or map guard. A node input folds a whole batch’s delivery latencies in one kernel pass over the batch’s high-watermark column before recording, so each latency series locks its accumulator and reads the wall clock once per batch rather than once per row, and its Prometheus child takes the batch in one flush. Whether a series was ever observed is an atomic flag set under that lock and read without it. Registration and removal stay on lifecycle paths. | | MQTT broker packet limit | Each MQTT sink’s event-loop task stores the Maximum Packet Size of every CONNACK its client receives, or the broker’s silence about one, as one atomic scalar. A reconnect replaces it. | A publish loads the scalar once per record to reject a packet the broker would refuse before handing it to the client. The value stands alone, so relaxed ordering is its whole contract, and a record published before the first CONNACK is measured only against the protocol’s largest packet. |

An atomic replacement gives consistency for the value it publishes. A transition involving several owners still needs a protocol. Graph and schedule changes use quiescence and relay gates; clock and state operations carry generations; subscription creation uses a visibility handshake. Publication removes read-side contention without weakening those transition contracts.

The control plane classifies each committed schedule change and builds a complete typed execution revision before handing it to the runtime. The revision keeps placement, state identity, handoff fingerprint, and all node plans together. Runtime application reads that revision for incremental swaps, relocation, remote consumer edges, and full or passive rebuilds. A failed application keeps the last successfully applied schedule as the predecessor for its retry; a stale revision cannot replace that predecessor. Each planned domain also carries that predecessor’s source identity. If an earlier attempt left a domain partly or wholly installed at another revision, the runtime rebuilds it instead of applying a delta against the wrong predecessor.

Schedule application prepares every changed local processor plan before publication. A preparation failure leaves the preceding routing revision observable. Publication swaps the complete typed routing snapshot with its installed execution revision. Unchanged processor specifications, schema fingerprints and resolved branch contracts keep their exact plan allocation, including prepared programs and compiled WASM modules.

Retained Task Dependencies

A source or sink retains one failure publication containing both its safe error and optional retry. Success reads that slot and clears it only when it holds a failure. Identical failure reports keep the same immutable observation. Reporting an error without a new retry keeps the active retry, which remains visible as publishing work to drains even when the emitter buffer is empty. DESCRIBE loads error, backoff and remaining wait from one observation. Emitter confirmation guards retain one counter resolved at startup, and task teardown removes its registry interest.

Every pooled Redis or SQL sink registers a wait slot once. The connector serializes its connection borrows. A ready borrow writes nothing; the first Pending poll publishes its wait and owns a guard that clears it on success, error or cancellation. Sink destruction removes only its own registry slot, so a replacement sink’s slot survives an earlier sink’s teardown.

Generators retain the domain acknowledgement tracker. Ingest executions, groups and endpoint bindings retain the same domain/ingestor tracker pair. Task and branch metric handles retain the exact placement’s dirty mark. Client batch outcome counters, quiesced payload counters/gauges and subscription drop counters retain their Prometheus children, including every declared outcome label combination, so recording resolves no label set.

Subscription generations also retain the node’s executor and pass it directly to predicate evaluation. Executor admission continues to own worker and memory bounds for extension calls. The admission benchmark also constructs the current emitter context, resolving its routing, metrics mark, status publication and confirmation counter before the timed encoding runs.

Each handoff entity has one immutable coordination-owner set and notification owner. Tasks retain that entity slot across repeated freezes. A watch registers before reading the publication; engagement and release replace it before notifying. Concurrent owners derive their replacement with the primitive publication boundary’s reference update. Domain removal drops registry interest.

Domain-clock publication contains lifecycle pause, generation and start-point observations alongside its installation. Ingestion reads admission and time from that one publication; Kafka offset polls read its generation; generators, processors, materialized relay tasks and subscriptions use the clock or lifecycle handle bound at startup. Each WASM state retains its entity assignment slot, which publishes state identity and checkpoint execution/replica owners together. A generation replacement or removal therefore fences a callback without a runtime registry lookup.

Clock authority reconciliation binds a producer to its locally installed mapping, generation and authority fence before starting it. Each node’s domain clock owner installs a committed authority-only reassignment ahead of graph installation. Each installed domain records the committed revision of its lifecycle, which keeps an older graph installation from restoring a superseded fence. A graph installation held on a departed node therefore stalls neither the replacement authority nor a receiver. Each remote delivery loop starts after its receiver advertises installation of that revision’s domain clocks. An obsolete producer still retires before its successor starts, and a leader proposes an authority that did not commit again on a fixed interval instead of waiting for the next state change. The takeover Cucumber scenarios in domain_clock_contract.feature exercise this ownership path in the active, order and lifecycle stress Deloxide selections; their async installation, watch and transport waits remain outside tracked-lock detection.

Force-flush participants retain a private idle, available or closed scalar. Idle and already-claimed polls acquire no coordinator mutex. An available indication enters the mutex, which remains the sole authority for generations and claims. The coordinator visits participants in participant-ID order when publishing readiness, so deterministic replay sees the same sequence of scheduling points. Publication of a request precedes the watch wakeup; claim release makes the obligation available again. The relaxed hint publishes no data from another location. Watch synchronization and ArcSwap ordering belong to opaque primitive dependencies; this change introduces no Nervix cross-location memory-ordering claim. Production Shuttle checks explore generation publication, claims, release and close, and the idle regression counts actual coordinator acquisitions.

The status and freeze transition contracts have registered Bolero sequence properties. Status observers and freeze registration/release have production-owner Shuttle checks. just bench-task-handles records raw same-host timing and allocated-byte samples for the repaired paths. The retained task handle measurements record the measured operations, host, samples and limits. The concurrent map inventory records their lifecycle registration, observation and teardown sites.

Server endpoint intake

EndpointIntakeRoutes owns one immutable host-and-path table containing each domain’s configured endpoint definitions and the source lifetimes bound to them. Domain install or teardown and source bind or unbind derive a replacement from the current publication with ArcSwap::rcu. Concurrent writers retry against the latest complete table, preserving unrelated domains and sources. A passive installation publishes no endpoint definitions. The listening socket remains present on every node independently of this table.

An HTTP request resolves one route using borrowed host and path keys; only uppercase hosts need a normalization allocation. A WebSocket retains the resolved route and its signaling protocol from upgrade through all later frames, including signaling data frames. Readers share the route allocation and borrow its prepared intakes; they do not clone route vectors, programs, codecs, or sender maps and do not return to a concurrent registry for each frame.

Each source lifetime publishes its optional intake through ArcSwapOption. Close, drop, domain replacement, domain teardown, and runtime clear publish absence before withdrawing that lifetime. Retained routes therefore refuse later admission even after a replacement source starts. A request that already loaded a present intake keeps its lease until dispatch finishes. Unbind removes the exact source allocation, so a late close cannot remove a replacement with the same typed identity. Configuration withdrawal removes the route; source withdrawal alone leaves configured metadata available to report unavailable intake. These publications remain volatile data-plane state.

Unit regressions and the registered Bolero operation sequence exercise the production owner and retained lifetimes. Shuttle races whole-domain publication with resolution, unbind with admitted requests and replacement, and domain teardown with retained routes. These checks treat publication operations as opaque scheduling points. Both publications reuse the primitive boundary’s existing implementation: no new memory-ordering protocol is introduced, and Loom does not model their internals. Listener ownership and network primitives retain their existing contracts; Turmoil is outside this owner’s scope. Public scenarios cover one and three nodes; the endpoint Chaos workload belongs to Typed Ratchet 10. The inventory ledger records the routing measurement and verification evidence.

Mutable Execution State

Per-row mutation is kept close to the lane that orders it. A global concurrent map is an index into those lanes, not the owner of their inner state. Once a task has found its lane, later mutation does not repeatedly acquire the registry guard.

Ownership handoff drain before freeze

The runtime closes intake and the affected boundary gates, then requests a force flush and confirms the admission hold. The coordinator can observe pending work and its timeout diagnostics while the installed ingestor routes and processors finish that flush. Each participant’s exact operation drain-status query publishes its ownership freeze only when the local affected scope is drained. Capture requires both the held operation and its own coordination identity’s freeze. The status query retains the operation’s registry guard through publication, so release cannot withdraw its hold between observing drain and publishing capture readiness.

This is lifecycle work: the polls read existing quiesce and acknowledgement registries, and hot paths continue to retain their counters and freeze publications. The existing force-flush and quiesce checks cover completion visibility; freeze checks cover publication and wakeup order. The ownership drain reads force-flush obligations before admitted-work counts. Acquiring a completed flush therefore makes a parked message that the flush resumed visible to that drain. loom_an_ownership_handoff_drain_sees_work_resumed_before_flush_completion exercises the production counter method; its qualification weakens that method’s existing acquire. The handoff publication check races the production freeze decision against the actual participant’s flush completion. The public shutdown regression holds an admitted Kafka route until handoff publishes its flush request, then verifies completed drain and exactly one acknowledged output. The diagnostic lane registers that path in all three selections; async wakeups, transport and deadlines remain outside tracked blocking-lock detection.

Emitter payload assembly

One emitter task owns the released carriers and their flush order. Its batch packer borrows each carrier’s source relay, concrete branch key and ordered metadata while it prepares selected Arrow rows. It clones the Arc-backed Arrow batch once per carrier passed to the packer; no row acquires a lock or increments an Arc reference count for source identity. The open candidate holds at most BATCH MAX MESSAGES prepared members and is sealed on metadata or source changes, a failed member, or the message-count limit. Each encoding attempt uses the codec’s bounded writer under MAX SIZE. The emitter retains original batch and row positions for acknowledgements and errors.

A payload offered to the sink stays in the same task’s buffer until the sink answers for it. The task marks each member row prepared, and the one answer for the payload resolves every member: a row resolves once, so neither a repeated answer nor a later attempt resolves its acknowledgement share again. An HTTP emitter’s prepared request is such a payload with exactly one member, retained by the same owner with its request fields and body bytes, so its answers follow the same rules, and so is an OTEL emitter’s Export request, retained with the protobuf bytes its connector prepared and the rows they carry. No lock guards this state; only the emitter task touches its buffer, and resolving a member is one operation on the lock-free ACK tree. A confirmed payload’s members are acknowledged in the same step that releases the payload, while a rejected payload’s members return to pending until their message errors are delivered, so an attempt its stop deadline cuts short leaves every member either resolved or still owned by the buffer.

Terminal teardown races the whole emitter task against its domain shutdown signal. It can cancel an in-flight connector await after the graph drain budget expires, dropping the task’s volatile payloads and ACK guards together. That cancellation cannot resolve a member as delivered; an acknowledged source retains its own redelivery boundary. Entity-pause swaps do not use this terminal cancellation: the old task must complete its deadline-bounded stop before the new task starts. The Shuttle check over the emitter’s prepared payload and ACK owner races a connector await with the terminal shutdown signal and asserts that dropping an unanswered request leaves its attached source unacknowledged.

Branch processor state

Each concrete branch has one dispatch lane and one mutable processor runtime. The lane admits one batch for that branch and queues later batches, which preserves processor order. Different branches have different runtimes and execute independently. Expiry and eviction first detach a branch from the instance registry and then take ownership of its runtime, so registry access is not nested over branch execution.

Deduplicator keys

A deduplicator keyspace belongs to the task for one concrete branch. That task prunes, reserves, and releases keys directly. When the keyspace changes, the task marks it dirty and periodically publishes an immutable generation whose key allocations are shared with the live keyspace.

Snapshot persistence, replication, ownership handoff, and a replacement branch task read the last published generation. They never read or lock the live keyspace. Publication occurs during the replication cadence, before a handoff checkpoint, and when the branch stops, so snapshot encoding can proceed while the current task continues processing rows.

Window and WASM state

A window and its aggregate accumulators belong to one branch task. The task mutates the live window directly and publishes a complete immutable window generation for persistence, replication, handoff, and restoration. Snapshot encoding reads that generation instead of holding the branch runtime while it serializes. The publication shares the retained input and aggregate-argument Arrow columns through row views. The snapshot task seals those views as bounded Arrow sections on the bulk executor, while a separate bounded typed section carries each group of histogram delayed removals. Encoding never materializes a scalar-field copy of the retained payload. A window whose rows exceed one snapshot section is sealed group by group into quota-owned pieces and published as a segmented checkpoint under the state store’s installation barrier, as a materialized relay’s periodic checkpoint is; the snapshot task holds no lock while it seals, and the barrier is held only by the storage job that writes the segments and the header. Restore opens the sections on the bulk executor and reuses their columns to rebuild exact accumulators and sketch panes.

Evicting a concrete branch resets its retained window rows and aggregate structures before the branch task’s final publication. The published generation is empty, so a later appearance of the same branch key cannot inherit the evicted window or its sketch panes. After the final checkpoint, the owner releases the evicted branch’s in-memory publication. A branch that appears without a restored lifecycle entry also publishes an empty initial window, even if a previous lifetime of its key left a checkpoint behind. A processor task therefore installs every branch its lifecycle names before it dequeues any input, and a restore the bulk executor refuses installs none of them and is attempted again; see Restoring Processor Branches. Stopping a branch for an ownership handoff follows the normal finalization path and publishes its retained window instead. The branch lifecycle and window checkpoint carry the same incarnation, assigned when the concrete branch appears. A restore whose incarnations differ begins with empty window state and marks it for publication, so a delayed checkpoint from the preceding lifetime cannot restore its panes.

A WASM guest instance likewise belongs to one branch task. At the end of every guest callback the task asks the guest to save its computation state and checkpoints the returned buffer: it writes it to stable storage, waits for the replicas the checkpoint names, and only then publishes it as the committed checkpoint a recreated instance restores. The branch runs no further callback until that checkpoint completes or fails. The publications retain the saved buffer rather than copying it for every persistence or replication reader. Input buffered by the guest host and ACK tokens remain execution state and are never included in a guest save. See WASM State And Recovery for the checkpoint’s stages.

Each branch publishes checkpoint revision, boundary, and stage together through one immutable observation. An inspection read samples that publication and replica progress without taking the branch task’s execution lane or advancing durability. A failure before guest state was captured is an explicit observation without a new revision; any earlier committed checkpoint remains the restore source. The published observation contains no guest bytes.

Every branch save is addressed by the guest-state generation in the committed schedule. Forced recovery publishes a new generation with the replacement schedule, so a late save, replica installation, or recovered checkpoint from the generation it replaced cannot address current state. The state store performs durable guest-state writes on its storage workers; the async worker that owns the branch never performs that storage operation synchronously.

A coordinated reset enters the selected branch task through its existing supervisor command lane. That lane is the serialization point with input callbacks, timeout callbacks, branch eviction, and task replacement. Preparation takes ownership of the selected task, reaches its stop boundary, and keeps the handoff only until either pre-publication abort restores it or generation publication makes it obsolete. Sibling branch tasks keep their own lanes and continue running. After publication, the supervisor creates fresh tasks and waits for their initial checkpoints before it drops the retained old tasks and accepts completion.

Checkpoint replication

Every replicated runtime state owns its replication: the domain offsets of a Kafka ingestor, the state of a deduplicator, window or WASM processor branch, a materialized relay, an entity’s branch-aggregated metrics, and the branch lifecycle of a branch-keyed entity. On the node that owns a placement, the replication records the highest revision each replica reported holding on its stable storage, and offers the placement’s newest checkpoint to the replicas that do not hold it yet. On a replica, it carries the owner’s announcements to the task that keeps the replica’s copy current. It lives and ends with the state it replicates, so a WASM checkpoint or a Kafka offset commit offers its revision through the state it already holds, and no checkpoint, commit, offer enters a node-wide announcement or progress map. State installation publishes a resolved route containing the actual state handle and the entity’s existing assignment slot. Frames select that route from one immutable publication, never from the state, execution or identity registries. An announcer retains its selected route for its whole lifetime and reads the current primary and replica set from the assignment slot.

Branch-aggregate byte capture keeps the narrow lifecycle_call expectation for scanning the metrics inventory at an explicit snapshot boundary. Each placement retains one encoded snapshot under its own blocking mutex. Capture reads the dirty revision first and chooses that revision’s bytes once; subsequent descriptors, streams and persistence reuse them, including elapsed metric fields. Initial, replica and persisted recovery install the received bytes with their revision. The guard serializes capture and installation, covers only that placement’s snapshot work and never crosses an await. Metric recording advances the dirty revision without taking this guard; the next capture includes those measurements. The guard belongs to cold checkpoint work, not to record or batch execution. The two interleaved-tenant window drain and relocation scenarios run in both diagnostic selections and the stressed restore selection. State-route selection uses the installed state handle and retained assignment slot. Checkpoint synchronization sends a bounded revision, length and digest description through that selected route. The replica fetches the exact revision through the bulk pool, admits the whole allocation and verifies every byte before installation. A handoff destination follows the same sequence from the source’s captured checkpoint while its entity gate remains held. Neither path adds a shared hot-path lock or a new wait-and-notify protocol; Deloxide sees the existing state-store installation lock but cannot see stream scheduling or async memory admission.

A replica’s progress only rises. Acknowledgements travel independently, and a replica acknowledges what it holds again whenever it is offered a checkpoint, so an acknowledgement of an older revision can arrive after a newer one; it leaves the newer one in place. Each wait for replicas registers for the next report before it reads the progress: a WASM checkpoint waiting for the replicas its boundary names, a Kafka offset commit waiting for its replica quorum, and a WASM state reset waiting for the branch lifecycle that authorizes its first checkpoint. The reset wait retains its route, follows the current replica assignment and rejects loss of identity or primary ownership. A checkpoint promised to replicas never confirms with fewer replicas, using the same typed boundary as a guest checkpoint. A 100-millisecond recheck observes assignment changes even without a replica report. A report that lands between the read and the wait therefore wakes the wait instead of leaving it to its deadline.

One announcer at a time offers a placement’s revision. An offer raises the revision the running announcer offers or, with none running, starts one. The announcer sends the revision to the replicas that lag behind it, again every 100 milliseconds, and ends in the same step that finds every replica the committed schedule assigns holding it, so a revision offered while it ends is either sent by that step or starts the next announcer. It also ends when this node stops being the placement’s primary, when the replicated state goes away, and when the runtime stops. An announcer whose task ends before it finished hands its announcement back to the next offer.

The runtime’s checkpoint announcement task owner cancels both dispatch and retry waits when it closes after domain drain. It joins every cancelled task before withdrawing replication routes, so shutdown releases the task’s exact retained state without waiting for a remote dispatch timeout.

The snapshot task of a deduplicator or window branch has the runtime as its owner too, because it outlives its branch task: once that task is gone, stopped or ended at its stop grace, the snapshot task still writes what the branch published last. A stopping runtime closes this owner after domain drain, which tells every task still waiting for its branch to make that write, and joins every one of them before it closes the announcement owner. Nothing is cancelled: the join is what keeps the node’s storage from being released under a write, so it lasts as long as the slowest of those writes. The owner is a task tracker and a cancellation token, with no lock.

The announcement, the progress and the announcer’s handover change together under a short lock that belongs to the one placement and is never held across an await. The placement’s originator, its one announcer and its replicas’ acknowledgements are the only participants; unrelated placements never share it. An acknowledgement or an announcement names its placement, and the node that receives it uses the handle resolved when that state was installed. The assignment and the placement must still name the same schema and guest-state generation. Cold replacement or teardown ends the route before withdrawing it, so a retained route never attaches to its successor, including when the successor has the same placement key. A frame already admitted through a borrowed state may finish on that exact state. On a replica, announcements of a lifecycle or one of its branches use the published entity lifecycle, which the entity’s replica task retains. An announcement of an entity a node holds no state for wakes nothing and creates no state.

A replica installs a branch checkpoint only while the branch lifecycle it holds names the branch. Each lifecycle checkpoint a node holds is decoded once into the set of branches it names, so installing a branch checkpoint looks its branch up in that set, through the lifecycle handle the replica task retains, instead of copying and decoding the lifecycle each time. Holding a replica’s copy of a branch checkpoint compares revisions and moves the newer payload into an immutable publication owned by the entity lifecycle. Lifecycle pruning visits only that entity’s passive copies without acquiring a registry shard. Catch-up steps and installation read the assignment slot the lifecycle retains. Cold assignment cleanup also discards superseded passive generations, while an already borrowed checkpoint can finish. Cold promotion consumes the passive copy; non-branch handoff snapshots remain in a cold recovery registry.

Replica catch-up

A replica keeps each branch-keyed entity it replicates current through one replica task: the entity’s branch lifecycle and, for a deduplicator, window or WASM processor, the state of every branch. The task retains the entity’s lifecycle handle and alone owns what it learned: the owner’s catalog as far as it read it, the revision this node holds of each branch it looked at, and the branches it still has to fetch or acknowledge. Nothing else reads or changes that record, so it takes no lock.

The owner keeps, with each entity’s lifecycle, a catalog of the newest replicable revision of every branch state it owns. A branch state registers when it is created, records every revision it offers its replicas, each generation a deduplicator or window branch publishes and each WASM checkpoint on the owner’s stable storage, and leaves the catalog when it goes away. A branch state that another one of the same branch replaced neither changes nor removes its successor’s entry. The catalog is one immutable value that each change replaces by a read-copy-update, so recording a revision never waits for a reader and a reader never waits for a branch. Every change is numbered, and a replica asks for the changes after the cursor its previous read returned. A cursor of another catalog, a cursor older than the oldest removal the catalog kept, or no cursor at all restarts the listing from the catalog’s beginning. A listing is paged, 256 changes at a time.

A round runs when the task starts, when the owner announces a checkpoint, and once every replication poll interval. It synchronizes the lifecycle, reads the catalog’s changes, and looks only at the branches that changed, that the lifecycle names for the first time, that the owner announced, or whose earlier step failed. The task reads what this node holds of a branch once, from its own copy, and keeps that current itself. A round in which no branch changed therefore sends two requests to the owner, reaches no shared map for any branch on either node, and does no work per branch, however many branches the entity has. The branches that changed are fetched and installed at most 16 at a time.

The owner’s announcements of the entity’s lifecycle and branch checkpoints are left with the entity’s lifecycle handle on the replica, the newest one of each, under a short lock that belongs to that one entity and is never held across an await, and each wakes the task. The task takes them all at the start of its next round, so an announcement that lands while a round runs is taken by the next one, and one of a revision the replica already holds is acknowledged again. An announcement that is lost delays a checkpoint by at most one poll interval.

Relay branch presence

A relay’s owner task owns its concrete branch instances: their activity order, the TTL scan that expires them, and the LRU eviction that bounds them. It keeps them in an ordinary indexed map beside a persistent set of their keys, and admits, evicts and expires through one owner type that changes both in the same step. A step that changes the membership publishes the set, sharing its structure with the membership published before it, so creating or releasing one branch copies only the path to that branch, never every branch the owner holds. A batch that creates one branch and evicts another publishes once, so an observer never sees the relay above its capacity. A batch for an established branch refreshes that branch’s activity in the owner’s map and publishes nothing; it takes no lock and writes no shared timestamp.

Each owner lifetime claims the presence when its task is spawned. The claim publishes an empty membership that replaces whatever an earlier owner left behind, including the branches of an owner aborted before its teardown ran, and from then on only the claiming owner changes what the presence publishes: a replaced owner that is still ending compares its lifetime with the published one and publishes nothing. Dropping the owner state, whether the task finishes its drain or is aborted, releases the presence by publishing an empty membership unless a successor has claimed it. Publication and claims are one ArcSwap compare-and-swap or read-copy-update each; the protocol adds no atomic of its own.

The owner publishes a new branch before it fans out the batch that created it and an eviction before it invalidates the evicted branch’s delivery slots, so a consumer that has processed a batch reads a membership that holds its branch.

Materialized relay entries

One assigned materialized-relay originator owns updates. The state contains at most one latest record for each concrete branch. Its origination capability moves into the branch runtime or the scheduled relay-state task and cannot be cloned. That task owns an ordinary map of mutable branch records. An established branch retains its row publication: timestamp comparison uses the owner’s record, then publishes one immutable Arrow-backed row view. It acquires no shared-map shard and does not republish or scan branch membership. A larger high watermark wins, then a larger low watermark; equal watermarks retain the first record and advance no revision.

Replacing an existing branch’s record is admitted under the originator’s assignment generation. Adding the first record for a branch or evicting a branch also changes the branch lifecycle, so that narrower operation uses the assignment barrier and advances a branch generation. The persistent membership publication changes only at those boundaries or a snapshot installation. Eviction first clears the exact row publication, then withdraws that member. Recreation allocates a new publication; a retained ended member never attaches to it. Rebinding waits for operations admitted under the preceding assignment before rebuilding the new task’s mutable selections from the published views. Previous writer tokens refuse further mutations.

Domain routing retains one materialized publication per relay and its relay-state epoch. The installed state-replication routes supply the relay’s immutable branch index. Dependency reads and generator ticks load that retained relay index and the installed row views, rather than looking up or iterating node-wide materialized-state, presence or epoch registries. Presence comes from the retained relay services; placement identity comes from the shared immutable assignment publication. Live state owns absence: an empty concrete branch does not fall back to the relay snapshot, and a missing live row does not fall back to a pre-eviction stored snapshot. Cold installation and public observation retain their lifecycle registries.

A snapshot capture holds the assignment boundary only long enough to take immutable row views, their revision, their ownership fence, and their branch generation. The Arrow columns stay shared, and sealing and encoding happen after the boundary is released. A snapshot from an earlier assignment or branch generation cannot restore state that a newer owner or eviction superseded. Installation also refuses a lower revision within the same fence and branch generation. A cached sealed snapshot answers a current read only when its revision and assignment fence still match. One sealed generation is cached and one build is admitted per placement; outstanding readers and captures keep only their selected row views and shared Arrow carriers. Publication never copies a row payload or a whole relay for an established update. Snapshot archives and storage ownership retain the current container and backup contract.

A stopped backup selects materialized checkpoint readers alongside its other state from one immutable database view. Those readers keep the selected namespace, metadata and chunks through bounded group conversion, even if a later publication replaces or reclaims the generation. They retain one stored chunk and one charged group at a time and publish no runtime state. Running and paused backup capture continues to borrow current row views under the assignment barrier.

The required-wait observation creates its notification before reading dependencies. The primitive boundary registers a notify_waiters observer at future creation, so an update after the read and before its first poll still wakes it. Broadcast publication occurs once after each changed batch and after a deletion. Periodic rechecks also cover remote changes; they do not establish the local wake invariant.

The registered materialized-publication-lifetimes property exercises bounded updates, captures, deletes and rebinding, then restores every captured schema, logical value, watermark and branch. Production-owner Shuttle checks cover update/capture, eviction/recreation, assignment replacement and required-wait registration without a polling timer. The Loom invariant server.state-assignment.drained-publication proves the synchronous admission drain observes preceding writes; its relaxed completion weakening must fail. ArcSwap internals are opaque to these models. Supported Turmoil transfer checks establish network framing and replacement scope, not disk durability or whole-cluster recovery. Typed Ratchet 10 owns mandatory immutable-image materialized smoke/soak qualification. The same-host just bench-materialized-state measures row and batch cost for 1, 256 and 4096 branches, allocation and captured-carrier retention; instrumented benchmark smoke execution supplies coverage, not performance evidence. Assignment/capture barriers and sealed-cache locks are tracked by Deloxide; async notifications and opaque publication internals remain outside its detector. just test-deloxide, just test-deloxide-order and just test-deloxide-stress include the materialized-publication diagnostic process, which starts the detector and exercises timestamp replacement, capture, sealing, installation lifetimes, rebinding, lazy relay installation, branch eviction and bounded complete snapshot properties. The latter also checks potential acquisition-order cycles among tracked lock instances. Each selection retains its own evidence alongside the other diagnostic processes under target/deloxide/test-deloxide; these instrumented executions supply correctness evidence, while the ordinary native benchmark supplies the row-publication cost measurements.

Sealing retains bounded identity and Arrow pieces on quota-owned staging disk and concatenates through one 64 KiB read buffer. Row projection and its IPC result share one bulk admission and one CPU job; projected columns never wait for a second grant while holding the first. Typed identity conversion is charged before it allocates. The sealed cache holds the immutable file and descriptor, so its memory does not grow with container size. Periodic writers select their namespace before executor admission, write one 64 KiB database chunk per batch, synchronize the chunks, then synchronize the replacement checkpoint header. A queued writer whose namespace was replaced is inert, and a pinned reader continues through the same database snapshot. Replica installation rejects a lower revision before clearing its current entries. An incoming materialized snapshot releases its response stream at EOF, before local verification or Arrow decoding. Those local operations retain their own staging and memory admission; they do not keep Snapshot request or connection-stream permits while waiting for executor work.

Kafka offset catch-up checks the revision before retaining or encoding a checkpoint. Capture holds the assignment barrier only to select a conservative revision and immutable partition topology; encoding retains that topology and reads shared offset slots after the barrier returns. A commit included ahead of the captured revision is offered again by the following revision. The native encoder writes into quota-owned staging disk with a fixed 64 KiB I/O grant and separately admitted serializer scratch. Bulk transfer carries bounded chunks rather than a whole resident checkpoint. At EOF the receiver releases its Snapshot stream, verifies the staged file, and admits native decoding separately. It builds a replacement table directly from archived entries, checks cancellation between entries, and publishes only under the replica’s existing assignment token. A failed conversion changes no installed offsets; rebinding rejects a delayed installer. No new concurrent map, per-record lock, or atomic ordering is introduced by this transfer path. An unchanged poll repeats the held revision through the existing replica assignment admission. This recovers a lost acknowledgement without encoding another checkpoint, while promotion or replacement prevents a retained poll from reporting progress through its former token. The runtime passes the native bulk operation’s thirty-second budget into the quorum wait for both committed and reset Kafka offsets. The offset-state owner supplies the register-before-read wait mechanism; it no longer chooses a separate response budget that can expire while an admitted native checkpoint is still progressing. A missing replica acknowledgement still fails at this bounded deadline.

Acknowledgement state

Each source attempt owns one in-memory acknowledgement tree. Fan-out reserves shares in an atomic pending count, and each completion resolves one share with a compare-and-swap. A second atomic word encodes either a typed tracking state with its active-share count or the completed state; its reserved raw representation stays inside the encoding’s owner. That state records whether shares still count against ownership handoff; a message parked on materialized REQUIRED WAIT does not keep a handoff blocked, and neither does a row a window retains until the window steps past it. Both owners hold one park guard per retained message and drop it before they acknowledge, so the acknowledgement removes the share the holder owns. Remote ACK progress publishes a monotonic sequence with the root’s parked handoff state. An upstream remote share follows that state, and its root publishes the resulting transition farther upstream. A delayed progress event cannot reactivate a newer parked state. Terminal ACK resolution still owns the pending share and releases the parked guard with it.

A handle owns an active share only when its attachment reserved a pending share. An attachment that finds the root complete may observe that root, but cannot park or remove a share owned by another handle. Resolution removes the owned pending share before changing active-share tracking, so a terminal transition cannot make an unsuccessful attachment look like active work.

Root trackers count attempts rather than individual shares. Their transition ordering may briefly overcount but cannot let handoff miss active work. Exactly one terminal transition takes the one-shot sender from its slot and releases that guard before notifying the waiter. ACK trees, shares, and trackers are volatile hot-path state and are never persisted.

A WASM branch holds back the success of the inputs a guest callback decided with one more attached share per input. The branch task owns those shares for the length of the callback’s checkpoint and resolves them itself: it releases them when the checkpoint completes and negatively acknowledges them when it fails. Holding them needs no lock and no shared registry.

A share a node forwarded to another node with a relay delivery waits in the node’s registry of forwarded acknowledgements under its registration number, beside two atomics: whether the delivery was admitted, and how many sweeps found no report from the receiver since its last one. A report, the admission, and the once-a-second sweep change them through a borrowed lookup, under the shard’s shared lock. The sweep fails a share only through an exclusive removal whose predicate rechecks the count, so a report that took the shared lock first keeps the share, and a report after the removal finds no entry. A terminal outcome, the delivery’s own failure, and the sweep each take the share out of the registry with one removal, so exactly one of them resolves it. The atomics establish no ordering across locations; the shard lock orders the recheck against every earlier report.

Client ingestor endpoints

Each client ingestor a node executes has one endpoint task that owns every producer attached to it: the batches each queued, the round-robin order among them, the batches handed to the execution’s admission worker or admitted and awaiting acknowledgement, and whether admission is open. Producers, the admission worker, the quiesce watch, and lifecycle changes reach that state only as commands on the task’s channel, applied in the order they were sent, so nothing locks it. An execution is installed before its admission worker and quiesce watch start, so the watch’s first report, which opens admission, is applied after the installation.

The admission worker takes one batch at a time over a channel of one. A worker that stops refuses the batch it was handed and never took, and closes the channel so that the endpoint refuses any later batch itself. The worker’s task is joined before the endpoint learns that the execution stopped, so a batch the endpoint still holds then was taken by a worker that was aborted when its stop outlasted the grace period, possibly while dispatching it: its outcome is unknown, never refused. The endpoint task awaits each admitted batch’s acknowledgement itself, among its commands and before further ones once it resolves, so an ending endpoint leaves no task behind, and a resolution for an attachment that already ended finds nothing to answer.

A producer’s turn gives its next queued batch a slot of the window whether or not the worker is free, so a busy worker never passes a producer over. A local producer’s batch is then ready for the worker. A batch that another node forwards first waits among the producer’s batches awaiting clearance while the endpoint asks the serving node over the producer’s own clearance channel. The clearance returns as a command, and clearances arrive in the order they were requested, so the one that arrives names the oldest batch awaiting it, or a batch the endpoint has already refused, which it ignores; the cleared batch is then ready for the worker too. A free worker takes a ready batch, which already holds its slot. A refusal, a close, an end, or a detach answers or drops the batches still awaiting clearance or the worker, which never reached it, and releases their slots in the same command, so a lost serving node never leaks a slot of the window.

After each command the task publishes its producer, outstanding, and window counts into plain atomics that DESCRIBE reads, and into metric gauges it resolved once. Each count is exact when written, and a reader may see one count of a change before another.

The node’s producer byte budget is one atomic counter. A reservation adds its bytes with a compare-and-swap that refuses to pass the budget, and dropping the reservation subtracts exactly those bytes, so concurrent opens through different sessions and links never overcommit it.

A session’s producer credit is held under a short mutex, taken by the receive loop when a batch arrives and returned by the producer’s task before it queues that batch’s reply, so a client that sends only after reading a reply always finds room. The mutex never crosses an await.

Client emitter attempts

Each running native client emitter has one actor for its volatile output. Its channel serializes consumer attach/detach, prepared-payload publication, assignment, settlement, timeout and cancellation. An attempt has exactly one owner and ACK reference. Revocation returns its worker’s batch and byte credit and marks that reference stale before the payload can be assigned again. Sequential dispatch checks every earlier unresolved delivery of the same source relay and concrete branch; parallel dispatch counts assigned attempts against that stream’s shared window. The actor keeps only a bounded queue of completed references for duplicate ACK recognition.

The node’s consumer grant and retained-output byte counters are separate atomics. A session’s consumer count and credit use one short mutex; neither that mutex nor a runtime map guard crosses an await. A retained payload reserves actual bytes until application settlement or cancellation, and its prepared Arrow bytes and source members remain owned by the emitter host until the result is applied. A consumer read uses an asynchronous receiver lock only for that consumer; it cannot hold the session receive loop or the ordered command lane. Settlement is a concurrent request, so a quiescing command cannot prevent the application ACK it waits for. The shuttle_competing_consumer_grants_never_exceed_the_node_budget check explores competing session grants against the production atomic budget. The shuttle_consumer_loss_and_ack_race_release_one_retained_delivery check runs the production delivery owner with deterministic attempt references and no timer wakeups to explore ACK versus consumer detachment. The shuttle_publish_cancellation_and_ack_race_release_one_reservation check also races publisher cancellation against application ACK and verifies retained byte credit returns once. Timeout revocation is covered by the ordinary owner and public scenarios.

Rust client attachment recovery

The client retains desired producer and consumer handles independently of their wire attachments. Their weak registries are reached for attachment registration, admission changes, termination and reconnect snapshots, not for discovering an attachment on each submission or delivery. Producer wire entries include the exchange generation beside the attachment ID. A snapshot upgrades live weak handles and releases its map guard before any handle transition or network await. Close/drop removes desired entries, exchange termination removes its producer wire entries, and snapshots prune expired weak handles. The concurrent map inventory records each map’s access frequency and disposal.

Each desired handle has one lifecycle owner, with a short selected mutex over its typed attachment phase and a watch notification for changes. Beginning or installing a restore checks that phase and its exchange generation under the same mutex as close. Close is terminal: a restore reply that loses that transition cannot install its attachment and releases the grant it obtained. Another exchange loss can interrupt only the restore or active attachment of that exchange. No lifecycle guard crosses an await. Submissions and deliveries keep their originating attachment, so replacing it cannot retarget an uncertain submission or an application ACK.

The four production-owner Shuttle checks listed below race close with starting restoration and with another loss during restoration. They establish that the handle stays closed and cannot begin a subsequent restore. Public scenarios cover exchange I/O, late open replies, reservation cleanup and delivery interruption; those network effects are outside these in-process checks. Client Session Protocol and its implementation manual own the restoration and application-outcome contract.

Node quiesce accounting

Every entity on a node keeps one set of quiesce counts. A drain reads them to decide whether the node still holds work, and the hot paths that take a message in, collect it, park it on required materialized state, buffer its output, or finish it adjust them.

Each total a drain reads is a counter of its own: everything the node holds, and the admitted subset of it that a local drain waits for. A total summed across several counters at read time can miss an item that is between two of them, because the reader loads the count the item has already left and then the count it has not yet joined. A drain that misses the last item reports a node holding no work while it still holds one, and an entity gate that believes that alters the entity under the message. Messages parked on REQUIRED WAIT and outstanding force-flush obligations are counted on their own for the drain’s report, and neither exchanges items with the admitted count.

An adjustment that moves work between the counts raises every count that rises before it lowers any count that falls, so a drain reading during the move sees at least the work the node holds. A processor publishes its collected inputs, parked messages, and buffered outputs together for the same reason: a batch that left an input collector for an output buffer was admitted work throughout the move. Overcounting only delays a drain, while undercounting lets one conclude early, so the ordering is chosen in that direction. The counts are hot-path scalars and are never persisted.

A local drain reads these counts together with the domain’s relays, ingestors, generators, acknowledgement roots and emitters, and work keeps moving between them while it reads. No single order of reads follows every move. A node that takes a batch from a relay counts it before the relay lets it go, so the relay has to be read first, while a relay counts a batch a node publishes before the node lets it go, so the node has to be read first. Each relay therefore keeps two totals on each node that only rise: the batches it admitted, from its owner’s publishers or from deliveries another node routed to its consumers here, and the batches it handed on. It holds their difference, and the admitted total is also its admission sequence. A drain observation reads every relay’s sequence before and after everything else, and a relay whose sequence changed in between counts as work still moving. A batch the observation missed reached the place where it rests only after the read that would have counted it there, so it entered some relay between that relay’s two readings. The observation reads force-flush obligations first: a participant completes its obligation after everything it did for the flush, including resuming a message parked on REQUIRED WAIT, so an observation that sees the obligation complete sees that work as well. The totals cost a relay’s owner path the same two read-modify-writes per batch as the pending count they replace, a routed delivery two more, and a drain reads them only on its own poll.

A broker source’s batch keeps its acknowledgement roots counted beyond their resolution. The source host creates confirmed roots, and a confirmed root that resolves hands its domain and ingestor counts to the completion that observes it instead of releasing them. The batch acknowledgement drops the completion of every root that resolves while another is still unresolved and keeps the completion of the last, which the source loop drops once acknowledge or reject has returned. The hand-over is part of the root’s one terminal transition, so a drain that reads the counts by read-modify-write never finds them at zero between a batch’s last resolution and its transport’s confirmation. An unresolved root keeps holding them itself, unless it waits only on REQUIRED WAIT state, which a drain does not wait for, and a completion that dropped before its root resolved has the counts released as the root resolves. See Confirmed acknowledgements.

An ingestor route’s force-flush participant releases its partial batches into branch execution; the admitted client submission’s tracked ACK root stays outstanding until its downstream work settles. For a model alteration with a shared relay gate, the control plane suspends the affected ingestors on every node and drains those roots before closing the relay gate. Otherwise a flushed batch could wait behind the gate whose drain waits for that same root. Intake and full entity holds overlap on the same quiesce control: releasing one EntityHold removes only its share, so the remaining hold keeps admission suspended. The client admission Shuttle check exercises that overlap with racing batch admission; the public buffered alteration scenarios exercise delivery through local and remote relay owners.

A task that must not publish while an ownership handoff has frozen its entity observes that freeze through one watch, which registers for the next freeze change before it reads the freeze. A release landing between a read and a registration wakes nothing, because the waiter does not exist yet, and a task that missed one keeps a stale freeze that disables exactly the force-flush and deadline arms that would have woken it again. The release lifts the freeze before it wakes anyone, so a waiter that rereads it sees the entity thawed rather than parking on a change that has already happened. The ingestor route task, ingestor branch maintenance loop, and processor branch task all take this observation before deciding which force-flush and deadline arms to enable. Their wake contract is therefore the watch owner’s registration order, checked by shuttle_an_ownership_handoff_freeze_observation_registers_before_its_read against the production release path. Other data-plane waits on watch subscribe before their deciding read, or use an existing receiver whose borrow leaves a subsequent changed able to see an intervening send.

Ordering Fences

Three fences remain because they define externally observable order or ownership. Each is scoped to the smallest identity that carries the guarantee. They do not provide general-purpose mutual exclusion for unrelated batches.

Per-slot delivery gate

A remote delivery slot is scoped to one destination, relay, payload role, and concrete branch. Its asynchronous gate permits one encode-and-send operation at a time for that logical channel. The gate preserves FIFO order from serialization through admission and ensures the channel sequence is allocated in the same order. Other destinations, relays, roles, and branches use different slots and continue independently.

Established slots are found with a borrowed lookup. entry() is used only when concurrent first deliveries must agree on a single slot; allowing two racing installations would create two sequence owners and could interleave the channel. The small sequence state is updated only while the slot gate is held, and its synchronous guard never crosses an await.

Dispatch-gate engagement

The relay dispatch gate separates ordinary publication from a model change, ownership handoff, or shutdown operation that must know every earlier dispatch has left. On the open path, a publisher raises an atomic in-flight count with a read-modify-write and then checks an atomic closed flag. An engagement closes the gate and then reads the count with a read-modify-write as well. Read-modify-writes of one count are totally ordered, so either the publisher’s comes first and the engagement counts it, or the engagement’s comes first and the publisher’s acquires it, observes the gate closed, rolls its count back, and waits. A plain load of the count would not do: it may return a count from before an increment that preceded it, while that publisher’s own load of the flag still finds the gate open. Leaving releases what a dispatch did under its permit to the engagement’s next read, and the last dispatch to leave a closed gate wakes the engagements waiting for it.

The engagement state is consulted only while the gate is closed. Once all earlier permits leave, the engagement owns a lease and the protected mutation can proceed. The acquisition deadline bounds waiting for that fence; a successfully acquired lease remains engaged until its owner releases or drops it.

An owner buffer admits a batch before its fan-out begins. Fan-out therefore takes a nonwaiting dispatch permit before it reads any local or remote runtime-consumer route and holds the permit until fan-out has attached the consumer ACK shares and resolved the owner’s share. A schedule swap cannot replace the routes while that permit is live. If the gate has closed first, fan-out fails the record ACKs and returns the batch for source retry. Waiting for the gate here would deadlock the swap, because the swap’s drain counts the buffered batch. The same gate protocol orders the nonwaiting attempt and the swap engagement.

The three-node attached-emitter move scenario injects a stale zero buffered-batch drain report while an owner batch is paused. That puts fan-out beside the local swap even when the coordinator’s usual drain would wait for the batch, and verifies that the local gate independently prevents an ACK for a batch the moved consumer missed.

A WASM state reset adds a narrower gate without putting a lock on relay dispatch. Under a short whole-relay publication fence, the relay atomically replaces an immutable list of scoped reset gates. Dispatch loads that list once, compares the batch’s branch fingerprint with each typed scope, and waits only on matching gates. Removing a lease atomically republishes the list and releases its gate. This gives publication and dispatch one total order while unrelated branches neither acquire shared locks nor wait for the reset.

Client batch admission fence

A client batch is validated before it is dispatched, and a quiesce can engage in between. The admission worker therefore tracks the batch’s ACK root with the ingestor’s drain accounting first, and only then reads the quiesce publication. Either the read comes before the engagement, so the drain that follows counts the root and waits for it, or the read observes the engagement and the batch is refused with its root resolved before anything was dispatched under it. No batch can be dispatched after a drain concluded that the ingestor held no admitted work. The admission tracks the root with a read-modify-write, and the drain reads each root count with a read-modify-write after the engagement published, so one side observes the other in the C11 memory model, not only on processors whose read-modify-writes are full barriers.

Assignment generations

A runtime-state assignment packs its generation and capability into one atomic word. A hot operation increments the admission counter for that generation before comparing its token with the published binding. A rebind serializes with other rebinds, publishes the successor binding, and waits only for operations admitted under the generation it superseded. It reads that generation’s admission count with a read-modify-write, so an operation whose admission came first is waited for, and one whose admission came later acquires the rebind’s read and observes the successor binding. An operation that finishes releases what it did to the rebind’s read. Even and odd generation counters let work admitted under the successor proceed without extending that wait.

This fence prevents a former owner from mutating or describing state after reassignment. Snapshot capture holds the assignment barrier so its observed binding stays current for the whole capture. Whole-state installation is authorized exclusively under its exact binding: it cannot overlap a capture or an admitted origination. Ordinary record updates use atomic admission, continue while a capture merely holds the barrier, and never take that barrier.

Bounded Synchronization Outside The Open Path

Some synchronization remains after a hot operation has selected a state that explicitly requires it:

  • a quiesced ingestor, and one draining what it retained, locks only the bounded retained-payload buffer selected by its declared MAX SIZE policy: to retain a payload, to take the oldest out for delivery, and to end that delivery. No guard crosses an await.
  • a retained payload whose unfolding finds the extension class’s wait queue full waits for a place in that queue. The executor’s semaphore hands a freed place to waiting jobs in the order they asked, ahead of any job asking afterwards. The wait is scoped to the one class, holds no lock, and ends when a place frees or when the ingestor’s shutdown or a new quiesce interrupts it
  • a live payload a paced source or a source whose loop may be held handed over without an acknowledgement waits for a place the same way. The source host registers for the next quiesce publication before it compares it with the decision the payload was taken in under, so a new decision always ends the wait, and the host then decides on the payload again; the ingestor’s stop ends it as well. The race holds no lock and no guard across an await
  • one concrete processor branch serializes its own batches, while other branches remain independent
  • one already-resolved histogram series serializes its bounded accumulator update, once per recording: a batch’s delivery latencies arrive as one folded recording, so the lock is taken once per batch; counter series are atomic
  • one inbound relay attempt serializes its small receipt, admission, rejection, and cancellation transition within the interconnect’s quotas and deadline
  • snapshot construction and state installation serialize per placement, off the row path
  • a WASM branch waits for its own checkpoint after each guest callback, bounded by the checkpoint deadline. The state store’s durability barrier lets one writer at a time run a storage synchronization for every writer waiting, through one atomic runner slot and a ticket watermark; it holds no lock across that wait, and the synchronization runs on the storage workers. The round of a synchronization belongs to the storage job that flushes: it frees the runner slot and records its outcome when that job ends, so a writer that stops waiting neither frees the slot while the flush still runs nor loses a failed flush. Replica progress reaches the waiting branch through the replication of its own state.
  • one placement’s replication changes its announcement, its replicas’ reported progress and the handover between its announcers under a lock scoped to that placement, taken by its originator, its one announcer and its replicas’ acknowledgements, and never held across an await; see Checkpoint replication
  • a replica’s copy of one branch checkpoint is held under the registry’s guard for one revision comparison and a move, never a copy of its payload
  • the owner’s announcements pending for one branch-keyed entity on a replica are added to under a lock scoped to that entity, one newest announcement per branch, and taken all at once by the entity’s one replica task, never across an await; see Replica catch-up
  • an ACK root locks only its single terminal sender transition

These sites are accepted for the contract and bound named above. A lock that merely makes shared state convenient, protects immutable configuration, or repeats registry discovery on every batch does not belong in this category.

The simulated relay fault checks drive the production receipt, admission, and cancellation APIs through authenticated connections. They synchronize on the received Arrow batch and verify the same attempt cannot be admitted twice after a lost reply. Cancellation before grant and while receipt or reconnection is unresolved must win the attempt’s existing admission fence before the runtime can admit it. A cancellation after admission returns the admitted outcome.

Relay fan-out itself uses one bounded queue per consumer. Publishers share no fan-out lock; capacity and receiver counts are atomic, and a publisher registers for notification only when a consumer queue is full. A waiting publisher raises a waiting count before it reads a consumer’s admission count, and a consumer lowers its admission count before it reads the waiting count. Both reach the admission count by read-modify-write, so either the publisher reads the room the consumer freed or the consumer reads the waiting count raised and wakes the publisher. Removing one consumer does not stop the others, and changing capacity does not discard batches already queued.

A publication counts the consumers it delivers to from the same snapshot of the registered consumers that it delivers through. A consumer that registers meanwhile receives later publications only, so the acknowledgement shares a publication reserves for its attached consumers always match its deliveries, and a routed delivery, which holds no dispatch permit, cannot resolve a share it never reserved.

Ratchet And Review

just ratchet, just validate and just validate-ci require the pinned compiler’s Nervix source diagnostics to pass across the complete declared matrix. Finite API recognition lives in tools/nervix-lint/report/src/rules.rs. Architectural contracts live with their source owners; generated inventories under target/ are evidence, never approval inputs. Other structural ratchets retain their own checked-in counts.

The compiler recognizes resolved Mutex, RwLock and DashMap acquisitions through aliases, re-exports, dereference adjustments, UFCS and selected trait implementations. Borrowed reads, mutating and try operations, and iteration remain visible. Ordinary collection entries, held-guard entries and I/O do not acquire a recognized lock. Borrowed DashMap iteration acquires lazily; consuming iteration owns its storage. Authored arguments remain checked through external macros, including operation expectation macros.

Source contracts

Gate annotations with cfg_attr(nervix_lint, ...). Only the isolated analysis driver registers the nervix tool and enables that cfg; ordinary stable and browser source needs no unstable feature. Contracts use the narrowest meaningful function, trait, type, impl, module or crate boundary:

#[cfg_attr(nervix_lint, nervix::context(recurring, reason = "one callback per admitted batch"))]
fn process_batch() { /* retained execution state */ }

#[cfg_attr(nervix_lint, nervix::context(bounded,
    reason = "the retained grant serializes its terminal transition",
    key = "one admitted relay grant", bound = "one synchronous transition; no guard across await"))]
struct GrantState { /* retained bounded state */ }

recurring identifies record, batch, frame, acknowledgement and steady poll execution. lifecycle identifies installation, replacement, snapshot cadence and teardown. observer identifies observation outside execution, and outside names an edge or harness outside this rule’s data plane. bounded remains recurring but permits the explicitly described retained protocol; its reason, identity key and capacity or deadline are required. These are reviewed architectural assertions, not proofs of exclusive ownership, bounded waiting or runtime cadence.

An explicit callable contract wins over defaults. An implementation inherits its trait method’s contract before its impl/type or lexical defaults; a recurring trait method’s override remains recurring or names a bounded protocol. A type contract applies to its implementations. Duplicate, conflicting, malformed and misplaced annotations fail. A binding whose initializer owns exactly one anonymous body can supply that body’s context: use it when a constructor installs a recurring task. Nested task bodies still require their actual entry contract.

The compiler re-evaluates supported local call edges and closure/callback bodies to a fixed point. A recurring caller makes an unannotated local helper recurring, including one in a lifecycle module. An explicit lifecycle callable is an installation boundary: entering it from recurring execution emits nervix::lifecycle_call. Callee and trait contracts survive cross-crate metadata and renamed imports. During recurring execution, unresolved generic, dynamic trait and function-pointer application calls require a callable/trait contract or nervix::dispatch(reason = "...") on their owning callable; a cold module default is insufficient. A dispatch contract states the external driver or callback boundary; it does not exempt compiler-visible local callback bodies or acquisitions.

Local iteration and polling implementations remain call edges. Generated await polling is the Rust scheduling protocol, rather than a separate application callback diagnostic. Synchronization inside external libraries, opaque external future implementations and unsupported whole-program relationships remain outside the recognizer’s claim. The checker supplies no universal ownership or effect proof.

Resolved architecture boundaries

nervix::layer(kind, reason = "...") states the exact documented crate/module, type, trait, impl or callable owner: primitives, vocabulary, language, engine, decisions, data_plane, control_plane, edge or harness. The narrowest contract wins; associated implementations inherit explicit trait metadata and impl-self contracts. Contracts survive compiler metadata and move with their source owner. An unrelated trait module default cannot replace an implementation body’s lexical owner. An engine that receives validated plans also declares nervix::input(plans, ...); general infrastructure is not presumed to execute graph plans.

nervix::value(model|name|cluster_access, ...) classifies exact semantic ADTs or traits. The current configuration Model union, shared name vocabulary and cluster observation capability carry those roles. nervix::model_in_data_plane rejects the configuration union in data-plane and plan-consuming interfaces or bodies, including normalized generic containers, aliases, constructors and patterns. An ordinary user type called Model has its own meaning. These checks do not treat every vocabulary field or schema descriptor as a configuration Model.

nervix::parser_dependency diagnoses resolved language definitions outside the language, edges and harnesses, including imports, type/trait references, qualified calls and methods. A separate source/manifest validator retains the owning crate’s import path before cfg selection, including renamed dependencies, inactive module imports and authored macro tokens. It derives language packages and consumer ownership from source contracts and Cargo/module declarations. Approved plain primitive and vocabulary re-exports keep their actual semantic owner; importing from a language crate still requires an allowed provenance owner even if resolution reaches a plain value.

The complete architecture rule matrix separates these resolved contracts from compiler-independent source properties. Integer/Duration clamping and Option/Result control-flow diagnostics use the same source-local exception machinery and legal configuration coverage; they do not classify a custom method by its name. Their serial analysis does not supply concurrency execution evidence. Explicit Result branches preserve the same reports and delivery outcomes when a caller expands an adapter sequence.

nervix::cluster_await diagnoses future-returning access on the classified cluster capability or trait when awaited from recurring data-plane execution. It follows the existing supported local helper/callback graph, selected concrete methods, UFCS, async closures, awaited expression wrappers and simple local future-binding chains. A local I/O await has no cluster role. A lifecycle cluster read remains lifecycle work. Dynamic/generic application dispatch needs its existing source effect contract; opaque external future implementations, arbitrary stored-future relationships and whole-program effects are not inferred. Execution graphs and recurring reachability are conservative within the declared local graph; annotations are reviewed architectural assertions.

nervix::node_identity, nervix::installed_state and nervix::state_contract implement the exact identity and representation contracts in Typed States. All six architecture diagnostics use the same normal lint levels, exact-operation expectations and complete current configuration evidence as synchronization and failure rules. No count, product owner catalog or source-review hash grants permission.

Diagnostics and reviewed exceptions

nervix::sync_acquisition diagnoses an acquisition on ordinary recurring execution. nervix::unknown_effect diagnoses an acquisition without a context or an unresolved application call reached from recurring execution. nervix::invalid_contract rejects contract and exception errors. The lints use ordinary Rust warn/deny/expect levels; the required gate also rejects unresolved Nervix warnings.

The driver also emits the resolved failure-contract, outcome-discard and panic diagnostics described in Errors And Diagnostics. Their exact error/return classifications use nervix::error_boundary; execution-context inheritance does not classify an error or exempt a failure. All diagnostics share the same one-operation expectation checks and complete configuration evidence.

Retained repair debt names its owning task at the exact operation:

let state = nervix_primitives::expect_lint!(nervix::sync_acquisition,
    "Typed Ratchet 15 (86bca1web): retain the replica entity before frame handling",
    states.get(&entity));

expect_lint! puts a normal reason-bearing Rust expectation on one binding, evaluates the operation once and returns its value. It supplies stable syntax and introduces no runtime policy, allocation or lock. A direct gated expectation on one operation binding works too. A lifecycle call admitted only during first installation or terminal teardown explains that phase and its concrete lifetime in the same narrow form. Blanket allow, undocumented or broad expectations, lint caps and unfulfilled expectations fail. The checker counts distinct HIR operations against Rust’s effective expectation identity, including macro expansion; widening a binding to cover a second operation fails even when Rust would regard the expectation as fulfilled.

Task handles remove recurring status, freeze, metric and checkpoint lookups. Source readiness, relay channels, buffered error delivery, domain selection and transport leasing use retained handles or immutable publications. Materialized writers own their mutable branch records and readers retain per-relay publications. Remote acknowledgement rows and admission waiters use fixed routing positions with exact generations; authenticated connections retain bounded peer protocol owners. Replica catch-up retains the entity’s lifecycle handle, assignment slot and its own record of each branch. Replication frames, synchronization and listing requests select published state handles; announcers retain their route and assignment slot. Their runtime registries handle installation, replacement and teardown. A session’s clock delivery retains the observer its attach resolved and reads only that observer’s lifecycle publication and progress watch. Accepted progress reaches the runtime’s domain registry once per report and then compares and replaces inside the watch, and an attach waits on the committed-domain installation count rather than returning to the registry. Bounded retained placement progress remains explicitly documented. Testing fault selectors name their exact emitter, ingestor, domain/branch, checkpoint window or acknowledgement link and finite read/removal steps; they release map guards before a scenario-controlled pause. These contracts describe test selection, not a product wait deadline. The executor-saturation lookup belongs to testing fault control. The concurrent map inventory records the runtime ownership review; source contracts and expectations are the executable policy.

Complete analysis and qualification

The isolated tooling workspace uses nightly-2026-09-17; product builds use stable. The matrix analyzes ordinary workspace libraries/binaries, the browser’s wasm32 library and binary, the server’s testing capability, server/interconnect/primitives under Shuttle, server/consensus/primitives under Loom, interconnect/primitives under Turmoil, and the server’s library and binary, the deadlock diagnostics and primitives under Deloxide, where every blocking acquisition resolves to a tracked adapter that the rules recognize as the lock it wraps. Turmoil’s runtime cfg comes only from its just recipe, which preserves the parent’s diagnostic mode. Compilation supplies no concurrency execution evidence. Test targets and undeclared feature combinations are not claimed. Mandatory source/manifest checks retain import provenance, inactive cfg and authored macro checks.

Cargo artifacts, complete side reports and declared roots must agree, including when there are zero sites. Reports retain every owner, configuration and expansion instance; generated target and external source sites are exclusions. Completion identities cover current source and annotations, Rust rules, compiler, driver, validator, locks, dependency declarations, Cargo configuration, diagnostic mode and relevant flags. Source changes during a run fail. The workspace wrapper nests beneath configured kache. Missing reports establish a new supported cache namespace and rebuild only isolated authored artifacts; the gate preserves RUSTC_WRAPPER.

just test-typed-ratchet qualifies APIs and source diagnostics in the legal modes, ordinary Bolero properties, stable gated syntax and paired Rust API doctests. just qualify-typed-ratchet-cache qualifies fresh/Cargo-fresh runs, an actual kache dependency hit, changed inputs, missing and interrupted output, and two worktrees. Native coverage uses the matching LLVM tools and records the driver executions that ran. Sanitizer campaigns follow the shared Bolero label policy. Calibration, measured cost and complete generated evidence belong on Typed Ratchet 02A.

Use the generated listing during review:

just typed-ratchet --show
just typed-ratchet --configuration shuttle --inventory

Partial selections and warning-mode inventories identify their diagnostic/matrix scope explicitly; they cannot satisfy the required full diagnostic gate.

For every new or moved site and every shared-map access, the reviewer establishes all of the following:

  1. Frequency. Trace its callers and decide whether it can run per record, row, batch, remote frame, ACK share, or steady poll iteration. A site on one of those paths is rejected unless it is an ordering fence or bounded protocol described by this chapter. Registration, first installation, replacement, teardown and observer reads are cold and are classified as such, separately from the recurring path.
  2. Owner. Identify the one component that changes the state. Immutable reconfiguration uses a whole-value publication, a scalar uses an atomic, a stable dependency is bound when the task starts, and branch-local state belongs directly to the branch task. An ordinary collection that one owner holds, and its entry API, are not synchronization and need no justification.
  3. Lookup behavior. A steady path does not look an established value up in a shared map, with entry() or with a borrowed get, contains_key or iteration, all of which take a shard lock. It retains the handle resolved when its task, branch, channel or attempt was created. entry() belongs to a cold or racing installation whose single-winner property is part of the contract.
  4. Fence contract. An allowed ordering fence or bounded protocol names the order it preserves, the exact key that limits its scope, the capacity or deadline that bounds waiting, and whether any guard crosses an await. Unrelated branches, relays, destinations, or assignments must still progress.
  5. Lifecycle separation. Startup, registration, schedule application, snapshot sealing, and teardown may synchronize with their peers, but their guards must not leak into a hot callback or be held while awaiting data-plane work.
  6. Mechanical result. Run just ratchet; inspect the site list when the count changes; update the baseline only when the count fell. A lower aggregate count does not make a newly introduced hot-path lock acceptable, and renaming a method or moving a file is not a repair.
  7. Inventory. Record a new concurrent map, or a changed frequency or disposition of an existing one, in the concurrent map inventory in the same change.

Installation-only fields declare their lifetime at the owner. The resolved installed-state rule rejects replaceable read-write locks in that representation, while an optional name/shared handle with an unstated lifetime requires explicit review. The current publication or one-time primitive must express the actual owner; type shape alone cannot establish a write-once lifetime.

Primitive Selection

Execution Primitives owns the shared primitive surface, its complete per-mode matrix, feature and target selection, authored-source scope, and unmodeled, blocking-work and thread-creation permissions. Every synchronization owner in this chapter obtains its primitives there. The boundary selects mechanisms; the source contracts and production-owner checks below establish what a caller may do with them.

Diagnostic Deadlock Detection

A diagnostic node selects the deloxide mode. The deloxide-order feature adds historical order instrumentation to that same mode, and the deloxide-stress feature adds Deloxide’s bounded scheduling disturbance to its active-only selection; neither is another execution mode, and the two cannot be enabled together. Ordinary builds keep their primitive reexports and contain no detector. Build the diagnostic node with just build-diagnostic-server [deloxide|deloxide-order|deloxide-stress]; its target directory is separate from the ordinary server.

Selection recorded in evidenceCompiled order instrumentationRuntime order checking
ActiveOnly (deloxide)absentdisabled
OrderAnalysis (deloxide-order)presentenabled
OrderInstrumentedActiveOnly (deloxide-order --deadlock-active-only)presentdisabled
StressedActiveOnly (deloxide-stress), with its stress configurationabsentdisabled

Runtime disabling does not recover an ordinary or active-only fast path. The pinned Deloxide 1.1.0 graph build enters its global detector even on uncontended mutex and write acquisitions. Reads enter that detector in either diagnostic build. The order build also tracks actual guard lifetimes locally, including when runtime checking is disabled, so active self waits remain observable. No timing from these builds is ordinary product performance evidence.

Scheduling stress. A deloxide-stress build compiles Deloxide’s random preemption into its tracked locks. A thread that already holds a tracked lock and asks for another sleeps first with a fixed probability, for a delay drawn between two bounds, and a configuration may also yield a thread after every release. An active cycle needs two threads that each hold what the other wants at the same moment; widening the window between one nested acquisition and the next makes the lifecycle paths that take nested locks meet that moment far more often than an idle schedule does. Disturbance never decides a finding: the detector reports the same cycles it would report without it.

StressConfiguration is the typed configuration, recorded with the selection in every process’s evidence. It is bounded by construction: a probability in millionths of at most one, delays of whole microseconds between 1µs and 2ms, the shortest no longer than the longest. The lane’s configuration, StressConfiguration::LANE, preempts one in twenty nested acquisitions for 20µs to 200µs and does not yield after releases. The longest delay holds one runtime worker for a small fraction of the shortest physical product deadline, and the product’s deadlines are not widened for it. A first configuration, a quarter of nested acquisitions for 50µs to 1ms with a yield after every release, made the stressed nodes miss their startup and request deadlines; so did one in twenty without the yield while four scenarios shared one process, which a plain diagnostic build of the same scenarios met. The cost is not the delays alone: a stress build enters Deloxide’s process-wide detector twice on every mutex and write acquisition, once to decide the delay and once to acquire, so every node of every scenario in one process contends on that detector. The stressed scenarios therefore run one at a time. Deloxide draws its preemptions and delays from entropy it seeds itself, so a configuration reproduces the distribution of the disturbance, not one schedule; a replay repeats the workload and the configuration.

Deloxide applies the disturbance only inside its blocking acquisitions, and its stress build disables its own optimistic mutex and writer fast paths. The tracked adapters therefore skip their own immediate try in this build and enter Deloxide’s blocking acquisition directly, registering the waiting site first. Deloxide keeps its reader fast path, so a shared acquisition that finds no writer is not delayed. Because every mutex and write acquisition then reaches Deloxide’s global detector, its held set is populated as in an order build, and the adapters keep the caller’s live guards so a self wait is still reported. Deloxide’s component-based strategy is not offered: it appends every nested acquisition to a list it never trims and scans that list on each one, so its memory and its cost per acquisition grow with the life of the process. The random strategy keeps one preemption counter per lock it delayed, which grows with the locks a process constructs and is bounded by the process’s life within its invocation bound.

The stress selections run every active-only probe, conformance check and owner test again, and between them every scenario of the active-only selection but the report tool’s triage, which drives no lifecycle path. deloxide-stress runs the lifecycle scenarios tagged @deloxide_stress and the paced-driver scenarios with the stressed Rust driver; deloxide-stress-restore compiles the same build under its own name and runs its probes and the capture, restore and checkpoint-transfer scenarios tagged @deloxide_stress_restore, in a lane of its own because the stressed scenarios of both, one at a time, would take most of one lane’s budget. The probe.stress-disturbance probe proves the disturbance is applied in each: twenty thousand nested acquisitions must wait at least the shortest delay for half the preemptions the configuration expects, and record the lane’s configuration. Its first run found that the adapters’ immediate try bypassed the disturbance entirely, and that a self wait went unreported once Deloxide’s held set was populated; both are fixed in the adapters, and the probe and the stress selection’s self-wait probes are their regressions.

The tracked surface. Every sync::blocking mutex, read-write lock and condition variable is an adapter over Deloxide. The adapters retain new, lock, try_lock, read, write, try_read, try_write, get_mut, into_inner, Default, From and guard dereferencing. Locks are not poisoned; Debug tries the lock and never waits. Timed, upgradable, mapped, fair and reentrant acquisitions are unavailable because this backend cannot track their contract. Constructors and successful guards retain caller locations; an acquisition that waits separately registers its waiting site and requested access. The compiler synchronization gate classifies these as the acquisitions they wrap.

A condition variable keeps the Shuttle adapter’s wait and notify_all interface and an exact count of the waiters in each notification generation. Waiting removes the caller guard’s held lease while the lock is released, and reinstalls it on reacquisition. Its internal state guard’s lease also leaves while the vendor condition variable releases that lock. A notified waiter reacquires the caller’s lock through its tracked adapter, so it can participate in an active cycle.

Installation and active findings. The process installs once, after its signal registration and before any tracked lock or runtime worker, through nervix-deadlock::DiagnosticRun. The server’s main, the diagnostic scenario binary and the Rust paced driver own that placement. A tracked constructor before installation panics with a configuration error; a second installation fails. Installation keeps the vendor banner off standard output by temporarily redirecting that descriptor to /dev/null.

A WaitForGraph finding describes threads currently waiting in a cycle. A self wait is also an active cycle: the mutex adapter checks a failed acquisition against its calling thread’s actual live exclusive guard in an order-instrumented or stressed build and sends that one-thread cycle through the same bounded handoff. This establishes a real incompatible owner/wait pair; it does not infer an active cycle from historical order. Deloxide 1.1.0’s common-held-lock filter otherwise discards this case once instrumentation or stress populates its held set. Recursive mutex acquisition and an upgrade from one’s own read guard remain disposable-process regressions in every selection.

Historical order and source evidence. A LockOrderViolation is a PotentialCycle, never an active outage. For example, a thread can successfully take A then B, release both, and successfully take B then A. The run retains the potential cycle and continues the workload. The pinned upstream graph adds requested mutex and write acquisitions; it does not add requested read edges. Opposite read-only orders complete without an order finding. A read guard held before a requested mutex or write still contributes a historical edge, with its shared mode and multiplicity retained. This limit also excludes some dangerous mixed orders: absence of a finding is not coverage of every reader/writer combination.

The boundary retains directed edges between run-local lock instances, not between construction lines. Each edge carries construction sites, Live, Ended or Unrecorded lock lifetime at correlation, and source witnesses with run-local thread identity, optional name, held and requested sites and modes, attempt count, and the number of identical live guards at the held site. Thread names and construction sites copied into history survive registry removal. Lifetime observations are not an atomic snapshot of all locks. Missing context is explicit. Source attempts include unsuccessful nonblocking calls; only upstream successful acquisitions decide that a historical cycle exists. Attempts are not claimed as successful-acquisition counts.

A complete cycle has no repeated closing node and rotates to its smallest lock identity. Witness sets sort by source context and reject duplicates. Deduplication keys on the directed instance cycle within one process, merges distinct source/mode witnesses, takes the maximum cumulative attempt count for each context, and counts delivered callbacks separately. It preserves direction, lock instances, known construction, ended lifetimes and guard multiplicity. New source or lifetime context invalidates an existing review. It never equates instances merely because a constructor line was reused.

Bounds and handoff. The live registry owns the live lock, waiting attempt and thread records; none of its shard guards cross a tracked acquisition. Order instrumentation adds a run-bounded history of at most 16,384 directed instance edges, 1,024 source witnesses per edge, and 64 guard leases per thread. Ending a lock does not reclaim the historical edge budget. Refused history or count overflow becomes an explicit overload finding. The witness budget retains distinct storage worker identities across checkpoint staging. Its process regression retains all 512 worker contexts in one historical edge; a separate 1,025-context workload must record order-history overload and fail qualification. The independent edge and held-guard limits remain fatal too.

The vendor dispatcher has an unbounded channel and swallows callback panics. The boundary callback therefore only retains bounded identity vectors, submits to its lock-free ReportHandoff of 64 reports, counts refusals, and unparks the findings thread. Each queued active report retains at most 64 threads/waits; an order report retains at most 65 identities to describe a 64-edge prefix. The findings thread correlates source context and calls the sink serially. Missing order payloads are loss, not a clean run. Sink panic aborts. Installation failure closes the handoff. A periodic wake also delivers history overload that produced no upstream cycle.

The history, handoff, output and artifact limits are Nervix retention limits. They do not bound the vendor graph, its cache, its pre-callback allocation or its dispatcher backlog. No exact upstream flush barrier is available: evidence is an observation at the time it is read, and a process can end with upstream callbacks pending. Deloxide 07 owns dependency and upstream graph/dispatcher hardening; full diagnostic workloads retain the actual observed bounds and gaps.

Recording and exit policy. --deadlock-evidence (NERVIX_DEADLOCK_EVIDENCE) selects an existing local directory; without it the run uses standard error. The run first records empty evidence and atomically replaces its own file after each finding. The current header is NVXDLEVD, evidence kind 1, format version 3; unsupported versions fail clearly. The artifact holds at most 16 records, with one slot reserved for retention loss after at most 15 distinct potential cycles. Each cycle retains at most 64 edges and each text at most 512 UTF-8 bytes, remembering truncation. An artifact is at most 128 MiB. Descriptions have a 128 MiB per-process budget; repetitions with unchanged source context update the artifact without repeating the description. Changed context stays visible. Exceeding a bound retains loss and cannot qualify.

An active cycle ends the process with status 3 after recording. Potential order continues with unreviewed evidence. Handoff/history/retention overload, failed recording or output, or a recording operation exceeding its ten-second budget ends with status 4. The deadline is cancelled after a successful potential recording; no detached deadline later kills a continuing workload. On expiry it exits directly: a diagnostic write to a blocked descriptor must not delay the deadline itself. Exit bypasses handlers, as for a forced exit. Ordinary logging, visualization and browser startup are unnecessary. Nothing uploads evidence, logs protected values, or uses source/lock identities as metric labels.

Local inspection and triage. The ordinary nervix-deadlock-report tool needs no detector or running node. Select and export complete current values, inspect the original indexes, and write a reviewed copy of the original process evidence:

just deadlock-report inspect /var/tmp/nervix-deadlocks/deadlock-PID-TIME.rkyv --source potential --export /var/tmp/potential.rkyv
just deadlock-report triage /var/tmp/nervix-deadlocks/deadlock-PID-TIME.rkyv --finding 0 --basis non-overlap --reason 'The owning operation serializes both paths before either lock is acquired.' --regression 'owner::tests::serialized_operations; retained run artifact' --output /var/tmp/reviewed.rkyv
just deadlock-report qualify /var/tmp/reviewed.rkyv

The placeholder artifact and regression names must be replaced with the observed run and a real retained regression. Review uses correction, non-overlap, shared-readers or lifecycle and requires a nonempty, untruncated reason and regression reference. A reader proof checks every recorded mode is shared; other proofs are explicit engineering assertions, not machine verification of a referenced test. A correction cites the fixed owner and the subsequent regression run. A non-overlap proof names the enforced serialization; a lifecycle proof establishes disjoint instance/operation lifetimes. A source line alone proves none of them. Triage keeps the complete original cycle. Missing/truncated context or active findings cannot receive a potential proof.

Qualification of whole-process evidence fails on any active, lost, incomplete or unreviewed finding. A selected export retains its selection scope and cannot qualify its source process, even when the filter yielded no findings. Loss always remains visible through a selection. The qualify command exits 5 for nonqualifying evidence and 4 for an invalid artifact or operation. It qualifies recorded evidence; the engineer also verifies workload completion and successful checks, the run selection, pending-callback limit, revision and declared coverage. Keep reviewed copies separate from files a live recorder replaces. The operator’s workload is not ended merely because an unreviewed historical cycle exists.

The diagnostic lane. just test-deloxide, just test-deloxide-order, just test-deloxide-stress and just test-deloxide-stress-restore run the lane of each selection, and CI’s deloxide job runs all four side by side for every pull request labeled deloxide, the label each change the Deloxide rule applies to carries. A selection is named after the diagnostic build feature it compiles, or extends that name and declares the feature when it runs workloads of the same build in a lane, budget and CI runner of their own; a build records one diagnostic selection in its evidence whichever selection runs it. An invocation runs in every selection unless the inventory names the selections that run it. The stress selection runs every probe, conformance check and owner test of the active-only selection, its stress-scenarios invocation of the one- and three-node lifecycle scenarios tagged @deloxide_stress, which drive client contract transactions and their rejected rollbacks, the drain of buffered branches before an alteration, interleaved branches with remote acknowledgement owners, a lost acknowledgement and a producer restart, session restarts that resume a producer and consumer, a domain restart that replaces its consumer, a memory-pressure pause of scheduled ingestors, a Roto function’s column builder, TLS rebinding of every HTTPS listener, batched inference over interleaved branches, a restarted voter’s ownership, and graceful shutdown with a slow cordon release and diagnostic server processes that stop gracefully, and its stress-paced-simulation invocation of the paced-driver scenarios with the stressed Rust driver. The restore selection runs the build’s probes and its stress-restore-scenarios invocation of the scenarios tagged @deloxide_stress_restore: backup capture and restore of WASM branches, Kafka offsets, deduplicator keys and windows, materialized relays and native metadata, complete restore generations, staging quota and reclamation, delayed restore coordinators, bounded multi-domain backup waits and their recovery, and the drain of a large-state WASM owner through bulk checkpoint streams. Every stressed scenario runs one at a time under its own stress. identity, with the scenario, examples and invariant of its active-only counterpart and the owners that counterpart reaches. tests/deloxide-inventory.toml is the lane’s bounded inventory. It registers every workload a selection runs, each with a stable identity, the invariant it owns, the selections that must run it and the coverage it declares: the disposable-process probes of nervix-deadlock, the tracked locks’ conformance checks of nervix-primitives, the diagnostic owner tests of the server library, and the tagged scenarios with the number of example runs each must make. The three @remote_ack_owners scenarios add four example runs across interleaved branches, a lost acknowledgement and producer restart; every selection exercises the tracked correlation and peer admission owner locks. The @shutdown_kafka_handoff scenario runs in every selection and reaches the ingestor quiesce control’s tracked locks while a stopping node closes intake, waits for its attached emitter’s handoff, and finishes admitted Kafka acknowledgements. The async gate wait, remote delivery and scheduling order remain outside the detector’s observation and have the Cucumber and chaos checks in Shutdown And Recovery. The @shutdown_leader_change scenario also runs in every selection and reaches the tracked entity-gate state while a stopping follower repeats the remaining handoff through a new leader. Its leadership observation, transport waits and shared shutdown deadline remain outside the detector’s observation. Both scenarios carry @deloxide_stress, with separate stress workload registrations that reach their owners under bounded scheduling disturbance. The inventory also bounds the lane: one real-time budget for a selection’s diagnostic compilation and execution after its prerequisites, a bound for every invocation, a stop grace period, the reserve the scenario binary keeps for its own teardown inside its invocation’s bound, and how many scenarios run at once, which is the same on every machine.

The fault harness shares its observation and release barriers through src/fault_injection/pause.rs. The barrier owns watch-backed reached, released and delivered state and the one-shot atomic claim; fault selection remains in the harness’s owning modules. These async observations introduce no tracked blocking-lock owner. Deloxide continues to measure the registered product locks, while the public scenarios and model checks exercise pause ordering at their applicable boundaries.

The registered @restarted_voter_observation workload reaches whole-cluster restart and automatic scheduling when the leader first hears a voter through a relayed heartbeat. Its test-only watch channels and concurrent fault map are untracked. The registered native metadata restore workload exercises distributed Kafka checkpoint replication, acknowledgement, restart and exact restoration above the resident replication message budget; its stream waits and assignment atomics retain their complementary checks.

For each invocation the lane builds the selection’s executable, lists what the build holds or reads the tagged scenarios from the feature files, and refuses a registered workload that the build no longer holds or holds ignored, a discovered probe, conformance check, owner test or tagged scenario that is not registered, a scenario whose example runs changed, and an invocation that selects nothing. It reports how many workloads it discovered, selected, executed and saw complete. Global detector configurations never share a process: each probe workload is a disposable child, each conformance check and each owner test installs its detector in a fresh process, and each scenario binary process installs once in main. Active detection runs its tagged scenarios together; order analysis starts a fresh process per feature and gives the six large materialized restore and four complete-generation restore examples their own processes. These restores each start several nodes and carry large state payloads, so per-example order histories stay within the detector and host budgets. Their order_tags in the inventory select and account for every example. Every diagnostic server and paced driver process installs its own detector. Each selection compiles one diagnostic build, deloxide-stress-restore the stress selection’s under its own name, and the diagnostic builds stay apart from ordinary, fuzz, Loom, Shuttle and Turmoil builds.

The conformance checks are the tracked adapters’ own: which locks a diagnostic build selects, that Debug formatting tries a lock and never waits for it, that a value moves in and out of a lock, the exact count of waiters a notification wakes, the refusal of a second installation, the registry’s record of a thread across its exit, and a tracked wait that proceeds once its lock is released. nervix-primitives sits below nervix-deadlock, which owns evidence files, so a conformance check records none. Its detector’s sink panics on any finding and a panicking sink aborts the process, so the lane ends such a check as signaled, with the finding in its retained output. just test-primitives-deloxide runs the same checks in one process for each selection, beside the other modes’ conformance.

Stress widens timing windows; it does not make untracked waits visible. A Tokio mutex, channel or Notify wait, a DashMap shard, dependency locks, an atomic publication and a wait on another node are invisible to the detector with or without disturbance, and a workload that stalls on one of them ends as timed-out, not as an active cycle. Those owners keep their Shuttle, Loom, Turmoil and Chaos evidence. A dependency’s own channels are invisible in the same way: closing a database waits for the storage engine’s background workers through the engine’s worker channel and active-worker count, with no tracked lock involved, so that close is held by the pinned engine’s own worker-stop tests and the store-close regression beside ApplicationStartup::close_stores, not by the lane. A stressed workload that fails is reproduced first as a focused failing test against its owner before the owner changes.

Every process the lane starts runs in a session of its own. A process that outlives its bound receives SIGTERM and, after the stop grace period, SIGKILL with its whole group. The lane is a child subreaper, so a process that left its group is found too, and anything still running after an invocation ends is killed and fails the lane. The lane names each ending:

EndingLane classLane status
A process reported an active cycle, or a failed invocation’s evidence records oneactive-deadlock3
Recording or output failed, findings or context were lost, or the recording budget expireddiagnostic-failure4
A process outlived its bound, or the lane’s budget expiredtimed-out, budget-expired124
A signal ended a processsignaled1
A test or step failedfailed1
A process was left runningleftover-processes1
A registered workload did not run or ran ignored, or the scenario count differsincomplete1
Evidence is missing, partly written or does not qualifyevidence-missing, evidence-partial, evidence-unqualified1
The inventory, a build or a prerequisite is wronginventory, build-failed, prerequisite-missing1

A failed invocation’s evidence is qualified before the lane reports it, so a node that deadlocked inside a scenario is reported as the active cycle it recorded rather than as the step it failed. After each invocation that records evidence, every process observation must be present, completely written and qualify. nervix-deadlock-report qualify prints one evidence summary: line of counts per file, and the lane reports repeated deliveries of a retained potential cycle apart from findings lost to overload. The first failure ends the lane.

A potential cycle fails the lane. The lane qualifies only the current run’s whole-process evidence, and it keeps no ledger that approves a recurring cycle: lock identities are run-local, and a source line never stands in for a lock instance. A correction removes the order from the exercised workload, and the lane is its regression. A reviewed infeasibility proof qualifies the retained evidence of the run it reviews, for the owner’s investigation and record; it never qualifies a later run.

Each run keeps a fresh attempt directory below <build target>/test-deloxide/<selection>/, which no later run reuses or removes: every log, the probes’ artifacts, every process’s evidence, the description of every finding with its source sites, and lane.json. The record holds the revision and whether the tree was modified, the toolchain, Cargo and Deloxide’s locked version and source, the inventory’s digest, the selection, the features of every build and the bounds, and for every launch its exact command, working directory, environment additions, bound, ending, duration, discovery and accounting, with the qualification of every evidence file. just test-deloxide-replay <lane.json> <launch> runs one recorded launch again, exactly, in a fresh attempt. OS schedules and stress timing are not recorded, so a replay can take another interleaving; deterministic reproduction belongs to Shuttle and Loom.

just test-deloxide-qualification proves the supervision on the probe binary’s real processes, through the lane’s own launch, evidence and classification code. A two-thread deadlock, an evidence directory that cannot be written, an untracked wait that never ends, an aborted process and, in the order selection, an unreviewed serial inversion and retention overload must each fail the lane with its own class and status, keep their output and the evidence they could write, and leave no process behind. A consistent-order control must pass, and the recorded deadlock must replay to the same class.

Under just coverage-native-extras test-deloxide, test-deloxide-order, test-deloxide-stress or test-deloxide-stress-restore, the lane builds in the collector’s instrumented target of the feature its selection compiles, with instrumentation on workspace crates only, so dependencies such as wasmtime’s compiler keep the speed the product’s deadlines assume. It takes the report tool, CLI and shared client binding from the target its prerequisites built, starts every test executable through Cargo’s configured runner, and hands lane.json to the collector, which exports nothing unless the lane completed. CI’s deloxide job runs each selection this way, then the qualification, and uploads the completion records, the diagnostic LCOV reports and every attempt directory whatever the verdict. Diagnostic coverage stays out of ordinary coverage and its CRAP report. The Integration Test Lifecycle chapter owns the job’s bounds and reserves.

Applicability. [[owner]] records in the same inventory cover every source file in which the compiler finds a tracked blocking acquisition in a diagnostic configuration. Each names the registered workloads whose processes reach its tracked locks, the path the lane does not reach and why, or both. just validate-deloxide-applicability, part of just validate and CI’s checks job, reads the acquisition catalog just ratchet writes and fails on an owner without a record and on a record whose file no longer acquires a tracked lock. It reports how many owners registered workloads reach and how many record a path the lane does not reach. The catalog is the compiler’s resolution of the Deloxide adapters’ operations, not a search for lock names. Every diagnostic library and binary configuration enables testing, as the lane’s server builds do, so acquisitions in testing-only fault controls require owner records too. This includes the server’s scheduling, drain and executor-occupancy controls and the consensus storage fault controls. Ordinary and modeled configurations retain their own feature selections. A record is a reviewed declaration: the lane’s coverage report is evidence about it, not a proof of ownership. Format equality, malformed and out-of-bounds rejection, normalized-cycle deduplication and the summary’s counts are registered Bolero properties with the same assertion in ordinary and sanitizer execution.

The native backup-wait workloads reach the client’s pending-reply locks during multi-domain capture, bounded wait expiry and recovery under the same execution reference. They run on one- and three-node clusters in both selections and name the client request and exchange owners in the inventory. The detector observes their blocking reply-registration and cancellation locks. Async command and reconnect mutexes, physical deadlines and network scheduling remain outside its observation; the public scenarios and client-session regressions assert their wait, expiry, leader-change and recovery outcomes directly. The bounded native-client case runs inside the diagnostic harness and reaches expiry and recovery with tracked client locks. The CLI scenarios exercise diagnostic nodes around ordinary subprocesses, whose own locks remain untracked.

The lane’s state-store owner test starts its detector in a fresh process before constructing store or executor locks and runs the current generation regressions: cancellation before and after pointer publication, snapshot readers, queued checkpoint writers, corrupt and incomplete chunks, exact retry after reopen, stale authority, bounded cleanup across 600 checkpoints and a 40 MiB guest save admitted with a 2 MiB reservation. Its evidence is retained beside the probe and scenario evidence. The lane also exercises quota refusal and exact staging retry, partial chunks without receipts across reopen, reclamation with applying and future generations retained, published and initial state preservation, snapshot readers across deletion, and interrupted bounded reclamation resumed after restart. Active-generation owner checks also exercise ordinary and same-revision inline writes, replica installation, handoff, forced recovery, entity and stale-identity purge, queued writers, exact publication retries, referenced-revision cursors and cancelled active deletion across restart. Current-shape storage fixtures also qualify segmented replacement and incomplete unpublished revisions in both initial and restored namespaces, with retained readers and reopen. Retained readers keep complete deleted payloads; SST observations distinguish logical deletion from snapshot release and explicit compaction. Header copies stay bounded, while Fjall’s value size queries and database caches retain their separate allocation owner. The node-local sweeper takes the applied consensus read guard before the checkpoint installation barrier, matching staging and publication; both remain held through bounded deletion. The public quota scenarios retain active staging through completed sweeps and prove failed targets remain gated after reclamation and restart on one and three nodes. The selected scenarios run without retries in a binary built for the mode: in-process diagnostic nodes running a workload, real diagnostic server processes that stop gracefully with evidence that records a running detector and no findings, each on one and three nodes, the buffered client alterations with overlapping holds and failed drains or full-hold engagement, and the restore installation workloads. The latter exercise the blocking applied-state authority guard through staging failure, complete publication, runtime handle clearing and a delayed coordinator across leadership transfer and a successor’s START. The large-generation scenarios cover two 20 MiB saves and forty 1 MiB saves on one and three nodes, failure after durable publication, cluster restart, branch isolation, saved source offsets and positive reclamation after resumed checkpoints and the state purge from module rebinding. The three-node many-save case retains one replica per state and observes reclamation of both owner and replica chunks. The large branch-state scenario restores a 40 MiB deduplicator keyspace and a 40 MiB window beside 24 small branches on one node, through a failure after durable publication, a stopped restore and a resumed one. Once the resumed domain runs, the window persists as a segmented checkpoint under the installation barrier and reopens from it after a cluster restart. Its three-node example runs in the ordinary suite only. Restore workload setup sends durable mutations and activation through the Rust client, which recovers the same execution reference when leadership moves; the raw subscription session keeps its own row stream. Large-generation and staging archive captures use a two-minute command budget so positive capture waits accommodate concurrent diagnostic workloads. The three-node copy restore cordons its coordinator before planning, so every guest save uses the remote chunk transfer and the receiver’s quota-owned completed file. Those restore scenarios also run in the ordinary public suite. The memory-pressure scenarios take the node’s pause lock as the pause collects every registered quiesce control and as each starting ingestor reads the pause, on one and three nodes. The client consumer scenarios take the Rust client consumer’s state and parked-read locks in the scenario process as a consumer is restored after a session restart and closed by a domain restart. The Roto scenario takes the bool column builder’s lock in every push, push_null and finish of an admitted function call, for a selected, a dropped and an absent value. The TLS rebinding scenario replaces the HTTPS listener’s TLS configuration on every node as a TLS VHOST is created and rebound, while each connection the background HTTPS posts open reads it. The inferencer scenario takes a branch’s ONNX session lock for each batch of two interleaved branches. Each of the three runs on one and three nodes. On three nodes with a replica, the deduplicator, window and guest-save restore scenarios leave the owner’s lifecycle and branch checkpoint announcements with the replica’s lifecycle handle, where the replica task takes them under the same lock.

The ONNX inspection admission scenario saturates the leader’s bulk workers and wait queue, observes the typed CREATE INFERENCER refusal, releases the occupancy and creates the same inferencer on one and three nodes. Its paired stress registration reaches the occupancy holders mutex under bounded scheduling disturbance. Metadata inspection keeps ONNX’s operator concurrency at one, so loading and optimization run on the bulk worker the executor admitted. Debug phase events distinguish worker queue delay, session initialization and release while the thirty-second product bound stays in place. The dependency’s own locks and threads remain outside Deloxide; the isolated native owner regression measures the live temporary session’s process thread count, and the public branch-batch scenario retains its exact interleaved-branch output oracle.

Shuttle checks the production handoff’s delivery/refusal and close races, including counter drain, through the registered replay-capable harness, in the Shuttle build where no detector is installed. The queue internals and OS wakeups are opaque; the relaxed refusal counter publishes no other location. OnceLock publishes the installation port; no new independent cross-location atomic protocol is introduced. Existing primitive Loom conformance remains separate. The lane’s supervisor runs outside every diagnostic process: it adds no synchronization to a process it observes and reads only the processes’ exits and the evidence files they wrote. Deloxide models no async or network scheduling. Tokio locks, channels, Notify, DashMap shards, dependency locks, atomic protocols, missing notifications and cross-node waits remain outside its detection claims and retain Shuttle/Loom/Turmoil/Chaos checks. No diagnostic check proves safety for an untracked path or an order the workload did not take.

Diagnostic images under external chaos. The lane runs diagnostic nodes in a scenario binary or as local processes; the external chaos suite runs them as packaged containers under real process, pause and network faults. The diagnostic target of Dockerfile.debian builds the server of one selection, NERVIX_DIAGNOSTIC_SELECTION, with the ordinary CLI and formatter and the nervix-deadlock-report tool, on the release image’s CPU runtime. It declares the selection in its io.nervix.diagnostic.selection label, and every image, release or diagnostic, declares the revision it was built from in org.opencontainers.image.revision. The release targets copy their binaries from the builder’s release artifacts and the diagnostic target from its diagnostic ones, so a build of one kind cannot produce an image of the other, and the diagnostic image never enters a published manifest. just docker-build-diagnostic builds it; CI’s build-diagnostic job builds the deloxide-order image beside the release images whenever a chaos suite runs, and the suites run against both. The chaos runner reads the label: every node records its evidence in a directory of the run, the run freezes the nodes once its Docker event recording is closed, and the image’s own report tool qualifies every file before cleanup. A run fails unless each node holds one complete, qualifying evidence file of the declared selection for every process start the recording shows, and no node ended with status 3 or 4; an unreviewed potential cycle fails it as in the lane. The suite refuses an image whose labels name another kind or revision than its caller asks for. scripts/chaos/README.md owns the runner’s contract and artifacts. The evidence observes each process up to its kill, pause, stop or the freeze, without an upstream flush barrier, and only its tracked thread-blocking locks; recovery, ledger and timing verdicts stay the scenarios’ own, with the product’s deadlines and the suite’s budgets unchanged.

Deterministic Concurrency Verification

The ordering contracts above are checked against the production owners under Shuttle. A check runs several tasks or threads through one scheduler, which chooses an interleaving at visible synchronization points. It can expose a lost notification, an early drain, a double completion, or a stale generation without depending on which OS thread happened to run first. A deadlock is a failed check. The model is the surrounding schedule and test data; the protocol under test is the same type used by the data plane, not a copied implementation of it.

Shuttle does not model elapsed time. Under Shuttle the boundary’s time family is Shuttle’s: a sleep yields once, and a timeout does not measure its deadline and fires only when a check triggers it by task label through nervix_primitives::time::trigger_timeouts. Instant::now still reads the operating system’s monotonic clock, while paused-time controls do not advance a simulated clock. Checks of deadline ordering therefore use an already-passed or far-future instant and explicitly choose whether the timeout wins. Ordinary tests on a paused Tokio clock retain responsibility for actual timer behavior. No concurrency check uses a wall-clock bound, sleep poll, or recv_timeout to establish progress.

Only scheduler-visible operations create interleavings. In a Shuttle build the primitive boundary selects every family from Shuttle or from its own adapters, as the backend matrix shows, and every package that owns the feature forwards it to the boundary, so the synchronization inside vocabulary types such as AtomicTimestamp, inside dependencies such as the execution crate’s cancellation, and inside every connector is Shuttle’s too, and so are the timers of every crate in the graph. The feature changes the test execution environment, not the public protocol. Edge I/O, sockets, filesystem access, and signals remain real and are outside a Shuttle schedule. A Shuttle execution has no Tokio reactor, so a socket created inside a check panics and fails it rather than reaching the network unobserved. A check’s own records use unmodeled atomics on purpose, under the unmodeled permissions, so that recording an operation adds no scheduling point to it.

Some primitives are opaque to Shuttle: arc-swap, concurrent-queue and the futures crate’s atomic waker have no modeled implementation. The boundary runs each ArcSwap and ArcSwapOption load and store, each ArcSwap compare-and-swap and read-copy-update, each operation of a lock-free queue, each registration, wake and take of an atomic waker, and each read and write of a OnceLock between two scheduling points, and calls the primitive directly in ordinary execution. Shared ownership through triomphe or the standard library takes no scheduling point: a reference count is real in every mode. The boundary’s thread module supplies the scheduler-visible synchronous yield that admission spin waits use. A check may claim an ordering around an opaque primitive only when its relevant calls have visible scheduling points. Shuttle cannot interrupt an arbitrary instruction inside it, and a check never claims the primitive’s own memory safety.

A lost wakeup lives between a waiter’s read of some state and its registration for the notification a publisher sends after changing that state. Shuttle’s own Notify keeps its waiter registration behind a real lock and its watch channel reads its version without a scheduling point, so no schedule could place a publication in that window, and a read followed by a registration looked safe in a check even though a release between the two is lost in production. The boundary’s Notify and watch channel keep Tokio’s semantics and take a scheduling point immediately before each registration, each notification and each read of the version that registers or decides, and the atomic waker’s adapter takes one before and after each registration and wake. Tokio’s Notify registers a future for notify_waiters when the future is created, and for notify_one only when it is first polled or enabled; notify_one without a registered waiter stores one permit; a waiter a single notification chose passes it on when it is dropped before it observed it. A watch receiver marks the current version as seen when it subscribes. The conformance checks of crates/primitives/src/tests run the same scripts against Tokio and against the adapters, and the Shuttle checks there show that a publication between a read and the registration is reached: each broken order deadlocks in some explored schedule and the correct one never does. The ownership-handoff checks separately exercise release-before-wake and register-before-read on the production freeze owner. Reversing the latter order deadlocks under both random and PCT schedules, and the failing schedule replays.

Shuttle explores sequentially consistent schedules. It cannot prove that a chosen Relaxed, Acquire, or Release ordering is sufficient on weak-memory hardware; a claim that depends on one is a Loom model, described under Memory-ordering models. Cucumber remains the public behavior test: when a scheduling defect affects an NSPL operation, runtime output, or process outcome, its Shuttle regression is paired with a scenario through that interface. A scenario cannot exhaust the interleavings of an in-process protocol, and neither a Shuttle check nor a Loom model can verify the whole cluster, socket, disk, or browser path.

Check contract and runner

Each check names the invariant in its test name, drives competing operations on the production owner, and asserts the state at meaningful transitions or after all participants have joined. Use an explicit handshake or scheduler-visible yield to position a race. When both the decision read and waiter registration are scheduler-visible, a missed notification leaves the waiter pending and Shuttle reports the resulting deadlock. Name a bounded number of participants so the search remains reviewable; use bounded depth-first search for small races and random plus probabilistic concurrency testing (PCT) for larger ones.

Every check runs through the one Shuttle runner, nervix_model_harness::shuttle: 100 random and 100 PCT schedules of depth three, 1,000 of each with a depth-first search bounded at 1,000 schedules, or one scheduler with the check’s own bounds. Every schedule may take 10,000 steps; one that needs more fails its check, because a step cap is an exploration bound, not a product timeout. A random or PCT search that ran fewer schedules than it declares fails too, and a search that finishes prints a record naming what it explored. SHUTTLE_REPORT_STEPS=1 reports the highest observed step count when tuning a check.

The primitive boundary’s own conformance and race checks run in just test-primitives, which builds the boundary once per mode; its Shuttle checks explore their small models exhaustively with depth-first search, and each deliberately broken order must deadlock in some schedule.

crates/model-harness/shuttle-inventory.toml registers every check of nervix-execution, nervix-interconnect, nervix-client-core, nervix-connector, nervix-consensus, nervix-server and nervix-deadlock by its test’s full name. just test-shuttle lists the library tests whose full names contain shuttle_ in each registered package, built with its shuttle feature, and a run over the whole inventory fails when a registered check is missing or ignored, or when a discovered check is unregistered. Each selected check runs twice, each time in its own process: under the exploration it declares, then under Shuttle’s uncontrolled-nondeterminism detector, which runs 100 random schedules twice each and fails when the second run diverges. A run counts only when its test passed and the runner printed its record, and the command reports how many checks it discovered, selected, executed and saw complete. just test-shuttle <filter> selects the checks whose full name contains the filter in every package; a package with no match is fine, and a filter that selects nothing at all fails. The recipe uses the repository’s kache-backed build and prepares the server’s test dependencies; just test continues to run the ordinary suite. just cargo-clippy-shuttle, whose package checks also run in just lint, lints every Shuttle build with warnings denied, including every inventory package in test mode, where its checks are compiled, so a check that compiles with a warning fails validation.

A failed check leaves target/shuttle-failures/<package>/<fully-qualified-test-name>/: the schedule Shuttle persisted for the failing execution, the run’s output, and metadata naming the check, the run, the revision, the toolchain, Shuttle’s version and the replay command. just test-shuttle-replay <schedule> replays exactly that check in a fresh process with the schedule, which ends where its execution failed and so replays that failure. just test-shuttle-replay-check proves the path end to end: it fails one check deliberately after its invariant held, requires one persisted schedule, and requires it to reproduce the failure in a fresh process. CI’s dedicated shuttle job runs both, only for a pull request labeled shuttle, and uploads the failure directory in a tar archive as the shuttle-failures artifact when a check fails; fully qualified test names contain colons, which the artifact uploader rejects as paths. See the command recipes in Developing Nervix.

Protocols and their checks

The checks below hold the scheduler-visible parts of each protocol to their invariants. Test names are given relative to their owning module; the shuttle feature selects the modeled build. A family of names means each member runs independently through the recipe.

ProtocolInvariant and check
Waiter registration and cancellation (crates/primitives/src/tests/shuttle_races.rs)shuttle_reaches_a_notify_waiters_published_between_a_read_and_the_registration and shuttle_reaches_a_send_between_a_read_and_the_subscription require a waiter that reads before it registers to deadlock in some schedule, which shows the scheduler reaches a publication between the read and the registration; shuttle_registering_before_reading_never_misses_a_notify_waiters and shuttle_subscribing_before_reading_never_misses_a_send require the correct order never to lose one. shuttle_reaches_a_wake_between_a_read_and_the_waker_registration and shuttle_registering_the_waker_before_reading_never_misses_a_wake hold an atomic waker to the same pair. shuttle_a_notify_one_between_a_read_and_the_registration_is_kept_as_the_permit shows a single notification’s permit makes the other order correct for notify_one, and shuttle_a_waiter_cancelled_as_it_is_notified_passes_the_notification_on requires the surviving waiter to complete however a cancellation and a notification interleave.
Execution budgets and storage jobs (crates/execution/src/tests.rs)shuttle_saturated_class_keeps_live_reservations_within_each_class_capacity keeps live reservations within each class capacity; shuttle_occupied_bulk_execution_leaves_control_execution_untouched keeps bulk saturation from charging control; shuttle_queued_job_drop_releases_its_reservation_and_exact_queue_slot and shuttle_running_job_observes_cancellation_and_keeps_its_charge_until_exit balance queue slots and permits across drop and cancellation; shuttle_full_wait_queue_is_exact_typed_backpressure checks a full wait queue’s typed rejection; shuttle_a_job_waiting_for_a_place_takes_the_first_freed_place requires a job that waits for a place to take the first one freed and run before a job refused when full that asks after it; shuttle_a_dropped_job_waiting_for_a_place_gives_up_its_place_and_charge returns a dropped waiter’s charge and hands its place to the job waiting after it; shuttle_consensus_storage_preserves_admission_order_and_returns_every_permit checks storage admission order and permit return.
Force-flush obligations (src/runtime/force_flush.rs)shuttle_two_participant_generation_waits_for_every_obligation prevents completion before all participants live at publication complete and redelivers a dropped, unhandled completion; shuttle_stale_completions_never_clear_a_newer_generation prevents an old completion from clearing new work; shuttle_published_generation_wakes_a_waiting_participant catches a lost publication wakeup; shuttle_participant_lifecycle_balances_obligations_through_close balances pending() across subscribe, request, participant drop, and close.
Ingestor intake (src/runtime/ingestors/source_shuttle_tests.rs; src/runtime/ingestor_quiesce.rs)shuttle_broker_source_observes_engagement_during_dispatch, shuttle_paced_source_observes_engagement_during_dispatch, and shuttle_request_source_observes_engagement_during_dispatch exercise host-loop engagement for memory pressure, entity gate, handoff, and shutdown: after engagement returns, no further payload dispatches, and a change during dispatch is observed. shuttle_an_open_control_answers_intake_without_waiting_on_retained_payloads keeps the open decision independent of a retained-payload lock. shuttle_a_new_quiesce_ends_a_delivery_waiting_for_extension_room and shuttle_a_shutdown_ends_a_delivery_waiting_for_extension_room race the engagement or the stop against a retained payload’s delivery waiting for a place the extension class never frees: the waiter registers before it reads, so the delivery always ends, and its payload is back at the front of the buffer counted as it was. shuttle_a_new_quiesce_decides_again_on_a_live_payload_waiting_for_extension_room and shuttle_a_stop_ends_a_live_payload_waiting_for_extension_room race a BUFFER engagement or the stop against a live payload waiting for such a place: the wait always ends, a new decision becomes the one the payload is decided under next and its buffer retains it, counted with its bytes, and a stop leaves nothing retained.
Broker source confirmation (src/runtime/ingestors/source_shuttle_tests.rs; src/runtime/ingestors/source.rs, src/runtime/ingestors/source_acknowledgement.rs, src/runtime_ack.rs)shuttle_a_drain_waits_for_the_transport_to_confirm_a_resolved_batch lets a drain read the domain’s ownership-handoff count at any point while the graph resolves the two confirmed roots of one batch in either order and the source’s transport takes a round trip to confirm the batch. Whenever the drain reads no outstanding work, every batch whose roots resolved has been confirmed, and the count returns to zero once it is. The check fails under a batch acknowledgement that releases the last root before the loop drops it, and under a root released as it resolves.
Broker source reconnect (src/runtime/ingestors/source_shuttle_tests.rs; src/runtime/ingestors/source.rs)shuttle_a_reconnect_discards_rejections_the_broker_redelivers loses the connection of a broker_redelivered source at any point, once or twice, while the host loop acknowledges two messages one at a time. The host never asks a connection to acknowledge or reject a delivery of a lost one. Every message is acknowledged exactly once, on the connection that delivered it, and each rejection a lost connection failed is discarded and counted exactly once. The check fails under a loop that retries the failed rejection after the reconnect.
Relay dispatch gate and fan-out (src/runtime/relay_channel_shuttle_tests.rs)shuttle_dispatch_permits_never_overlap_a_quiescent_lease_and_release_frees_every_waiter, shuttle_overlapping_gate_leases_all_release_before_dispatch_resumes, shuttle_expired_gate_fence_frees_every_waiter_without_reporting_quiescence, shuttle_canceled_dispatch_returns_its_permit_to_the_gate_fence, and shuttle_dispatches_parked_behind_a_lease_wake_only_on_its_release hold the fence, lease, expiry, cancellation, and waiter contract; in-flight dispatches drain before quiescence. shuttle_capacity_shrink_keeps_buffered_batches_and_wakes_publishers_after_the_drain, shuttle_capacity_growth_admits_waiting_publishers_without_a_take, shuttle_publishers_wait_for_the_slowest_consumer_and_skip_consumers_that_leave, and shuttle_losing_every_consumer_delivers_or_returns_the_waiting_batch keep queued batches across capacity changes, release waiting publishers, and return or deliver each batch when receivers leave.
Relay owner fan-out (src/runtime/relay_boundary_shuttle_tests.rs)shuttle_owner_fanout_fails_its_ack_while_an_attached_consumer_moves keeps a live sibling from completing a source ACK while another attached consumer leaves under a schedule fence; after release, a retry reaches the live consumer. shuttle_a_routed_batch_racing_a_joining_consumer_reserves_a_share_for_every_delivery races a batch another node routed here against an attached consumer registering here: every consumer the batch reaches holds a share of its own, so one that fails the batch fails the source ACK.
Local drain observation (src/runtime/local_drain_shuttle_tests.rs)shuttle_a_batch_published_while_a_drain_observes_is_never_missing_from_it, shuttle_a_batch_a_consumer_takes_while_a_drain_observes_is_never_missing_from_it, and shuttle_a_batch_its_owner_hands_on_while_a_drain_observes_is_never_missing_from_it race a batch moving from a node into a relay, from a relay into a consumer’s node, and from a relay’s transit into its consumer’s queue against one drain observation of the production read order: the observation shows the batch, or the relay’s admission when the move went against the order of its reads. shuttle_a_message_a_flush_resumed_is_seen_by_a_drain_that_sees_the_flush_complete races a force flush that resumes a parked message and completes its obligation against the observation: one that sees the obligation complete sees the message as admitted work.
Relay subscription definitions (src/runtime/relay_subscription.rs)shuttle_an_attachment_racing_a_redefinition_is_refused_or_closed_by_it races a subscriber’s attachment against a redeclaration of the relay with a field that became sensitive: the attachment is refused, or the redeclaration closes the receiver it attached, so no subscriber describes rows by a replaced definition.
Materialized dependency waits (src/runtime/materialized_read.rs)shuttle_a_parked_message_is_woken_by_the_materialized_state_it_waits_for races the arrival of the state a REQUIRED WAIT message is parked on against the processor branch reading it and parking: the wait registers before the read, and again before every retry, so the arrival always wakes it.
Client emitter budget (src/runtime/client_emitter_shuttle_tests.rs)shuttle_competing_consumer_grants_never_exceed_the_node_budget, shuttle_consumer_loss_and_ack_race_release_one_retained_delivery and shuttle_publish_cancellation_and_ack_race_release_one_reservation keep grants within the node budget and release each retained delivery and reservation once. shuttle_a_reservation_waiting_for_a_full_budget_takes_the_bytes_a_release_returns races a release against a reservation that finds the budget full: the reservation registers for the change before it reads the budget, so the release wakes it and it takes the bytes.
Assignment authority and state updates (src/runtime/state_store_shuttle_tests.rs, materialized_state.rs, kafka_offset_state.rs)shuttle_a_rebind_yields_until_the_operation_admitted_under_its_replaced_binding_finishes and shuttle_no_operation_admitted_under_a_superseded_binding_outlives_its_superseding_rebind fence admitted work even when generations reuse an even or odd counter. shuttle_snapshot_installation_never_overlaps_origination_or_a_capture keeps exclusive installation apart from originators and captures. shuttle_an_originator_update_proceeds_while_the_assignment_barrier_is_held and shuttle_a_committed_offset_proceeds_while_the_assignment_barrier_is_held keep ordinary admitted updates independent of a capture’s barrier.
ACK tree (src/runtime_ack.rs)The shuttle_tests checks concurrent_attachment_and_final_ack_leave_exact_tracking, concurrent_wait_and_active_ack_exempt_the_remaining_root, concurrent_wait_release_and_completion_leave_no_tracking, attachment_losing_its_reservation_to_completion_resolves_no_share, attachment_losing_its_reservation_to_completion_parks_no_share, wait_release_racing_the_last_active_ack_holds_domain_and_ingestor_handoff_once, attachment_racing_the_last_active_share_into_wait_publishes_one_active_share, and parked_remote_progress_survives_a_racing_heartbeat_and_resumes_once keep pending, active, and handoff counts exact across attachment, REQUIRED WAIT, and remote progress. concurrent_success_and_failure_choose_one_terminal_transition, fan_out_across_ingestors_with_a_failing_root_resolves_each_root_once_with_exact_counts, and parked_and_fanned_out_roots_hold_exact_counts_at_every_quiescent_point require one terminal result per root, one observed completion, and zero outstanding counts after resolution. a_confirmed_root_stays_counted_until_its_owner_confirms_and_releases_once races a confirmed root’s resolution with its owner taking the resolution and dropping its completion: a resolution the owner holds keeps the root counted, and the counts are released exactly once, after both.
Forwarded acknowledgement silence (src/runtime/remote_dispatch_shuttle_tests.rs)shuttle_a_terminal_outcome_racing_the_final_sweep_resolves_the_share_once races the receiver’s terminal outcome against the sweep that would fail a forwarded share: exactly one of them removes it, and the root delivers that one’s outcome. shuttle_a_report_racing_the_final_sweep_keeps_the_share_it_reached races a report against the same sweep: a report that reached the share keeps it pending with its root unresolved, and only a report that found it removed lets the sweep fail it.
Entity gate and node quiesce (src/runtime/entity_gate_shuttle_tests.rs)shuttle_an_entity_gate_hold_fences_every_relay_and_admits_no_work_until_it_is_released requires admitted work to drain before quiescence and prevents new admission while closed. shuttle_a_work_item_parked_for_materialized_state_is_never_missing_from_a_drain and shuttle_every_node_quiesce_gauge_withdraws_exactly_what_it_contributed keep parked, buffered, and branch work counted without underflow and back to zero. shuttle_every_engagement_waiter_wakes_and_exactly_one_release_takes_the_hold, shuttle_a_hold_dropped_before_its_fence_completes_reopens_every_relay_it_engaged, and shuttle_a_failed_engagement_wakes_every_waiter_with_its_failure cover release, drop, and failure. shuttle_releasing_an_ownership_handoff_wakes_every_waiter_frozen_by_it holds release-before-wake; shuttle_an_ownership_handoff_freeze_observation_registers_before_its_read holds register-before-read against that release.
Interconnect slots and membership (crates/interconnect/src/connection/stream_slots/shuttle_checks.rs, request/shuttle_checks.rs)management_drain_stops_leasing_and_waits_for_every_leased_slot, replication_drain_stops_leasing_and_waits_for_every_leased_slot, bulk_drain_stops_leasing_and_waits_for_every_leased_slot, and relay_drain_stops_leasing_and_waits_for_every_leased_slot keep partition and subquota reservations isolated, forbid leases after drain starts, and wait for every lease to return. racing_registrations_lose_no_handler_and_publish_each_name_once prevents a lost handler registration and duplicate name. a_membership_change_between_a_callers_check_and_its_wait_is_never_lost prevents a missed discovery wakeup.
Shutdown and signals (src/application/shutdown.rs, termination_signals.rs)shuttle_racing_stop_requests_accept_exactly_one_and_keep_its_deadline retains the first stop request and its deadline. shuttle_phases_only_advance_and_every_completion_waiter_observes_the_one_outcome keeps phase order and one completion. shuttle_an_expired_deadline_and_a_repeated_signal_let_exactly_one_forced_exit_end_the_process and shuttle_a_repeated_signal_before_the_deadline_ends_the_process_with_the_status_of_that_signal give one forced-exit claimant and the exit status of the cause that won.
Brokered sink confirmation window (crates/connector/src/confirmation_window_shuttle_tests.rs)Each check hands records to the production ConfirmationWindow while broker tasks answer them concurrently through channels of their own, each recording when its answer could be read, and the check notes which answers were given before the publish began to end. shuttle_a_failure_settles_every_answer_its_broker_gave_before_it ends the publish with the driver’s failure: every record whose answer was given before it is delivered or rejected as answered, and a failed answer leaves its record unresolved. shuttle_answers_racing_a_full_window_settle_each_record_exactly_once hands five records through a window of two whose answers arrive in any order: the window holds fewer than two answers after each hand-over, and every record is settled exactly once as its answer says. shuttle_an_expired_oldest_settles_the_answers_given_before_it lets a check-triggered timeout expire the publish’s deadlines at any point among the answers, which a Shuttle timeout does before it polls the answer: a missed deadline still settles every answer given before it, the oldest’s own included. shuttle_a_failed_oldest_answer_settles_the_younger_answers_given_before_it ends the publish with the oldest’s failed answer and still settles a younger answer given before the end.
Emitter batch payloads (src/runtime/emitter_record_writes_shuttle_tests.rs)shuttle_a_retried_payload_acknowledges_each_fanned_in_member_once_after_every_emitter and shuttle_a_sibling_failure_resolves_each_fanned_in_member_once_despite_a_retry fan two source messages out to a batching emitter and a sibling: each source acknowledgement completes once, successfully only after both emitters confirmed it, and the retry writes the retained payload’s first bytes. shuttle_a_cancelled_attempt_leaves_each_member_to_resolve_once cuts an attempt short at any point and requires the next one to write only unanswered payloads and deliver each rejected member’s message error once. shuttle_a_drain_never_finds_the_emitter_empty_while_a_member_is_retained races a drain’s reads against a stalled write and the force flush that repeats it.
Client ingestors (src/runtime/client_ingestor_shuttle_tests.rs)shuttle_racing_reservations_never_exceed_the_node_budget_and_return_every_byte races opens that each need more than half the node’s producer budget: at most one holds it at a time and every reservation returns its bytes. shuttle_a_batch_racing_a_quiesce_is_either_counted_by_its_drain_or_refused_undispatched races the admission fence against an engagement and its drain: no batch is dispatched after the drain concluded. shuttle_a_closing_producer_answers_every_admitted_batch_once_before_its_release and shuttle_an_ending_endpoint_answers_every_batch_once_and_ends_its_producer_last race a close or an endpoint end against the worker’s admission reports and the batches’ acknowledgements: every batch is answered exactly once, a close answers each with its real outcome before the release, and an end reports no admitted batch as not admitted and comes last. shuttle_a_detach_racing_a_clearance_admits_only_a_cleared_batch_and_returns_its_slot races a forwarded producer’s detach against the clearance of its batch while a local producer waits for the window’s one slot: the forwarded batch reaches the worker only after its clearance was recorded, and the local batch is admitted whichever comes first, so no slot leaks. shuttle_an_end_racing_clearances_reports_a_batch_not_admitted_exactly_when_the_worker_never_took_it races an endpoint end against the clearance of two batches while the worker holds the first without reporting it: each batch is answered once, not admitted exactly when the worker never took it, whether it was still being cleared or cleared and waiting for the worker, and of unknown outcome when it did.
Rust client submission slots (crates/client-core/src/producer/slots_shuttle_tests.rs)shuttle_a_wait_racing_its_resolution_takes_the_outcome_once_and_returns_the_credit, shuttle_a_cancelled_wait_loses_neither_the_outcome_nor_the_credit, and shuttle_a_release_racing_its_resolution_returns_the_credit_exactly_once race a submission’s resolution against the application’s wait, an aborted wait followed by a new one, and a release: the outcome is taken at most once, a cancelled wait leaves it retrievable, and the credit comes back exactly once.
Rust client attachment recovery (crates/client-core/src/producer.rs, consumer.rs)shuttle_close_fences_a_producer_restore_started_on_the_same_exchange and shuttle_close_fences_a_consumer_restore_started_on_the_same_exchange race close against beginning restoration. shuttle_close_fences_a_producer_restore_interrupted_by_another_loss and shuttle_close_fences_a_consumer_restore_interrupted_by_another_loss race close against another loss while restoring. Each check uses the production lifecycle owner and requires the final phase to remain closed, with subsequent restoration refused. shuttle_a_reply_racing_the_end_of_its_exchange_reports_the_gap_once races a consumer’s reply against the end of its exchange: the interruption is reported once, whichever observes the end first. shuttle_a_read_parked_while_its_consumer_closes_does_not_outlive_the_close races a read that parks for delivery against the close: the read ends with the close. shuttle_a_submission_racing_its_producers_end_observes_the_end races a submission’s wait against its producer’s end, and shuttle_a_reader_racing_a_reported_gap_takes_it_without_another_change (subscriptions.rs) races the application’s next read against a subscription event the follower reports: each waiter subscribes before it reads, so neither waits for a later change.
Rust client domain clock restoration (crates/client-core/src/domain_clock.rs)shuttle_a_detach_racing_a_restoration_leaves_no_attachment_of_its_clock races a detach of an interrupted clock against the production restoration decision, with a modeled session that serves both in the order it receives them and applies each reply through the production owner: once the detach is answered the session holds no attachment of the clock and the client does not follow it, whether the restoration attached first, waited for the detach, or found the clock released.
Relay branch presence (src/runtime/relay_branch_presence_shuttle_tests.rs)shuttle_an_observer_sees_every_owner_step_whole_and_never_an_older_one races an owner at capacity one through admission, eviction, recreation and release against an observer that registers and reads throughout: every read is a membership the owner published whole and never older than the step the owner had finished. shuttle_capacity_and_expiry_publish_whole_memberships keeps every read within the owner’s capacity and drops an expired branch from every read after the expiry. shuttle_a_replaced_owner_never_publishes_over_its_successor races a predecessor’s admissions, expiry and release against its successor’s claim: once the claim is visible no read holds a branch only the predecessor admitted, and the successor’s branch survives the predecessor’s release.
Checkpoint replication (src/runtime/kafka_offset_state.rs, src/runtime/state_replication/checkpoint_announcement_shuttle_tests.rs)shuttle_a_replica_acknowledgement_racing_the_quorum_wait_is_never_missed races a Kafka offset commit’s replica quorum wait against its replica’s acknowledgement: the wait registers before it reads, so it completes without its deadline, which a Shuttle timeout only reaches when a check triggers it. shuttle_an_offer_racing_the_end_of_an_announcement_is_always_announced races a second offer against the announcer of the first finding its replica caught up: the second revision is always announced and acknowledged. shuttle_a_retired_replication_ends_its_announcer ends an announcer whose replicated state goes away while its replica never acknowledges. shuttle_an_announcement_racing_the_replica_wait_is_never_missed races an owner’s announcement against the replica task’s synchronization and wait: an announcement that lands before the wait is kept as its permit. shuttle_checkpoint_announcement_close_cancels_pending_dispatch exercises the production task owner with close racing the first poll and close after dispatch starts: both cancel a dispatch that never becomes ready and release its retained announcer.
Branch snapshot task ownership (src/runtime/state_replication/tasks_shuttle_tests.rs)shuttle_a_closed_snapshot_task_owner_is_waited_for_until_every_task_has_written races a runtime closing the production task owner against three branches: one that stops in time, one ended at its stop grace that drops what it held, and one that never tells its snapshot task anything. The wait returns only once every task has made its last write, and a task that waited for its branch alone would hang it, which Shuttle reports as a deadlock. The task body stands in for the write; the tracker and token are opaque dependencies.
Resolved state replication (src/runtime/state_replication/routing_shuttle_tests.rs)shuttle_state_replacement_and_retirement_fence_frames_in_flight exercises the production routing owner: a frame can complete only on its selected lifetime, never change a successor, and cannot enter a route after retirement. ArcSwap internals remain opaque; this check covers owner use and scheduling, with no new Nervix-owned memory-ordering claim.
Replica catch-up announcements (src/runtime/branch_lifecycle_state_shuttle_tests.rs)shuttle_an_announced_branch_racing_the_replica_round_is_never_missed races an owner’s announcement of a branch checkpoint against the replica task taking the pending announcements and waiting for the next: the task takes it whether it lands before the take, between the take and the wait, or during the wait. shuttle_announcements_of_one_branch_keep_the_newest_pending delivers two announcements of one branch in either order while the task takes them: an older one never replaces a newer one still pending.
Backup capture, restore publication and reclamation (src/runtime/backup_capture_fence.rs, src/runtime/branch_state_capture_shuttle_tests.rs, crates/consensus/src/restore.rs, materialized_state.rs)shuttle_backup_cut_includes_pre_cut_branch_publication includes every registered publication before a cut. shuttle_a_forced_deduplicator_publication_holds_every_key_acknowledged_before_the_cut and shuttle_a_forced_window_publication_holds_every_step_acknowledged_before_the_cut race a branch task’s admissions or window steps against the cut’s checkpoint request over the branch task’s own command and input channels: once the task answers, the keyspace or window the capture reads holds every change acknowledged before the cut asked. Restored deduplicator and window checkpoints install through the same staged publisher and domain handle clear as the other kinds, so the restore checks below hold their installation order too. shuttle_restore_publication_and_handle_clear_precede_resume_and_fence_delayed_publishers drives the applied-state authority guard against a successor generation and RESUME, requiring complete publication and cleared handles before activation. shuttle_materialized_capture_names_exactly_its_branch_generation races capture with an update and eviction. shuttle_replica_synchronization_preserves_the_restored_materialized_revision races delayed and current owner snapshots. shuttle_restore_reclamation_holds_the_revision_through_queued_installation races the reclamation guard with a successor generation and queued installer, requiring the borrowed revision to stay fixed through deletion and preserving the current installation. They register in the shared Shuttle inventory and use its exploration, nondeterminism and replay contract.
Domain clock (src/runtime/domain_clock.rs)shuttle_lifecycle_tests::concurrent_reads_of_one_installed_generation_never_decrease checks the nondecreasing watermark; a_clock_bound_to_a_replaced_generation_is_refused_by_revalidation rejects a superseded generation; readers_never_observe_an_installation_older_than_one_they_observed prevents publication regression. shuttle_delivery_sends_state_before_ticks_without_regressing_progress explores the production observer and attachment delivery order across accepted ticks, same-generation unassignment and reassignment, and a generation change. shuttle_an_attach_waiting_for_the_first_installation_observes_its_domains races an attach’s wait and lookup against the node’s first installation of the committed domains and requires the lookup to find the domain and its clock. a_logical_waiter_wakes_when_its_generation_stops, a_logical_waiter_wakes_when_its_generation_is_replaced, a_logical_waiter_wakes_when_its_domain_is_removed, and a_logical_waiter_wakes_when_a_replacement_mapping_reaches_its_deadline cover each lifecycle wakeup.

The checks of WASM checkpoint holds and the durability barrier use the same runner and replay contract. Among them, shuttle_an_abandoned_writers_synchronization_keeps_the_barrier_until_it_ends and shuttle_an_abandoned_writers_failed_synchronization_still_refuses_what_it_covered hold a synchronization round to the storage job that runs it: a writer that stops waiting frees neither the barrier nor the round’s failure. Their state semantics live in the WASM state documentation; they do not turn Shuttle into a disk or replica simulator. Deterministic interconnect simulation and Cucumber cover the network and process behavior outside this in-process scheduling boundary.

shuttle_a_vocabulary_atomic_is_a_scheduling_point_in_the_server_build (src/shuttle_selection_tests.rs) holds the selection itself to account: in the server’s Shuttle build, an operation on the vocabulary crate’s AtomicTimestamp must advance Shuttle’s scheduling-point count, which it can only do when the feature reached that dependency’s atomics.

Memory-ordering models

A claim that one thread’s writes are visible to another because of an ordering, rather than because both threads were scheduled in some order, is checked under Loom. Loom runs a model once for every schedule and every reordering the C11 memory model allows that it can distinguish, so a Release weakened to Relaxed shows up as an execution in which a reader sees the flag but not the write it was meant to publish. A model drives the actual synchronous production owner from Loom threads with Loom’s atomics, both selected through the primitive boundary by the package’s loom feature. When a protocol is embedded in asynchronous orchestration, the synchronous protocol is made independently testable and the runtime uses that same owner; a copied algorithm, a witness that a join or an extra lock publishes, or a real atomic does not make a model.

The execution, interconnect, consensus and server crates own a loom feature, and each forwards it to the primitive crate and to every dependency that owns one, so the whole library graph of each builds with Loom’s primitives. just cargo-clippy-loom, whose package checks also run in just lint, lints every Loom build: the models and their harness, the primitive boundary, and the server and consensus libraries both as they ship and in test mode, where models of their owners are compiled.

Each model names its invariant with an InvariantId and runs through nervix_model_harness::loom::explore, which explores it to exhaustion: no preemption bound, no permutation or time budget, a branch limit of 1,000 thread switches per execution that fails the model rather than ending the search, and Loom’s full thread count. A Loom setting in the environment that would change that search is refused. A completed search prints a record naming its invariant, its execution count and its bounds, and just test-loom accepts nothing else as a completed model.

Discovery, model execution, qualification and checkpoint replay use the workspace’s loom build profile. It inherits dev, retains debug assertions, overflow checks, debug information and the server’s allocation instrumentation, and compiles with optimization level one to reduce the allocation frames on Loom’s fixed coordinator stack. Qualification cleans only this profile’s package artifacts before and after each weakening, so a weakened artifact cannot become an ordinary build input or satisfy another qualification.

The coordinator starts and joins the model body without touching protocol state. The body and participants started through nervix_model_harness::loom::spawn each request a 1 MiB coroutine stack for instrumented allocators and debug frames. The coordinator retains Loom’s fixed default stack; its initial allocation and spawn path must fit before the larger body stack can run. Loom forwards that request to its coroutine implementation, whose allocation units need not be bytes. Its five thread slots include the coordinator, body and at most three other participants. The harness stack model executes a 64 KiB frame on both the body and a participant; reducing the stack request must reproduce the overflow and replay it from its checkpoint. Scheduling and memory-ordering checks still explore to exhaustion.

crates/model-harness/loom-inventory.toml registers every model by invariant. just test-loom lists the loom_* library tests of every registered package built with its loom feature, and a run over the whole inventory fails when a registered invariant’s test is missing, ignored or did not complete, or when a discovered model is unregistered. Each model runs in its own process, and the command reports how many models it discovered, selected, executed and saw complete; a filter that selects none fails. A failed model leaves target/loom-failures/<package>/<invariant>/: the Loom checkpoint of the failed execution, the run’s output, and metadata naming the invariant, revision, toolchain, Loom version and exploration bounds. just test-loom-replay resumes Loom from that checkpoint with location tracking and tracing, so the failed execution runs first. The artifacts hold model output only, never payloads or secrets. For a pull request labeled loom, CI runs the models in its dedicated loom job, which uploads the failure directory, and their qualification in the loom-qualification jobs. Invariant IDs use ASCII letters, digits, dots and hyphens, so these paths are valid artifact names; metadata retains the exact Rust test name used for replay.

Every model also registers a weakening that must make it fail. just test-loom-qualification applies each to a copy of the working tree, requires the model to fail with the registered message, and requires the checkpoint of that failure to replay it. This is what shows a model depends on the ordering it claims, rather than passing because something else synchronized its threads. A weakening whose original text no longer appears exactly once fails as well, so changing an owner’s ordering means revisiting its qualification.

Successful qualifications retain target/loom-qualification/<qualification-id>/ as well: the counterexample checkpoint, the checkpoint advanced during replay, both outputs and metadata with the revision, toolchain, exploration bounds and complete registered weakening. CI uploads these records whether the qualification job passes or fails. To replay a retained counterexample, restore its recorded revision, apply its recorded weakening in an isolated worktree and run just test-loom-replay <directory>; replay against the unweakened owner is a different check. The isolated build copy is still removed after qualification, and an unsuccessful control retains its output and any checkpoint it produced.

Loom models a SeqCst access as an acquire or a release, and models only fence(SeqCst) exactly. A protocol in which each side writes one location and then reads the other, such as a dispatch that raises the in-flight count before it reads the closed flag while a fence closes the gate before it reads the count, is therefore written so that its correctness follows from acquire and release alone. The side off the hot path reads the other side’s count with a read-modify-write, which reads the newest value in that count’s modification order: either the hot side’s read-modify-write came first and the cold side’s read sees it, or the cold side’s came first and the hot side’s read-modify-write acquires it, and with it the write the cold side made before. That is exact in the C11 model and checkable by Loom, and it costs the hot path nothing. The qualification of such a model weakens the cold side’s read-modify-write to a load, which is the store-buffering fault the protocol rules out.

Loom’s own limits bound every claim. It does not model every relaxed behavior the C11 model permits, and an operation inside a third-party dependency, such as a triomphe reference count or an arc-swap publication, is invisible to it and excluded from the claim rather than given a fictional model. The client batch admission fence reads its quiesce decision from such a publication, so its model checks the root counts the admission and the drain reach by read-modify-write, and the publication’s own ordering stays outside the claim. A relay’s subscription definition is another: an attachment and a redefinition each put a sequentially consistent fence between their own write and the read of the other’s, but the attachment’s write is its registration in the fan-out’s consumer list, an arc-swap publication, so the protocol has no Loom model and its Shuttle check orders the two. The state store’s durability barrier orders each writer’s ticket after the write it covers and each synchronization’s start before its flush, but those writes and the flush are the database’s, inside its own synchronization, so it has no Loom model either; its Shuttle checks hold which tickets a synchronization covers. A standalone counter carries no cross-location claim, whatever its ordering. Relay branch presence is such a case: its owner lifetimes and publications are arc-swap compare-and-swap and read-copy-update operations with no Nervix-owned atomic beside them, so it has no Loom model; its Shuttle checks order its publications against observers and successors. Checkpoint replication is another: its announcement and progress change under one placement’s lock, and its wake-ups are the boundary’s Notify, with no Nervix-owned atomic beside them, so it has no Loom model either; its Shuttle checks order an announcer’s end against offers, and a wait’s registration against reports and announcements.

InvariantClaimModel and qualification
execution.cancellation.publicationA job that observes its cancellation also observes every write its awaiting caller made before the cancellation: raising the flag releases and observing it acquires. The witness is read the moment the job observes the cancellation, before any join could order the two threadsloom_a_job_that_observes_cancellation_observes_every_write_made_before_it (crates/execution/src/cancellation.rs); fails when either the raising store or the observing load is weakened to Relaxed
execution.cancellation.cancel-on-dropDropping an armed obligation cancels its job: once the drop is ordered before a check, every clone of the job’s signal reports it, and a check never loses a cancellation an earlier check observedloom_dropping_the_obligation_cancels_every_later_check_of_the_job
execution.cancellation.disarmA disarmed obligation never cancels its job, whether the job checks while it is disarmed or after it is dropped. Disarming writes nothing, so the model has a single schedule and fails if disarming or the drop after it ever raises the flagloom_a_disarmed_obligation_never_cancels_its_job
runtime.relay-dispatch-gate.entry-fenceA relay dispatch fence counts every dispatch that finds the gate open: a dispatch raises the in-flight count before it reads the closed flag, an engagement closes the gate before it reads the count, and both reach the count by read-modify-write, so one observes the otherloom_a_fence_counts_every_dispatch_that_finds_the_gate_open (src/runtime/relay_channel_loom_models.rs); fails when let in_flight_dispatches = self.in_flight_dispatches.fetch_add(0, Ordering::AcqRel); weakened to let in_flight_dispatches = self.in_flight_dispatches.load(Ordering::Acquire);
runtime.relay-dispatch-gate.lease-publicationA fence that takes its lease observes everything each dispatch it counted did under its permit: leaving releases and the fence’s read of the count acquiresloom_a_lease_follows_what_every_counted_dispatch_did_under_its_permit (src/runtime/relay_channel_loom_models.rs); fails when a dispatch’s leaving is weakened from AcqRel to Relaxed
runtime.relay-dispatch-gate.drain-wakeupThe last dispatch to leave a closed gate wakes the fence waiting for it: either the fence’s read of the count sees the dispatch gone, or the dispatch’s leaving acquires that read and observes the gate closedloom_the_last_dispatch_to_leave_a_closed_gate_wakes_its_fence (src/runtime/relay_channel_loom_models.rs); fails when let in_flight_dispatches = self.in_flight_dispatches.fetch_add(0, Ordering::AcqRel); weakened to let in_flight_dispatches = self.in_flight_dispatches.load(Ordering::Acquire);
runtime.relay-dispatch-gate.reopen-publicationA dispatch that finds the gate reopened observes what the protected mutation changed while it was closed: reopening releases and the dispatch’s read of the flag acquiresloom_a_dispatch_that_finds_the_gate_reopened_observes_the_protected_change (src/runtime/relay_channel_loom_models.rs); fails when .store(!state.engagements.is_empty(), Ordering::Release); weakened to .store(!state.engagements.is_empty(), Ordering::Relaxed);
runtime.relay-fanout.admission-wakeupA consumer that frees room a waiting publisher needs either admits it or wakes it: the publisher raises the waiting count before it reads the admission count, the consumer lowers the admission count before it reads the waiting count, and both reach the admission count by read-modify-write, so one observes the otherloom_a_consumer_freeing_room_admits_or_wakes_the_publisher_waiting_for_it (src/runtime/relay_channel_loom_models.rs); fails when let admitted = self.admitted.fetch_add(0, Ordering::AcqRel); weakened to let admitted = self.admitted.load(Ordering::Acquire);
runtime.state-assignment.admission-fenceAn operation admitted under a runtime-state binding that a rebind supersedes either observes the new binding and is refused, or finishes before the rebind returns with everything it did visible to the rebinding side: the operation counts itself in before it reads the binding, the rebind publishes the binding before it reads the count, both reach the count by read-modify-write, and finishing releases what the read acquiresloom_an_operation_admitted_under_a_superseded_binding_never_outlives_its_rebind (src/runtime/state_store_loom_models.rs); fails when while admitted.fetch_add(0, Ordering::AcqRel) != 0 { weakened to while admitted.load(Ordering::Acquire) != 0 { or .fetch_sub(1, Ordering::Release) weakened to .fetch_sub(1, Ordering::Relaxed)
runtime.client-ingestor.admission-fenceA client batch whose admission races a quiesce is either counted by the drain that follows the quiesce or refused before anything is dispatched under it: the admission tracks its root by read-modify-write before it reads the quiesce publication, the quiesce publishes before the drain reads the root counts, and the drain reads each count by read-modify-write, so one side observes the otherloom_a_client_batch_racing_a_quiesce_is_counted_by_its_drain_or_refused (src/runtime/client_ingestor_loom_models.rs); fails when self.outstanding.fetch_add(0, Ordering::AcqRel) weakened to self.outstanding.load(Ordering::Acquire) or self.ownership_handoff_outstanding.fetch_add(0, Ordering::AcqRel) weakened to self.ownership_handoff_outstanding.load(Ordering::Acquire)
runtime.local-drain.moving-batchA batch a node publishes into a relay while a drain observes the domain is never missing from the observation: the relay admits the batch before the node lets it go, the observation reads every relay’s admission sequence before and after the node’s count, and acquiring the node’s release makes the admission visible to the closing readloom_a_batch_published_while_a_drain_observes_is_never_missing_from_it (src/runtime/local_drain_loom_models.rs); fails when self.admitted.load(Ordering::Acquire) weakened to self.admitted.load(Ordering::Relaxed)
runtime.local-drain.taken-batchA batch a consumer takes from a relay while a drain observes the domain is never missing from the observation: the consumer’s node counts the batch before the consumer returns the relay’s admission, the observation reads the relay before the node, and returning the admission releases the node’s count to the observation that acquires itloom_a_batch_a_consumer_takes_while_a_drain_observes_is_never_missing_from_it (src/runtime/local_drain_loom_models.rs); fails when the consumer queue’s return of an admission is weakened from AcqRel to Relaxed
runtime.local-drain.handed-on-batchA batch a relay’s owner hands on to its consumer while a drain observes the domain is never missing from the observation: the consumer admits the batch before the owner hands it on, the observation reads the relay’s transit before its consumers’ queues, and handing on releases the consumer’s admission to the observation that acquires itloom_a_batch_its_owner_hands_on_while_a_drain_observes_is_never_missing_from_it (src/runtime/local_drain_loom_models.rs); fails when self.handed_on.fetch_add(1, Ordering::Release); weakened to self.handed_on.fetch_add(1, Ordering::Relaxed);
runtime.local-drain.flush-completionA message a force flush resumed is admitted work to a drain observation that sees the flush complete: the participant resumes the message before it completes its obligation, the observation reads the obligations before the node’s work, and acquiring the completion makes the resumed message visibleloom_a_message_a_flush_resumed_is_seen_by_a_drain_that_sees_the_flush_complete (src/runtime/local_drain_loom_models.rs); fails when self.force_flushes.load(Ordering::Acquire) weakened to self.force_flushes.load(Ordering::Relaxed)
runtime.kafka-offset-state.snapshot-revisionA Kafka offset snapshot stamped with a commit’s revision encodes that commit’s offset: the commit stores the partition’s offset before it advances the revision, the snapshot reads the revision before the offsets, and advancing releases what reading the revision acquiresloom_a_snapshot_carrying_a_commits_revision_carries_its_offset (src/runtime/kafka_offset_state.rs); fails when LsmSequence::advance is weakened from SeqCst to Relaxed
server.backup.cut-includes-publicationA quiesced backup cut waits for every branch state publication that registered before the cut closed, then reads every write those publications completed: closing and registering are read-modify-writes of one word, a publication leaves with a release and the cut’s wait acquiresloom_a_cut_includes_every_registered_publication (src/runtime/backup_capture_fence.rs); fails when a publication’s leaving is weakened from Release to Relaxed
server.backup.cut-generationA branch state publisher that enters after a backup cut acquires the cut generation and sees the coordinator’s writes before it: reopening extends the closing release sequence, and a publisher’s registration acquires itloom_a_publisher_acquires_the_cut_generation (src/runtime/backup_capture_fence.rs); fails when the registration is weakened from AcqRel to Release
harness.loom.coroutine-stackA model body and the participants it spawns run debug frames with allocation instrumentation on the coroutine stacks they request, while Loom’s coordinator only starts and joins the bodyloom_model_coroutines_support_instrumented_debug_frames (crates/model-harness/src/loom.rs); fails when the stack request is cut to 4_096

The cancellation protocol these models check is the bounded executor’s. Cancellation::armed creates both ends of one job’s cancellation: the executor keeps the obligation while its caller awaits the job and moves the signal into the job, which checks it between its bounded units. Dropping the awaiting caller drops the obligation and cancels the job, which keeps its memory charge until it returns; observing the job’s value disarms the obligation first, so an ordinary completion never reports itself as cancelled. The executor keeps admission, charges and cancellation policy; the protocol it runs is the one the models explore.

SIMD Kernels

Nervix applies explicit SIMD to contiguous typed buffers where generated-instruction inspection and measurement justify it. The optimization includes the caller: decoding into Arrow builders, resolving a column once, retaining bitmaps, or folding a whole run removes the per-row structural work that would otherwise hide a faster arithmetic loop.

This chapter owns the kernel boundary, dispatch, result guarantees, caller catalog, qualification, and measured evidence. Data Plane owns batches, branches, persistence, and ACKs; VM Functions owns expression execution and window structures; Domain Clock owns time and admission; Data-Plane Concurrency owns synchronization and execution modes. Schemas And Codecs, Syslog, and Connector Crates And The Connector Contract retain their wire contracts.

Ownership And The Admission Rule

nervix-simd-kernels, in crates/simd-kernels, belongs to the primitives layer, below the vocabulary. It owns portable operations on slices, offsets, validity bytes, and bitmask words. It depends on fearless_simd, simdutf8, pointer-width conversions, and self-contained error values. It must not know Arrow, Models, schemas, domain clocks, codecs, metric recorders, connectors, branches, or execution graphs. A nanosecond buffer is an integer buffer here; the caller gives those integers their meaning.

A new explicit kernel needs all three:

  1. Inspection of the current caller’s optimized code shows scalar or lane-extracting work. A source loop, a batch API, or an operation’s name is insufficient evidence.
  2. Every supported level and the forced fallback reproduce the complete scalar result, including failure bits, null masking at the caller, and unused tail bits. Integer, bitmap, and byte operations are exact. Window floating reductions follow the run contract below.
  3. The caller supplies a typed contiguous slice or bitmap. Per-row boxing, field lookup, virtual type selection, or reconstruction from values already held in columns must be resolved first.

Kernel ownership does not grant clock, scheduling, synchronization, or allocation policy. The caller retains its bounded-executor admission, cancellation between bounded units, memory charges, and branch-local state. A kernel neither reads a clock nor starts a task. Its output is transient values and masks, never a new persisted execution representation.

The repository’s cast denial, checked arithmetic, typed errors, ownership headers, and debt ratchets apply here. Source-owned compiler diagnostics and narrow reason-bearing expectations govern architecture rules; a generated report or an approval count grants no exception.

Dispatch And Build Targets

The crate caches one fearless_simd::Level::new() result in a process-wide OnceLock from nervix_primitives::unmodeled::sync. The exact-file permission explains why CPU capability detection may outlive individual model executions. It carries no protocol state or ordering that a concurrency check relies on. Later calls reuse that level; short byte searches can return before resolving it at all.

dispatch! enters the selected target-feature context. Small #[inline(always)] functions generic over S: Simd perform the buffer loop there, using simd.vectorize where required. The dispatch expression returns values or masks. Fallible validation and contextual errors stay outside it; a dispatch body does not use ? or return from its enclosing caller. Callers name operations and types, and do not choose an instruction set or use std::arch themselves.

LevelWhere it is availableSelection contract
SSE2x86 and x86-64The supported x86 baseline where no higher level is selected
SSE4.2x86 and x86-64Requires the level’s complete feature set, including its auxiliary features
AVX2x86 and x86-64Includes the associated features required by fearless_simd, rather than testing AVX2 alone
AVX-512x86 and x86-64The complete Ice Lake class feature set; AVX-512F alone is insufficient
NEONAArch64Selected from the target’s NEON capability
WASM SIMDwasm32 built with simd128A build capability; browser feature detection does not dynamically enable it inside an already compiled module
FallbackTargets without a supported SIMD level, and explicit testsPortable scalar implementation; LLVM may still auto-vectorize it for the ambient build target

The workspace enables force_support_fallback so tests can explicitly request Level::fallback() even on SIMD hosts. This makes the fallback implementation reachable; it does not make an AVX2 binary runnable on a CPU below that binary’s baseline.

BuildWhat the payload guaranteesWhat runtime selection adds
Debian x86-64 release imageDockerfile.debian defaults cargo-sonic payloads to x86-64-v3, including AVX2The same payload can select the Ice Lake class AVX-512 arm on a supporting host
AArch64 release imageThe AArch64 target and its NEON capabilityNEON execution; x86 levels do not exist in this build
Local target-cpu=native buildFeatures of the machine used to compile itSelection cannot lower that ambient baseline; a native AVX-512 build can dispatch lower x86 tokens through its AVX-512 backend
Untuned local buildThe compiler target’s baselineHost-supported higher levels through multiversion dispatch
WASM buildSIMD only when simd128 is enabledSelection within that module’s compiled capabilities; no claim that the browser console invokes these server kernels

The effective compiler flags matter. A native build’s nominal SSE or AVX2 test token does not prove that its lower backend executed. The production-target recipes build separate x86-64-v3 artifacts, allowing an AVX-512 host to exercise AVX2 as well as its higher runtime arm. SSE and non-x86 qualification require builds and hosts that expose those backends. Current instruction and timing evidence below is x86 evidence; it establishes no AArch64 or WASM timing.

Results, Failures, And Bounds

Result familyRequired guarantee
Integer arithmetic and reductionsExact values and failure classification; integer window sums accumulate in i128 and check the declared result width at emission
BitmapsBit zero names the first lane or byte; unused high bits in the last word or byte are clear
Byte classification and encodingExact member positions and masks, and byte-identical wire output under the owning codec’s contract
Timestamp admission and latencyExact inclusive admission and histogram bucket decisions over Arrow nanoseconds, including the full signed timestamp range
Window float sums and momentsPer-lane compensated sums and centered moments merged per run; run partitioning and addition order may affect rounding
VM float arithmeticExisting scalar/compiler-vectorized arithmetic and non-finite rules; packing failure bytes does not change the arithmetic result

Checked arithmetic returns the wrapped lane value together with its failure word, matching overflowing_*. The VM intersects failures with operand validity, zeroes failed values, makes them null, and constructs errors only for set bits. Null lanes cannot produce a message error, including when their underlying value buffers contain overflowing operands. Scalar-null operands produce an all-null result. Sliced arrays retain their value span and bitmap offset.

Integer sums, counts, comparisons, byte classes, and masks require exact scalar equality. Floating window admission uses eight compensated accumulation lanes, two-pass centered moments, and one Chan merge per run. This is a typed run algorithm, not a claim that every float loop has an explicit fearless_simd backend. Different batch/run partitions can round differently under the public window contract; changing VM float association is outside this boundary. Retraction refolds survivors rather than subtracting floating statistics. Window ownership, snapshot restore, and branch isolation remain defined by Window Aggregates And Sketches.

When a sum of finite operands overflows, the mean is computed from compensated operands divided by their largest contributing magnitude, then rescaled. Clamping the normalized mean to [-1, 1] keeps it finite at the representable limit. Constant finite runs retain their exact mean and zero centered moments in this path; ordinary finite sums keep the existing compensated division.

Output storage scales with the input run, or with a validated fixed histogram layout. Local padding, lane reductions, and block masks have bounded sizes. Tails are masked before publication; a padding value is never an extra admitted row or failure. Byte scanning uses overlapping loads inside the existing buffer rather than allocating padded tails. Unsafe conversion is confined to XmlChars::into_string: the spans accepted by simdutf8 partition the complete buffer, so joining them preserves UTF-8 and permits String::from_utf8_unchecked. Character rejection remains a typed result, and invalid UTF-8 takes precedence over an excluded XML character.

Kernel And Caller Catalog

The catalog includes library-dispatched operations and typed run algorithms so their contribution is not mistaken for an explicit vector kernel. The callers retain schema validation, sensitivity, route errors, ACK attribution, and branch ownership.

OperationBuffer API or libraryCaller and retained work
Live schemaful JSON decodesimd-json borrowed values, reusable Buffers, pre-resolved KnownKeyThe open ingest group owns writable payload scratch and parser buffers; connector, endpoint, ingest-group, and lookup-file decoding append directly to typed Arrow builders
Columnar JSON encodeJsonEscapeClassification over string values and Arrow-style offsetsnervix-columnar-json prepares typed readers per batch and pre-escaped keys per schema; runtime wire codecs and ClickHouse JSONEachRow share the writer
Delivery latency and latest watermarkElapsedHistogram, ElapsedLayout, latest_instant, elapsed_nanos over &[i64]Relay delivery observations feed retained metrics handles at processor, reingestor, and emitter inputs; HDR bucket scatter and Prometheus local recording remain scalar
Paced-domain admissionAdmissionKernel::admit returns packed bytesDomainAdmissionWindow::admit_column supplies reached centers and period; ingest groups select Arrow rows, timestamp metadata, and ACK sidecars together
Window admission and retractionRunValidity, signed/unsigned sums, boolean counts, non-finite masks, compensated sums, moments, co-moments, bucket indexes, typed reverse visitsArgumentColumn resolves type and validity once per run; branch-owned accumulators admit, retract, and rebuild retained rows; deque updates, scatter, and sketches remain irregular
VM failure packingFlagPacker::pack, WORD_LANES, lane_masknumeric::Lanes computes blocks of up to 1,024 lanes and packs one failure byte per lane; explicit arithmetic kernels already return failure words
Selection bitmapsArrow BooleanBuffer operations, collect_bool, set_indices, filter/takeVM predicates exclude null and failed rows; runtime selection and ingest branch masks retain packed form; required-output validation ORs inverted validity buffers
Checked integer arithmeticCheckedArithmetic, LaneOperands, CheckedLanesVM integer operators pass two runs or a shared operand; every signed/unsigned width has checked sums and differences, with widened products below 64 bits
Constant integer divisionUnsignedDivisor, SignedDivisor, ConstantDivisionVM column-by-scalar / and % and fixed-unit datetime operations prepare the divisor once; measured 64-bit operations retain scalar reciprocal paths
SYSLOG header and structured-data scanningByteClass::first_in, ByteScannerCompiled SYSLOG fields retain names and Arrow types; byte classes find header refusals and structured-data syntax before scalar parsing at those positions
Syslog TCP framingmemchr, retained read cursor, typed FrameProgressThe connector retains parsed octet counts and searches newly read bytes of split lines, with one compaction per read
SQS XML character admissionXmlChars::into_string and XmlChars::admits, with simdutf8The SQS sink validates bodies and string attributes; its integration owns size limits and reporting
OTEL trace/span identifiersfaster-hexThe OTEL connector decodes fixed-length nonzero hexadecimal IDs; otel-hex-ids compares every byte and each typed refusal with a per-digit scalar reference in ordinary and sanitizer execution

JSON Boundaries

Decode copies a borrowed payload into reusable writable scratch because simd-json mutates its input. It builds no intermediate serde JSON tree or materialized row map. Schema compilation resolves field keys and Arrow types; datetime text is parsed once and base64 chunks append to the binary builder. Exact integer shape/range, strict unknown-key rejection, nullability, invalid UTF-8, and document limits remain codec decisions. Workspace serde_json::preserve_order remains for JAQ’s public object-member-order contract, independently of schemaful decoding.

Encode classifies quotes, backslashes, and controls below 0x20 over each string values buffer in 64-byte blocks, then summarizes offset pairs into row masks. Non-ASCII UTF-8 remains verbatim. Clean strings copy directly; marked rows enter escaping. Numbers use itoa/ryu; datetime and base64 text write without another escape scan. Sensitivity redaction, nested values, ClickHouse’s widened F32 rendering, bounded-write refusal, and frozen retry bytes remain the owning encoder’s contract. The shared byte block classifier also implements the JSON escape class.

Latency And Domain Admission

Relay metadata carries low/high ingestion watermarks as Arrow-compatible nanosecond buffers. The delivery fold computes the latest watermark, excludes timestamps after the acceptance instant, rounds elapsed time to milliseconds with halves up, clamps at 30 seconds, and counts HDR buckets. The histogram layout validates the bounds under which floating lane division reproduces the integer rounding exactly. Scalar bucket scatter is intentional. Each retained series merges the fold into its four rolling windows under one lock with one wall-clock read, then flushes its local Prometheus histogram once. The domain acceptance instant and wall-clock window maintenance remain separate clock responsibilities.

Admission uses the first and last reached centers with inclusive skew. Ordered signed timestamp differences become exact unsigned distances, including across the signed extrema. Interior rows test their distance to the nearest period boundary. A window with no gaps needs no remainder; powers of two use masks; other periods prepare a floor reciprocal and correct its estimate once. The single-timestamp vocabulary check has the same semantics. A rejected or missing timestamp affects its row alone, with the existing validation/admit error and ACK attribution. The kernel does not establish which centers are eligible; Admission Windows defines that clock contract.

Window Runs And Bitmap Work

Validity readers carry a bitmap and its bit offset and count present rows by popcount. Boolean counts combine value and validity words. Signed/unsigned 64-bit sums accumulate low and high 32-bit halves in vector lanes, periodically reducing them to exact i128 before lane overflow. Smaller integer sums and generic min/max use typed scalar folds. Non-finite F32/F64 inputs produce column bitmaps. Bucket-index calculation and subsequent histogram scatter remain typed scalar work. Sketch admission reuses a run key buffer; BLAKE3, t-digest insertion, and candidate updates are not turned into lane-wise kernels.

Failure packing compares failure bytes with zero and extracts masks per register. Valid-lane-only VM operations read validity by 64-lane words: empty words do no work, full words use the ordinary loop, and mixed words evaluate only present lanes. One packing call covers the block, with clear bits past its end. Explicit checked arithmetic bypasses the intermediate failure bytes entirely. Selections use bitmap operations and Arrow filtering; required output columns with no nulls take a fast path. These operations remove repeated per-row mask reconstruction, without changing which row is selected or reported as failed.

Checked Arithmetic And Constant Division

Sums and differences wrap at their own width. Signed operand/result signs determine overflow; unsigned carry and borrow comparisons determine it. Products of 8-, 16-, and 32-bit operands widen to twice the width, compare against the original bounds, and narrow by truncation. A 64-bit checked product remains scalar because no wider exact lane is provided.

For a nonzero non-power-of-two divisor d, preparation computes m = floor(2^64 / d) once. Discarding the low 64-w bits yields floor(2^w / d) for width w. The high product gives a quotient at most one below the exact quotient, and one remainder comparison corrects it. Powers of two use a shift and mask. Signed division operates on unsigned magnitudes, including MIN, before sign restoration. Truncating numeric division and Euclidean datetime division keep their distinct rounding. Zero fails valid lanes; MIN / -1 fails, while the numeric remainder contract keeps MIN % -1 equal to zero.

Constant operationRetained execution
Signed/unsigned 8-, 16-, 32-bit quotient and remainderExplicit SIMD reciprocal lanes
I64 quotientExplicit SIMD multiply-high from four 32-bit partial products
I64 remainder, U64 quotient and remainderScalar multiply-high and correction, selected by measurement
Fixed-unit truncation/binning and to_unixPrepared signed reciprocal in the datetime caller; scalar/compiler-vectorized loops
Fixed-unit date_diffChecked i64 subtraction and prepared truncating division; exact i128 subtraction/division when the difference exceeds i64, followed by a checked i64 result
date_add, from_unixChecked i128 multiplication/addition; from_unix has no division
Column divisor, including scalar-left/column-rightExisting checked scalar lane loop

Binning normalizes the value and origin, corrects their phase difference by at most one stride, and checks subtraction of the distance to the bin start. Calendar and zoned operations retain their calendar rules. Divisor preparation may execute wide division once; selected reciprocal lane bodies contain no integer division instruction. The exact wide date_diff fallback still divides in i128.

Byte Classes And Text Codecs

A ByteClass supplies an ordinary inclusive byte range, excluded bytes outside it, and listed bytes inside it. A byte is a member when it lies outside the range without being excluded, or is listed. Constants become splatted vector comparisons. Each excluded/listed list is bounded by MAX_NAMED_BYTES at compile time; supported examples and a paired compile_fail doctest exercise that current API restriction.

first_in stops at the first block holding a member. ByteScanner retains the current block’s mask for successive forward positions, classifying each block at most once. A final partial block uses the last 64 bytes of a sufficiently long buffer and shifts away the overlap. Buffers shorter than a block use 16-byte registers and an overlapping final load. first_in below 16 bytes uses the scalar loop before resolving the level. No padded-tail copy is needed.

XmlChars checks the XML 1.0 Char ranges: tab, LF, CR, U+0020–U+D7FF, U+E000–U+FFFD, and U+10000–U+10FFFF. The owned-byte path validates UTF-8 and classifies bounded 4 KiB spans, carrying sequences across classification boundaries and adjusting validation boundaries to character starts. Once a character is excluded it still validates the remaining bytes, because invalid UTF-8 outranks character rejection. An already valid &str needs only character classification. Syslog framing’s memchr, UTF-8 validation’s simdutf8, and OTEL’s faster-hex choose their own SIMD; the host adds no competing dispatch layer for their internals.

Qualification

Tests compare every level available to their build/host, the baseline, and the forced fallback with a complete scalar oracle. They cover empty runs, short inputs, every tail length, word/block boundaries, sliced validity, nulls, shared operands, signed extrema, and unused bits. Byte arithmetic and division include exhaustive operand pairs. Float-window tests check the documented run algorithm and numerical bounds; they do not assert bit equality across different partitions.

Use the repository recipes and retain its kache compiler wrapper:

just test-simd-kernels
just test-vm
just test-bolero simd-
just build-simd-kernels-x86-64-v3
just build-vm-bench-x86-64-v3

Registered Bolero targets use the same production path and complete assertion in ordinary randomized/corpus runs and sanitizer-backed libFuzzer. Current targets include simd-checked-lanes, simd-constant-division, simd-byte-classes, simd-xml-chars, simd-json-escapes, simd-paced-admission, simd-elapsed-histograms, simd-flag-packing, and simd-window-runs and simd-window-moments; syslog-stream-framing, syslog-structured-data, and columnar-json-rows cover the connector/codec consumers. Elapsed buckets are compared with HdrHistogram’s lowest equivalent values, and float window sums with an exact sum within the compensated-summation bound.

The moment reference uses exact addition partials and fused-product residuals for runs of at most 128 finite values with exponents from −200 through 200. Counts and histogram placement are exact. For ε = f64::EPSILON, n = max(count, 1), input absolute sum S, and reference mean μ, its mean error bound is B = 4ε|μ| + [2(n + 16)ε]² S/n. The M2 bound is 16(n + 16)ε M2 + 4n B²; the cross-moment bound is 32(n + 16)ε Σ|centered products| + 4n B₁B₂. Independent sliced null masks select paired contributions. Separate constant runs at 1e308, -1e308, and f64::MAX check that overflow of an intermediate sum preserves the exact constant mean and zero centered moments. Histogram cases check each edge, both clamps and null positions. Every applicable property must remain registered in tests/bolero-targets.toml. Ordinary runs remain required on PRs; sanitizer CI runs only for PRs labeled fuzz. Scheduled, workflow_dispatch, and unlabeled PR runs expect a sanitizer skip. A skip is neither execution nor coverage evidence. Explicit qualification uses just fuzz <target> <seconds> or just fuzz-all <seconds> and records the actual inventory executed. See Property Testing And Fuzzing.

Current Rust API restrictions use source-adjacent compile_fail doctests paired with compiling examples of supported imports and signatures, run in normal tests and CI. Retain compiler-resolved lint fixtures, feature/dependency/target checks, public scenarios, and model tests under their owners. Use LLVM tools matching the producing compiler, as declared by its toolchain.

Public behavior retains Cucumber evidence on one and three nodes: exact JSON ingestion/emission, batch latency metrics, reached admission-window edges, interleaved branch window aggregates, sparse/dense/empty filters with row errors, checked numeric and datetime errors, split Syslog TCP/TLS frames, SQS noncharacters, and OTEL ingestion. Kernel equivalence does not establish ACK, branch, transport, or recovery semantics. Synchronization changes additionally need the evidence required by Data-Plane Concurrency; a pure buffer kernel introduces no claim about concurrent ordering.

Measurement And Recorded Evidence

Inspect optimized instructions for the actual payload target, using nm -C and objdump -d -C on the binaries the production-target recipes build. Check every operand shape and width, including tails and fallback. A source abstraction’s scalar implementation can be folded back into vector instructions by LLVM; conversely, an early-exit loop can remain scalar even when the target supports AVX2. Instruction evidence must name the revision, toolchain, flags, and host.

Criterion compares a kernel with the loop it replaces on identical operands in one process. Include scalar/shared operands, null/error density, short inputs, and divisor preparation and allocation where the caller pays them. Pass reference operations as inlinable closures: a function pointer per lane measures indirect-call overhead. A byte scan’s reference must preserve its early exit; removing that exit can let LLVM vectorize a different algorithm.

just bench-json-encode
just bench-relay-interaction
just bench-window-admission
just bench-checked-lanes-x86-64-v3
just bench-constant-division-x86-64-v3
just bench-byte-classes-x86-64-v3
just bench-syslog-framing
just bench-vm numeric_kernels
just bench-vm datetime_kernels
just benchmark-ab <baseline-ref> 3 <workload>

An application-throughput claim requires serialized same-host just benchmark-ab, exact output parity, and equivalent configuration. Pin timing runs to one core on mixed-core hosts and interleave baseline/candidate rounds. Report intervals and interference; a bounded backlog rate does not establish maximum sustainable throughput. Store new raw logs, benchmark outputs, disassembly, and validation reports on the corresponding ClickUp task, rather than committing transient artifacts. The summaries below are architectural evidence, not fresh measurements.

DeliveryRecorded evidenceWhat it establishes and limits
01: live JSON decodeDecode measurements, 2026-09-26, i9-14900HX/AVX2Direct builders and library dispatch; same-host Kafka means changed +0.0% and −0.8%, within spread and at backlog caps, so no throughput gain is established
02: columnar JSON encodeEncode measurements, 2026-09-28, i9-14900HXAVX2 vpcmpeqb/vpmovmskb and AVX-512 mask instructions; 1,024-row writer 77.8 µs versus a cheaper direct-serde reference 74.0 µs; bounded Kafka mean +3.9% does not isolate the writer’s contribution
03: batch latencyLatency measurements, 2026-09-28Vector timestamp arithmetic with scalar bucket scatter; final 1,024-row spread-watermark recording 322.9 → 30.66 µs, shared-watermark 415.5 → 7.896 µs; bounded processor A/B −0.7% establishes no application gain
04: domain admissionAdmission measurements, 2026-09-29, i9-14900HXExact scalar/level equivalence and public paced-window edges; noisy +8.0% mean came from an unpaced workload, measuring surrounding metadata/selection changes rather than paced SIMD arithmetic
05: window runsWindow measurements, 2026-09-29AVX-512 integer sum adds/shifts; combined four-kernel 4,096-row fixture 30.696 µs, not isolated kernel throughput; bounded window A/B mean −0.04% is within spread
06: packed failures and selectionsBitmap measurements, 2026-09-29, Ryzen AI 9 HX 370Packing’s per-lane shifts removed; AVX2 compare/movemask and AVX-512 mask extraction; 65,536-row arithmetic-filter program 465.08 → 423.05 µs; native and production-target checks distinguish dispatch arms
07: checked integersArithmetic measurements, 2026-09-30, Ryzen AI 9 HX 370Signed sums 2.2–6.0× and narrow products 1.4–4.1× faster in same-process kernel comparisons; unsigned 8/16-bit sums at parity; VM rounds varied by up to 2.3× under shared load
08: constant divisionDivision measurements, 2026-09-30, i9-14900HX/AVX2Exact reciprocal loops without per-lane idiv; measured vector/scalar selection, kernel ratios, and VM timings are distinct observations, with no end-to-end throughput measurement
09: text scanningImplementation evidence on that task, 2026-10-03, PR #600, rustc 1.99/LLVM 22, production targetReplaced early-exit loops were scalar; AVX2 byte compares/movemasks and AVX-512 masks, with no kernel tail memset/memcpy; short headers reach parity at 8 bytes, about 3× at 48 and 15× at 255; no Kafka A/B workload represents Syslog/SQS/OTEL

The arithmetic inspection corrected the initial assumption about I64: every signed sum and difference was a scalar flag-per-lane loop in the inspected x86-64-v3 VM, while unsigned sums and differences were already vectorized. Own-width overflow checks also measured faster than widening sums (native i8: 93 versus 161 ns; i32: 171 versus 247 ns per 1,024 lanes). Unsigned operations now share the checked-arithmetic owner, with parity or a measured improvement; their presence is not evidence that LLVM previously left them scalar.

Constant-division production-target experiments measured I64 quotient at 1,302 ns in vector lanes versus 1,745 ns with scalar reciprocal, but U64 quotient at 856 versus 788 ns and U64 remainder at 877 versus 797 ns. A repeated I64 remainder comparison measured 1,685 versus 1,534 ns. These 1,024-lane comparisons explain the retained scalar operations. Separately, VM truncation/binning measured 20.005 → 14.348 µs, fixed elapsed arithmetic 9.546 → 7.329 µs, and numeric constant programs about 26–68% less time. Combined to_unix/from_unix remained unchanged; the latter is multiplication. Unchanged controls also moved under shared-host load, so these are kernel/VM observations, not application-throughput guarantees.

Text-scanning qualification found that a padded-tail copy compiled into memset/memcpy and made an 8-byte header 4.5× slower. The shipped overlapping-load and below-16-byte scalar paths remove that cost. This is why every byte-kernel benchmark includes short inputs. Framer A/B used one MiB of frames read eight KiB at a time, with interleaved pinned rounds against the prior decoder; it remains connector-level evidence rather than a Kafka pipeline measurement.

Adding A Kernel And Scope Limits

  1. Resolve the typed caller and ownership first. Reuse Arrow or an existing dispatched library when it supplies the exact semantics. Inspect the current optimized loop before adding code.
  2. Write the full scalar contract, including bounds, signed arithmetic, failures, bitmap offsets, empty input, and tails. Establish a failing focused check; observable behavior also needs its public Cucumber scenario before product changes.
  3. Put the operation at the primitives boundary. Validate fallible inputs before dispatch, resolve the cached level, and return values/masks from the generic buffer body. Keep allocation proportional and local padding bounded; keep irregular scatter in its owning caller.
  4. Qualify every available backend and forced fallback, with appropriate baseline builds. Register differential properties in both Bolero modes and pair API-rejection doctests with supported examples. Record the limits of host/target coverage.
  5. Measure the complete caller and inspect generated instructions. Keep a scalar operation where it wins. Any end-to-end claim additionally needs same-host A/B. Publish raw evidence on the task and update this chapter and the caller’s authoritative chapter in the same change.
  6. Run focused checks and just validate; lower ratchet baselines that fall. Keep public contracts and NSPL skill routing current when their surfaces change, then run just book dev.

Do not add explicit kernels for operations LLVM already vectorizes without a measured caller benefit, or duplicate Arrow and library dispatch. Division by a column has no constant reciprocal and stays scalar. Checked 64-bit multiplication lacks a wider exact lane. VM floating arithmetic and platform-libm transcendental calls keep their current contracts. Branch keys, deduplicator and reorderer keys, lookups, correlator keys, and subscription sampling still contain irregular string/map/hash work: typed keys and a demonstrated buffer algorithm must precede SIMD there. No kernel may combine state across concrete branches, weaken schema/sensitivity checks, change clock authority, hide an error, or persist hot-path payloads.

VM Functions

Every NSPL expression runs through one expression VM. That covers a route’s SET, WHERE, and INVOKE, a source or processor filter, a key or ordering expression, an emitter mapping, and a window aggregate’s arguments. This chapter is the implementation reference for that VM and its function catalog. It covers:

  • how an expression Model becomes a compiled program
  • how a program executes over an Arrow batch
  • which kernels compute each function family, and how far each one vectorizes
  • how window aggregates and sketches keep branch-local state
  • how to add a function without weakening any of these contracts

Expression Functions owns every contract a user relies on: signatures, exact types, nulls, errors, limits, and measured costs. This chapter explains how the implementation delivers those contracts, and links to the reference instead of restating them. Four other chapters own neighbouring contracts:

Five rules hold throughout:

  • Arrow batches are the only payload. A program reads Arrow columns and writes Arrow columns. No row is ever held as a map of fields. A value every row shares is held once, not copied into each row.
  • Types are exact. The compiler never inserts a conversion, and every operation’s result type is decided when the program is compiled.
  • A failure belongs to a row whenever it can. A kernel records which rows failed and why. Only a failure of the batch as a whole, such as a UDF trap, fails the batch.
  • Time is an input. No kernel reads a clock. The current time arrives in the execution context from the caller’s domain execution snapshot.
  • The VM decides nothing about the graph. It runs one program against the bindings its caller hands it, and knows nothing of relays, branches, schedules, or connectors.

Ownership By Layer

The predicate capability and its execution entry point keep paired supported and rejection Rustdoc examples beside their APIs. just test-capability-docs runs them in normal tests and CI, separately from execution and concurrency evidence.

LayerOwnerWhat it owns
VocabularyExpression Models in nervix-modelsExpression, RouteConstruction, Assignment, Invocation, and JsonPath. A builtin name is a validated BuiltinFunctionName identifier, not a closed set, and a UDF name is a UdfName.
Languagenervix-nsplParsing a call as a generic name(args) or udf::name(args) into those Models. The grammar has no function-name table; completion emits a typed builtin or UDF expectation at a call position.
Engines and infrastructurenervix-vmLowering Models into VM programs, the semantic catalog of every operator, cast, and builtin, type and sensitivity checking, compilation into instructions over typed registers, every kernel, window aggregate lowering and route compilation, and where an execution runs: inline, or admitted through the node’s bounded executor onto its data or extension workers.
Engines and infrastructurenervix-rotoCompiling a typed UDF program on the node’s bulk workers, and the FunctionInjector that answers the VM’s UDF calls over Arrow arrays under a watchdog.
DecisionsRegistry validation and resource planningCompiling every expression it can check with the same compiler the runtime uses when a statement is applied, then selecting scheduled UDF programs and lowering generator and emitter routes, source filters, HTTP fields, ordering groups and sink mappings against their declared scopes.
Data planeRuntime plan binding and hostsBinding runtime programs against installed schemas once per typed node revision, projecting carrier batches into VM input, supplying the execution context and injectors, turning row errors into structured message errors, and owning branch-local window accumulators.
Control planeSubscriptionsCompiling a session subscription’s WHERE into a read-only predicate when the subscription is created.

The VM compiler, batch constructors, runtime, and FunctionInjector return error-stack reports with their semantic CompileError or RuntimeError context. A compile error retains a typed code, its existing stable code spelling through code(), the operation span, and a safe message. Registry validation adds the owning model and route while retaining the VM report. Runtime plan binding likewise adds its operation above the original VM report: a RuntimeVmCompileError names the program and its node, such as a filter, a FILTER-MAP route, an output branch construction or a generator output, and its caller names the domain above that. A lowering, LOOKUP_HASH_MAP or materialized-state binding failure stays beneath the same program context instead of being rendered into its text. Roto UDF setup returns UdfError reports; its injected calls return VM runtime reports, retaining an underlying Arrow failure when one caused the call to fail. Jaq compilation, evaluation, and format conversion return their own typed reports to the codec or signaling caller. These reports are batch or setup failures; selected-row execution and SideError values remain the row-failure channel.

The compiler failure-contract gate reads source-owned outcome classifications on SideErrorReason, CIDR NetworkDefect and JSON JsonTargetDefect. These are row or pure type conversion outcomes; their callers establish the operation report when a whole batch or setup fails. Errors And Diagnostics owns those compiler contracts.

For ordinary expression completion, the session resolver asks FunctionName for the VM’s sorted builtin spellings, including datetime names and accepted aliases. That list excludes injected calls and write_header; it is a candidate catalog, while VM lowering and registry validation still decide whether a call is valid for its arguments. The parser carries the source or sink kind with header-function expectations. The resolver adds read_header and read_headers only for a source that can read headers, and write_header only for a sink that can write them. UDF candidates come from the selected domain’s Models after the session’s queued transaction changes.

Registry planning converts the validated scheduled Models into typed processor specifications. Before a node revision is installed, runtime plan binding lowers every processor expression against the installed schemas, branch declarations, lookups and UDF catalog. The complete map of bound processor plans is published with the domain routing snapshot. Processor supervisors and concrete branch tasks consume those plans and never read the active graph or a semantic Model.

The Expression Pipeline

Each representation is produced once, at its boundary, and the next stage consumes it:

flowchart LR
    Text["NSPL text"] -->|parsed once| Model["Expression Models"]
    Model -->|lowered once| Program["VM Program"]
    Program -->|"types, sensitivity, folding, sharing, registers"| Compiled["CompiledProgram"]
    Compiled -->|"executed per batch"| Result["ExecutionResult"]
    Carrier["Carrier Arrow batch"] -->|projected| Input["TypedBatch"]
    Input --> Result
    Result -->|"rows, selection, row errors"| Host["Routes, message errors, ACKs"]

Models are persisted; programs are not. A compiled program and everything prepared inside it, such as a compiled regular expression or an IN set, live in memory beside the runtime node that runs it. Nothing prepared at compile time ever enters a stored Model, and no executable NSPL text is stored for a runtime to parse again.

Lowering

crates/nervix-vm/src/frontend.rs converts Models once into the VM’s program vocabulary in program.rs. The result is a Program with an optional filter, ordered set assignments, and ordered invoke calls, each built from Expr trees. Each route kind has one lowering, and each lowering fixes the scopes a bare field name reads and writes:

LoweringUsed forBare reads and writes
lower_transforming_routeTransforming routes, which begin empty and may INHERITread and write output
lower_set_only_routeSet-only constructions, such as a set-only processor route or a materialized-state DEFAULTwrite output only
lower_generated_routeGenerator routesread generated, write output
lower_branch_constructionBRANCHED BY ... SETread and write branch
lower_finalized_output_filterA route WHERE over finalized outputread output only
lower_route_constructionA construction under a scope policy its caller chooses, such as a correlator route, an emitter VALUES mapping, or an error recordset by the caller
lower_expressionOne standalone expression, such as a subscription WHEREset by the caller

Window routes have their own lowering in window.rs, described under Window Aggregates And Sketches.

SemanticScopePolicy decides what a bare read or write means. A scope the route kind does not expose is a typed FrontendError, never a fabricated name that later fails name resolution. Lowering also removes surface forms the compiler does not need to know about:

Surface formLowered to
IFCASE
NOT IN, NOT BETWEENnegated membership and range tests
TRY_CASTa cast whose failure yields a typed null
INHERITplain assignments; a field inherited with LEAK SENSITIVE is wrapped in leak_sensitive
udf::name(...)FunctionName::Udf

Lowering resolves datetime builtins further than other calls, because their literal arguments choose the kernel. It resolves literal units, time zones, formats, and bin widths once into a DatetimeFunction, and the call keeps only its row-valued arguments. Every other name is parsed, case-insensitively, into the closed FunctionName enum. An alias such as ceiling, power, or substring maps to the same variant as ceil, pow, or substr, and messages always use the canonical name. A name that matches nothing becomes FunctionName::Unknown, which the compiler rejects.

A span in the VM is an operation ordinal, not a source offset: the i-th assignment, the route WHERE, or the j-th INVOKE. Every node inside one operation shares that operation’s span. The runtime maps a span back to the node, route, operation, operation index, and fields that a structured message error reports.

The VM never parses NSPL in production code. nervix-nspl is a development dependency that only its tests and benchmarks use to build Models from text.

The Semantic Catalog

crates/nervix-vm/src/semantics.rs is the one catalog of every operation’s meaning. Registration is a set of exhaustive match expressions over closed enums, not a runtime table, so a builtin that misses a decision does not compile. For each builtin the catalog and its neighbours decide:

DecisionWhereWhat it records
NameFunctionName::parse and as_str in program.rsThe canonical name and its aliases.
Loweringbuiltin_descriptor, producing a BuiltinLoweringThe operation the runtime executes. Some variants carry values prepared at compile time: a regular-expression call, an IN set, an Aho-Corasick matcher, a constant network, or a resolved datetime function.
Semanticsbuiltin_semantics_for_lowering, producing OperationSemanticsVolatility (Immutable, Stable, Volatile), dependency scope (Constant, ExecutionLocal, RowLocal), side effects, whether it can report a per-row error, and null propagation (NeverNull, Strict, Custom).
Signaturebuiltin_signature and builtin_output_typeArity, exact argument types, and the result type.
Conditional armsbuiltin_arm_execution, producing ArmExecutionWhether a conditional arm runs the kernel over the whole batch or only over the rows it selects.
Foldingfold_builtin_call in compiler.rsA folding arm, or an explicit refusal.
Executionexecute_builtin in runtime.rsThe kernel call. It has no wildcard arm.
Optional resultCompiler::expr_may_be_nullExceptions to “optional when any argument is”.

ExpressionSemantics::from_operation combines an operation with its children bottom-up. It takes the most volatile child, the widest dependency scope, and any child’s side effects or ability to fail. Some calls have no descriptor: leak_sensitive, LOOKUP_HASH_MAP, the header functions, window aggregates, and UDFs. The compiler handles those specially. For a tree that contains one, expr_semantics returns no semantics, so the tree is neither folded nor shared through the builtin path.

No builtin has side effects. The language’s one side effect, write_header, is an INVOKE statement rather than an expression.

A value rule that both folding and execution apply is defined once in the catalog:

  • CaseMapping: full Unicode, locale-independent lower and upper
  • FloatClass: the floating-point classification behind is_nan, is_finite, and is_infinite
  • BitwiseOperation and IntegerBits: integer bit operations
  • the IP address and URL text checks

Execution may reach the same value by a faster route. lower and upper map a column whose visible bytes are all ASCII in one pass over its buffer, for example, but must produce exactly the value the contract defines. This is what keeps upper('grüßen') and upper(input.text) equal.

Types, Nullability, And Sensitivity

The compiler in compiler.rs infers every expression’s type from its input bindings and the catalog. Argument types match by Arrow DataType equality. No numeric widening, parsing, stringification, or datetime–text interchange happens unless the program spells it out as a cast, TRY_CAST, or JSON_VALUE. Messages name types by their Arrow names, which the reference maps back to NSPL names. A result is optional when any argument is, unless the catalog or expr_may_be_null records an exception. The exceptions are listed in Function Properties.

A program compiles against CompileBindings. Each binding has a namespace, a schema, the sensitive fields of that schema, and whether it is readable and writable. With more than one binding, input fields are qualified by namespace, such as left.id and right.id.

Sensitivity is a compile-time property:

  • Propagation. expr_is_sensitive marks a result sensitive when any operand is sensitive. That includes a CASE condition, because the arm a sensitive condition selects reveals it.
  • The only exemption. leak_sensitive(...) is the only operation that removes sensitivity. It compiles to its argument and costs nothing at run time.
  • Where a leak is rejected. A sensitive_leak error rejects each of these:
    • a sensitive value assigned to a non-sensitive output field
    • an inherited field that would pass sensitive data through
    • a sensitive write_header argument
  • The bypass. CompileOptions::allow_sensitive_output turns these checks off where the output never leaves Nervix. A predicate always compiles with it off.
  • Unbound sensitivity. A binding compiled without its sensitive fields enforces nothing. A caller whose output can leave Nervix must bind the sensitivity of every input it reads.

For direct emitter VALUES, the registry lowers each mapping through the same route frontend and infers its type and sensitivity against the declared input schema. The inference result retains sensitivity for each assigned field, including a field assigned more than once. The registry rejects a sensitive mapping before activation unless its expression explicitly calls leak_sensitive(...); the target column or attribute is identified without including its value.

A CompileError carries a stable code, a message, and its operation’s span. The codes fall into these groups:

  • binding and namespace errors, such as unknown_identifier and wrong_stream
  • typing errors, such as type_mismatch and unsupported_cast
  • function errors, such as unknown_function and invalid_argument
  • IN-set errors
  • output-contract errors, such as missing_set and null_for_required_field
  • invocation errors
  • sensitive_leak

Folding, Sharing, And Scalar Values

Three mechanisms avoid repeated work. None of them changes a result.

Constant folding. compile_expr folds an expression only when all of these hold:

  • it is not volatile and its dependency scope is Constant
  • it cannot fail and has no side effects
  • the folder produces a non-null value

A folded value becomes one Literal instruction. The folder is deliberately small. It folds only:

  • among builtins, lower, upper, trim, length, coalesce, is_null, nullif, is_nan, is_finite, is_infinite, the bitwise functions, bit_count, contains, starts_with, ends_with, is_ip_address, and is_url
  • among binary operators, comparisons, AND, and OR

Casts, arithmetic, membership, ranges, JSON, and every other builtin are never folded.

Folding applies the catalog’s value contracts rather than calling the runtime kernels, so a folded call and an executed call agree by construction. trim and length are the exception: each has a separate folding implementation beside its kernel. A new folding arm must reuse a catalog contract rather than add a second implementation.

Three kinds of constant argument are prepared while the call is lowered: a regular-expression pattern, a contains_any pattern list, and an ip_in_network network.

  • An invalid constant pattern is kept, and every row that evaluates it reports the error.
  • An invalid constant network is a compile error.

Sharing. Identical expressions share one register within one generation of the compiler’s expression cache. The generation advances each time an assignment rebinds a writable field, and each time an uninitialized field is materialized. So an expression is shared within one assignment, and, after the last assignment, among the route WHERE and the invocations. The cache key ignores spans. An expression is shared only when all of these hold:

  • it has no side effects and cannot fail
  • it is not volatile
  • inside a conditional arm, it contains no injected call, TRY_CAST, or JSON extraction, because those answer only the rows the arm selects and leave nulls on the others

A failing expression is compiled at each occurrence, so each occurrence reports its own error. A UDF call follows its own rule: it is shared unless the UDF, or a UDF among its arguments, is declared VOLATILE. Extractions from one JSON document share one scan for each document register and selection, as the JSON entry of Function Families describes.

Scalar values. A register holds either a column or a scalar, which is a one-row array standing for every row:

  • What is scalar. Literals are scalar. An instruction whose operands are all scalar and that is not volatile is computed once, over one row. That covers an all-literal call that was not folded, such as clamp(5, 1, 3), and now(), which has no operands.
  • Errors. When a scalar computation fails, as 1 / 0 does, its row error is copied to every row that evaluates it.
  • Expansion. A scalar becomes a column only when a kernel or an output field needs one. It is expanded at most once per register and cached.
  • Kernels that read scalars. Operand implements Arrow’s Datum, so Arrow comparison kernels, the checked numeric lanes, and most string and selection kernels take a scalar as it is.

Compiled Programs And Their Lifetime

CompiledProgram in ir.rs holds:

  • its input and output schemas
  • the bindings that load input columns into registers and read outputs from them
  • the invocation bindings
  • the instruction list
  • the register layout of each register space
  • the filter register
  • an optional injector

Registers are typed. The types are:

  • the thirteen scalar types: eight integer widths, two float widths, Boolean, Utf8, and Binary
  • Datetime, a nanosecond UTC timestamp
  • Generic, for lists

The register spaces are Input, Temp, Condition, and Output. An Instruction carries its kind, its span, and a selection register. It carries a selection only when its result depends on which rows it runs for: an operation that can fail, an injected call, a JSON scan, or a cast that reads or writes text.

Two optimization passes run after compilation:

  • Dead instructions and moves. Instructions whose results are never read are removed, except volatile builtins and injected calls. Redundant moves are removed as well.
  • Temporary registers. Temporaries are renumbered to reuse slots.

Input registers are never pruned: every readable field is bound, and binding one costs an Arc clone.

There is no separate constant pool. Literals are instructions, and prepared artifacts live inside their BuiltinLowering behind shared pointers. Cloning a program therefore shares its prepared patterns, sets, matchers, and pattern caches. CompiledPredicate wraps a program privately, so a general construction program cannot be passed where only a read-only filter is allowed.

The VM has no notion of plan activation. The host holds each program as a shared Arc<CompiledProgram>, the primitive boundary’s nervix_primitives::sync::Arc, inside a published typed processor plan. A prepared artifact lives exactly as long as that plan. An unchanged scheduled node reuses the exact plan allocation across a domain revision. A change to its topology, schema fingerprint, resolved branch contract or processor specification binds a fresh plan before publication. Existing branches adopt it by its typed revision identity between batches, while a newly appearing branch starts from the same published allocation.

Where Programs Are Compiled

Registry validation compiles or type-infers expressions when a statement is applied and discards the result. The exceptions, which are first checked when their domain’s execution is built or a subscription is created, are listed in Where Expressions Run. The decision layer lowers the installed revision’s expressions into typed plans; node installation binds and compiles those programs against runtime schemas. Execution Plans describes the complete revision and its publication.

ProgramCompiled for execution
Routes of junctions, deduplicators, reorderers, inferencers, correlators, window processors and WASM processors, and processor FROM ... WHERE and FILTER WHEREOnce when the installed typed processor revision is bound, before its domain routing snapshot is published. Every concrete branch shares the plan’s programs and prepared artifacts. A valid route that needs no VM program records that prepared absence and uses direct Arrow projection.
DEDUPLICATE ON, reorderer BY, and CORRELATE WHEREOnce when the installed typed processor revision is bound, then shared by every concrete branch
Ingestor FILTER WHERE, routes, and BRANCHED BY ... SETLowered once by the domain’s entrypoint plans, then bound when the ingestor starts, before its source opens. Every source instance and ingest group shares the bound programs.
Reingestor FROM ... WHERE, FILTER WHERE, routes, and BRANCHED BY ... SETLowered once by the domain’s entrypoint plans, then bound for each input relay before that input’s task starts
Emitter FROM ... WHERE, routes, HTTP request fields, SQS FIFO GROUP, VALUES, and OpenTelemetry mappingsLowered once into the domain’s typed emitter execution plans, then bound against installed schemas and UDFs when the task starts or swaps. Reconnects reuse the bound programs.
Window aggregate argument and output programs, inferencer INPUTS, and inferencer output routesOnce when the installed typed processor revision is bound, then shared by every concrete branch
Generator routesLowered into typed ordered route plans from the committed schedule, then compiled once when the domain’s execution is built or the generator is swapped; each concrete branch task retains those compiled programs and the exact materialized source branch.
Materialized-state DEFAULTCompiled and executed in one step when the default binds
ON MESSAGE ERROR SEND TO ... SETThe registry lowers the ordered assignments before installation. The domain execution or its replacement binds the lowered program once. It reads the original eligible input, the exact captured state, the optional partial output and structured error fields for each failed record. A route update installs a new prepared program; delivery never compiles one.
Subscription WHEREWhen the subscription is created

An HTTP emitter’s route compiles without its write_header invocations, which are request fields. The emitter compiles its METHOD, PATH and header writes as one more program: METHOD and PATH are assignments into an internal, write-only request namespace, so neither is an input of the program, and the header writes are its invocations. It reads input as the original source record and, with a codec, output and message as the finalized record; without a body message is the source record. It runs once for each admitted batch over only the rows the route kept, with the batch’s execution snapshot and materialized state, so a filtered record evaluates no request field, and a volatile call such as uuid_v4() yields the value the retained request resends on every retry.

The Runtime Bridge

The host builds a program’s input with project_vm_input_batch in src/runtime/vm_input.rs:

  • Carrier columns. Each is an Arc clone of the carrier batch’s column, checked for its exact type. No buffer is copied.
  • Built columns. Branch key fields, broadcast materialized-state values, lookup results, and selected ingest metadata are built as new columns. SharedVmInputColumns builds each of them once per dispatched batch and shares it among every output route that reads it.
  • Uninitialized outputs. These stay TypedArray::Uninitialized until finalization materializes them.

The host also builds an ExecutionContext. It carries the domain execution snapshot’s timestamp, and optionally an injector for this execution alone, such as the header reader or a window’s aggregate results.

execute_program_with_selection_in_context returns an ExecutionResult. It holds the output batch, the selection that survived the program’s WHERE, and any write_header invocations. The VM applies the WHERE itself, and always keeps a row that carries an error in the selection, so the caller sees every failure. A caller must therefore check batch.errors() for each selected row before treating that row as a result.

The kept rows are bitmap operations over the batch: the predicate’s values under its validity, where a null keeps no row, ORed with the bitmap of rows that carry an error when any row does. The selection is read from the set bits of that bitmap, and the same bitmap filters every output column and invocation argument.

src/runtime/filter_map.rs shows the complete handling:

  1. It acknowledges each row the WHERE dropped. The kept rows are set in a bitmap directly from the selection, and the dropped rows are its clear bits.
  2. It turns each row that carries an error into a structured message error. The error holds the stable reference, the code, the operation and its index, the fields, and the execution’s domain time, together with the partial output when the error route reads it.
  3. It rejects each row on which a required output field is uninitialized or null. The rows are found once per batch, as the OR of the inverted validity bitmaps of the required columns, and a batch whose required columns hold no null skips the bitmaps on their null counts. Only a rejected row names its fields.
  4. It passes every remaining row on as output. The rows it exports are set in a filter bitmap directly from their indices.
  5. A returned RuntimeError fails the whole batch through the node’s general error handling. The host keeps its report beneath a PlannedGeneralError that names the program by its clause and the step that failed, and hands back the acknowledgements of every message of the batch with it; the report is rendered only when the error policy reports the failure.

A branch construction program keeps the same split: a row whose branch SET recorded an error keeps that SideError as its own typed outcome, which its route’s error handling formats only if it reports the row, while a RuntimeError fails the whole batch.

Implementation Map

All paths are relative to the repository root.

PathOwns
crates/nervix-vm/src/lib.rsThe public surface and the with_typed_registers! expansion over the scalar register types
crates/nervix-vm/src/frontend.rsLowering Models into programs, scope policies, and frontend errors
crates/nervix-vm/src/program.rsProgram, Expr, FunctionName, and resolved datetime calls
crates/nervix-vm/src/semantics.rsThe semantic catalog: lowerings, semantics, signatures, arm execution, and shared value contracts
crates/nervix-vm/src/compiler.rsType and sensitivity checking, folding, sharing, register allocation, instruction emission, and optimization
crates/nervix-vm/src/ir.rsCompiledProgram, CompiledPredicate, instructions, registers, and bindings
crates/nervix-vm/src/runtime.rsExecution, register banks, scalar sharing, conditional arms, the builtin dispatch, and the list, cast, and most string kernels
crates/nervix-vm/src/batch.rs, operand.rsTyped batches and arrays; column versus scalar operands and broadcasting
crates/nervix-vm/src/error.rsCompileError, RuntimeError, SideError, error codes, and sparse RowErrors
crates/nervix-vm/src/numeric.rs and numeric/Checked numeric lanes, comparisons, math, bit operations, and decimal rounding
crates/simd-kernels/src/flags.rsPacking the checked lanes’ per-lane failure bytes into bitmap words at the selected SIMD level
crates/simd-kernels/src/checked.rs, benches/checked_lanes.rsChecked integer addition, subtraction and multiplication in vector registers at the selected SIMD level, returning each lane’s value together with the failure words, and their measurement beside the lane loop they replaced
crates/simd-kernels/src/division.rs, division/lanes.rs, benches/constant_division.rsConstant integer divisors prepared once per call, exact reciprocal quotient and remainder kernels, and comparisons with scalar reciprocal and checked division loops
crates/nervix-vm/src/datetime.rs and datetime/Fixed-unit datetime kernels, calendar arithmetic, time zones, and formats
crates/nervix-vm/src/text_column.rsThe bounded builder for STRING and BYTES values whose length an argument chooses
crates/nervix-vm/src/text_search.rs, regexp.rsSplitting, joining, LIKE, contains_any, NFC normalization, and regular expressions with their caches
crates/nervix-vm/src/membership.rs, extremum.rs, count.rsIN sets, greatest, least, and clamp, and integer counts read without narrowing
crates/nervix-vm/src/bytes.rsEncodings, hashes, and UTF-8 conversion of BYTES
crates/nervix-vm/src/json.rsTyped JSON extraction over one parse per document
crates/nervix-vm/src/ip_address.rs, url_component.rsIP addresses, CIDR networks, and URL components
crates/nervix-vm/src/window.rs, window/route.rsLowering window assignments into aggregate demands, and compiling each route’s argument and output programs
crates/nervix-vm/benches/The Criterion harness, workload shapes, and allocation probe
crates/nervix-roto/src/lib.rsThe UDF injector and its watchdog
src/registry/validation/Apply-time compilation, including window_route.rs and the sketch budget in processor/sketch.rs
src/registry/entrypoint_plan.rsLowering ingestor and reingestor filters, routes, and branch constructions before a node binds them
src/registry/message_error_plan.rsResolving the schemas, branch contracts and flush policies of DLQ routes and lowering their ordered assignments from scheduled Models
src/runtime/entrypoint_routes.rsBinding lowered ingestor and reingestor programs to a node’s schemas, state, lookups, and UDFs
src/runtime/message_error_plan.rsBinding each error-route VM program once to installed relay services and node-local capabilities
src/runtime/vm_compile.rsRuntime compilation and message-error sites
src/runtime/vm_input.rsInput projection, lookup key execution, and exporting selected output rows
src/runtime/filter_map.rsProgram execution and result handling for routes and filters
src/runtime/ingest_metadata.rs, lookup_hash_map.rsThe header injector and hash-map lookup calls
src/runtime/message_error.rsStructured message errors, the rows whose required outputs are missing, and execution of prepared error-record programs
src/runtime/window_processor.rs, window_accumulator/, window_state.rsBranch-local windows, their aggregate structures, and their snapshots
benches/window_sketches.rsThe cost of admitting retained rows into pane sketches, from a new pane for every row to a thousand rows per pane
src/runtime/window_archive.rsA captured window as bounded archive Arrow groups, and the window snapshot rebuilt from them
src/runtime/subscription_predicate.rsSession subscription filters

Window checkpoints nest two Arrow containers of the shared snapshot codec, the retained input rows and their argument columns, behind a header that counts the rows and both containers’ bytes. A window whose retained rows fit one 8 MiB snapshot section is encoded in memory under the window’s complete-payload admission; replica synchronization and ownership handoff use that resident encoding, which refuses a window whose rows exceed the whole bulk budget before it encodes a section. A larger window persists through the same container sealed in bounded pieces: each group of rows is sealed and staged on quota-owned disk as soon as it is encoded, the header is placed first once the groups are counted, and the pieces are concatenated one 64 KiB chunk at a time and published as a segmented checkpoint. Each bounded typed section of delayed histogram removals is encoded, staged and released before the next, so the removals never hold more than one section of the bulk budget. The two encodings are byte-identical for the same window. A backup restore seals an archived window the same way, one archived section at a time and a group’s input rows before its argument columns, keeping each archived Arrow section whenever its rows’ identities fit one identity record. A section whose sealing fails stages nothing and leaves the checkpoint as it was, so a restore the node refused only for room seals the same section again.

Retained input keys may repeat, and argument rows are unbranched. The relay restore boundary applies one-record-per-concrete-branch validation after this shared decoder; that relay invariant does not restrict retained window rows. A relay’s restored row views stay within its 8 MiB metadata ceiling, while a branch reopening its window charges the row views of every retained row to the relay memory class, so a window retaining more rows than that ceiling reopens after a restart or a restore. Relay snapshot transfers use quota-owned files independently of the window’s encoding.

Worked Example: clamp

This example traces one assignment from its declaration to its execution:

SET bounded = clamp(input.latency_ms, 0, input.budget_ms)

Here input.latency_ms and input.budget_ms are I64 fields and bounded is an I64 output field.

  1. Parse. The parser produces Expression::Call with the name clamp and three argument Models. It knows nothing about clamp.

  2. Lower. clamp is not a datetime name, so FunctionName::parse yields FunctionName::Clamp. The assignment is the route’s first operation, and every node in it carries that operation’s span.

  3. Catalog. builtin_descriptor maps it to BuiltinLowering::Clamp. Its semantics:

    • immutable, with a constant dependency scope
    • strict nulls
    • it can fail, because a row’s low bound can exceed its high bound or be NaN

    Its arm execution is WholeBatch, because two comparison bitmaps and two selections cost less than narrowing a batch to the selected rows.

  4. Type check. builtin_output_type requires three arguments of one identical, ordered type (numeric, STRING, or DATETIME) and returns that type:

    • an I32 bound beside an I64 value is a type_mismatch
    • a BOOL value is unsupported_function

    The result is optional when any argument is, and sensitive when any argument is.

  5. Compile. Because clamp can fail, it is neither folded nor shared. The compiler emits InstructionKind::Builtin { lowering: Clamp, inputs }. It writes the assignment with a fallback, so a row that fails leaves bounded null. Inside a CASE arm, the instruction also carries the arm’s selection register. The literal 0 stays a scalar register.

  6. Execute. execute_builtin calls extremum::clamp:

    • Comparisons. For integers and floats it compares the value with each bound in one pass over the value buffers, with IEEE 754 rules, so a NaN value is neither below nor above anything. For STRING and DATETIME it calls Arrow’s lt and gt.
    • Selection. Two zip selections pick the bound or the value for each row. nullif then nulls every row that has a null argument or invalid bounds.
    • Errors. It returns bitmaps of the rows whose bounds are invalid. The runtime builds a SideError with InvalidClampBounds only for the set bits of those bitmaps, so a clean batch allocates no error at all.
  7. Report. A failed row reaches the route’s ON MESSAGE ERROR policy. Its error has the code evaluation, the kind invalid_argument, the operation set, and the operation index 0. The runtime built its field set when it compiled the route: the assigned field and every field the expression reads, sorted as input.budget_ms, input.latency_ms, and output.bounded.

The public contract is in Extrema. Unit tests are in extremum_tests.rs, and the public qualification is the Greatest, least and clamp outline in tests/features/runtime/membership_ranges_extrema.feature.

Batch Execution

Typed Batches And Registers

TypedBatch carries:

  • its schema, held rather than rebuilt for each batch
  • one TypedArray per field
  • its row count
  • the row errors it arrived with

A TypedArray has one variant per scalar register type, plus these:

  • Datetime, a nanosecond UTC timestamp array
  • Generic, for lists
  • Uninitialized, for an output no assignment has written yet

Each NSPL type has one Arrow representation, and the VM executes over all of them:

NSPL typeArrow type
U8, I8, U16, I16, U32, I32, U64, I64The matching unsigned or signed integer type
F32, F64Float32, Float64
BOOLBoolean
STRINGUtf8
BYTESBinary. Its values may be empty or hold any octets, and are never read as UTF-8 without an explicit conversion.
DATETIMETimestamp(Nanosecond, "+00:00"). RFC 3339 is only a wire representation of it.
ARRAY<T, n>, VEC<T>FixedSizeList and List of the element type

Execution checks the schema once and then works through these steps:

  1. It allocates one register bank for the batch.
  2. It loads each input register with an Arc clone of its column. A sliced array keeps its offset, and kernels read only its visible span of values.
  3. It carries the batch’s incoming row errors forward.
  4. It runs the instructions in order.
  5. It reads each output register, expanding a scalar into a column at this point.
  6. For a program with a WHERE, it filters the output columns, the invocation arguments, and the row errors together.

Route construction is one ordered program, and it executes as follows:

  1. Inherited fields. An inherited field that no assignment changes is bound straight to its input register, so the output column is the input column itself.
  2. Assignments. Assignments run in written order. A repeated target rebinds the field to the later value’s register, so it replaces the current output column. A later assignment that reads the field reads the value it holds at that point.
  3. Finalization. Each field is then finalized:
    • Uninitialized optional outputs materialize as typed nulls.
    • A required output that is still uninitialized, or holds a null, fails. The compiler rejects both cases statically wherever it can prove them.
  4. Route WHERE. The route’s WHERE then reads the finalized output.

This is the implementation of The Working Message, which owns the scopes and their edge cases.

Conditional Arms And Selected Rows

The compiler compiles each CASE arm under a selection:

  • An arm’s condition is compiled as coalesce(condition, false), over the rows that no earlier arm answered.
  • Its result is compiled over the rows its condition selected.
  • A nested arm narrows its parent’s selection with AND.

An instruction observes its arm’s selection only when its result on a selected row depends on which rows it runs for:

  • an operation that can report a per-row error
  • an injected call
  • a JSON scan
  • a cast that reads or writes text

Every other instruction runs over the whole batch, even inside an arm. Its value on an unselected row is discarded, and nothing else it does is observable. This covers every function that cannot fail, such as lower, replace, date_part, or format_datetime.

For an instruction that does observe the selection, an ArmSelection reads the selection mask once. It classifies the mask as all rows, no rows, or some rows, and every instruction under the same mask reuses that classification. The runtime then chooses how to execute each instruction:

CasePlan
The arm selects no rowWrite a typed null without running the kernel.
Every operand is scalar and the instruction is not volatileCompute one row, and copy its errors to the selected rows.
WholeBatchRun the kernel over the whole batch, then discard the errors it reported for unselected rows by truncating each row’s error list back to its earlier length.
SelectedRowsNarrow each column operand to the selected rows with one Arrow filter, run the kernel over those rows only, and scatter the result back with take. Unselected rows get nulls, and errors are re-indexed onto the batch rows.

execute_select then merges the arms from last to first with Arrow zip. It skips an arm that selected no row and takes an arm that selected every row as it is.

The catalog’s ArmExecution makes the cost decision for each fallible builtin. Narrowing costs a few nanoseconds for each row of the batch, so it pays only for kernels that cost more per row:

  • SelectedRows:

    • regular expressions, LIKE, contains_any, split, join, and concat_ws
    • NFC normalization
    • the encodings and decodings, sha256, and UTF-8 conversion
    • the transcendental functions
    • uuid_v7
    • reading and writing IP address text, and URL parsing
    • date_trunc, date_add, date_diff, and parse_datetime
    • casts that read or write text

    repeat, lpad, and rpad must use SelectedRows, and not only for cost. Every row’s text shares one column’s size limit, so text built for an unselected row could exhaust that limit and fail a selected row.

  • WholeBatch: checked arithmetic, clamp, bit shifts, date_bin, from_unix, and non-text casts, which are vectorized kernels.

An injected call, whether a header read, a window aggregate, or a UDF, receives only the narrowed arguments, the batch indices of the selected rows, and the errors those rows already carry. It is not called at all when its arm selects no row. A JSON scan parses only the selected documents.

AND, OR, and coalesce compile every operand under the same selection. They run Arrow’s Kleene boolean and zip kernels over whole columns. Only conditional arms narrow evaluation.

Row Errors And Batch Errors

A per-row failure is a SideError: a typed SideErrorReason and the operation’s span. Each reason maps to one stable ErrorCode:

  • division_by_zero
  • overflow
  • cast_failed
  • invalid_argument

The reason stays typed until the host reports it, so its text is formatted only for a row that actually failed. RowErrors is sparse. A batch without failures allocates nothing, and push_failures builds reasons only for the set bits of a kernel’s failure bitmap. Reasons name the operation, the target type, or a position or length, never the value.

There are two exceptions:

  • A regular-expression syntax error carries the regex crate’s message, which quotes the pattern. A pattern read from a field can therefore appear in error.message, as Regular Expressions states.
  • An injected call’s error text is chosen by the injector.

A failure that belongs to the batch is a RuntimeError, returned as Err:

  • a schema that does not match the program
  • an Arrow kernel error
  • a collection larger than Arrow can address
  • an execution the bounded executor did not admit, or that panicked on its worker
  • a formatted datetime column larger than its offsets allow
  • an invalid injected result, such as the wrong type or row count

Not every caller of the VM has an error route. The Errors section of the reference says what each context does with a row error.

Prepared Patterns, Sets, And Matchers

ArtifactPreparedHeldBound
Constant regular expressionOnce, when the call is lowered (ConstantPattern)Inside the program; one search cache is borrowed from the regex’s pool for each batch, so no row takes a lock10 MiB compiled size, 2 MiB lazy-DFA search cache
Pattern read from a fieldOn a miss, on the executing threadDynamicPatterns, one cache per call site per program, shared by clones and across batches. Each batch loads one ArcSwap snapshot and publishes a miss copy-on-write.64 patterns, oldest compiled evicted first; rows that repeat the previous row’s pattern skip the lookup
Constant contains_any listOnce, as an Aho-Corasick matcherInside the program128 patterns and 64 KiB; over that, each non-null row fails
Per-row contains_any setOn a miss, for each batchA per-call cache for one batch; a hit borrows the Arrow strings without allocating64 sets per batch
IN setOnce, from evaluated constant elementsMembershipSetUp to 8 fixed-width values compared in turn; larger fixed-width sets and every STRING set hashed
Constant CIDR networkOnce, when the call is loweredInside the programA per-row network is reparsed only when its text differs from the previous row’s
Datetime zone and formatOnce, when the call is loweredInside the resolved callA 256-byte longest formatted value, checked at compile time

LOOKUP_HASH_MAP is not a VM function at run time. The runtime rewrites each lookup into an internal input field, and runs the key expression as its own program before the route’s program. Identical lookups of one program are answered once, and the answer enters the program as an input column. A key expression that fails fails the batch. The map itself is the resource version the graph pins; see Lookups and Resource Versions And Bindings.

Allocation And Result Bounds

Registers are allocated for each execution and are not reused across batches. Kernels reserve their output from the input where the size is known:

  • string builders sized from the input’s offsets and values
  • 32 bytes per row for md5
  • numeric lanes sized up front
  • one scratch string reused across the rows of a batch

The per-row allocations that remain belong to functions whose output is irregular:

  • the Unicode path of lower and upper
  • repeat
  • uuid_v4’s text
  • list contains and overlap, which slice each row
  • the error copied to each row of a failed shared value

The limits users see are enforced in these places:

  • Argument-sized text. A value whose length an argument chooses is built through ByteColumnBuilder in text_column.rs. It sizes each value before allocating it and refuses one that would take the column past 2,147,483,647 bytes. A shared value is charged once for each row it stands for. A refused value fails its row with overflow.
  • Text built without that check. concat, replace, regexp_replace, translate, lower, upper, and initcap use Arrow’s ordinary builders, so no row reports overflow for them. Arrow’s builder does not return an error when its 32-bit offsets overflow: it panics. The reference’s Result Size asks users to keep these results within the column limit for that reason.
  • Other bounds. split stops at 65,536 parts, LIKE patterns at 4 KiB, JSON documents at 16 MiB and 128 levels, and format_datetime at its preallocated column. Lists that Arrow cannot address fail the batch with CollectionTooLarge.

Scheduling And Execution Bounds

The VM’s entry point alone decides where a program runs:

ConditionWhere it runs
At most INLINE_ROW_LIMIT (1,024) rows, and no injected function whose FunctionExecutionPolicy is ExtensionInline, on the caller’s task
More than 1,024 rows, and no such functionOn the node’s data workers, admitted through the bounded executor
Any Inject instruction whose injector’s FunctionExecutionPolicy is ExtensionOn the node’s extension workers, admitted through the bounded executor, whatever the batch size. Every UDF call does this.

A caller hands the entry point the node’s Executor and awaits the result; it chooses neither the class nor the charge. An admitted execution is charged to the relay memory class for the bytes the batch’s columns hold, the usual order of what the program builds from them, and never more than one relay batch may decode into. A batch Arrow cannot measure is charged that bound. A UDF runs operator-supplied native code the node cannot bound, so its executions take the extension class: one that never returns holds an extension worker and leaves the data workers, which relay bodies are encoded and decoded on, their whole capacity.

Admission can refuse. A class whose wait queue is full refuses the execution with RuntimeError::ExecutionNotAdmitted, and a job that panics on its worker returns RuntimeError::ExecutionPanicked. Both are batch errors, which reach the node’s error policy as the report of the planned step that ran the program, like any other batch failure.

An inline execution never yields and has no cancellation point: it runs every instruction over its batch to completion, and the batch’s size and the limits above bound that work. An admitted execution checks between its instructions whether its caller stopped waiting, and returns RuntimeError::ExecutionCancelled at the next instruction if it did, so it costs at most the instruction already running. It keeps its relay charge until it actually exits. Stopping a node or a processor otherwise takes effect between batches. Host loops call consume_budget once per batch iteration, not the VM. A UDF adds its own watchdog, described below.

Kernels

SIMD Kernels owns the primitives boundary, dispatch and build targets, caller catalog, exact buffer results, scalar/level qualification, and measured evidence. This chapter owns how the VM selects those operations, masks nulls and failures, and reports errors.

Kernel Classes

Every kernel falls into one of five classes. The class decides how its cost scales and which claim about vector instructions it supports.

ClassExamplesClaim
Arrow compute kernelBoolean logic, STRING/BYTES/DATETIME comparisons, LIKE/ILIKE, contains/starts_with/ends_with, casts, CASE/coalesce/nullif selection, filter/take/zip/interleave, UTC date_part, bitwise_and/or/xor, list sum per rowWhatever Arrow 58.4’s kernels do; Nervix adds none of its own
Explicit SIMD lanesChecked + and - over I8, U8, I16, U16, I32, U32, I64, and U64, checked * below 64 bits, integer column-by-scalar / and % below 64 bits, and I64 column-by-scalar /nervix-simd-kernels selects the vector instructions once per process, with a scalar fallback, and every level computes the same lanes; the SIMD kernels 07 report and 08 report inspect the generated code
One pass over value buffersInteger division by a column, U64 column-by-scalar / and % and I64 column-by-scalar % with a prepared reciprocal, checked * over I64 and U64, checked float arithmetic, numeric comparisons, fixed-width IN, abs/sign/negation, ceil/floor/round/trunc, sqrt, shifts, classification, fixed-unit date_trunc/date_bin/date_add/date_diff, to_unix/from_unix, length/octet_length/bit_length, ASCII lower/upper, list countWritten so LLVM may auto-vectorize the loop for the target CPU; no claim that it does
Library with runtime SIMD dispatchJSON structure (simd-json), base64 (base64-simd), hexadecimal (faster-hex), sha256 (sha2 with SHA-NI detection)The library selects instructions at run time; xxh3_64 selects them when the binary is built
Irregular, per rowTranscendental math, round(value, digits), zoned and calendar datetimes, datetime formatting and parsing, Unicode case mapping and NFC, regular expressions, Aho-Corasick, substring and padding functions, md5, IP and URL parsing, JSON path walk and conversion, most list functions, UUIDsBatch API with optimized substeps; scalar work per row

Arrow Kernel Reuse

The VM reuses Arrow kernels from arrow-arith, arrow-ord, arrow-select, arrow-string, and arrow-cast wherever their semantics are exactly NSPL’s. It deliberately avoids them in these cases:

  • Numeric comparisons. Arrow’s comparison kernels order floats by the IEEE 754 total order. NSPL comparison is IEEE 754 equality and ordering, in which NaN equals nothing and the two zeros are equal. numeric::Comparison evaluates every numeric comparison, integers included, over the value buffers. IS DISTINCT FROM, nullif, list contains, IN, greatest, least, and clamp apply the same float rules.
  • Arithmetic. Arrow’s wrapping or erroring arithmetic cannot report which rows failed. The checked lanes described below compute arithmetic instead.
  • List sum and fixed-width float dot. These keep ordered accumulation, checked integer overflow, and a finiteness check, rather than Arrow’s wrapping sum.
  • Three casts. A float-to-STRING cast uses Rust formatting. A STRING-to-DATETIME cast parses RFC 3339 through chrono. BOOL–DATETIME casts always yield null.
  • UTC date_part. It reads the column as a zoneless timestamp, so Arrow does not convert every lane through an offset.

List min and max are the one place that intentionally keeps Arrow’s total order. The reference documents how that differs from greatest, least, and the window aggregates.

Checked Buffer Kernels

numeric.rs computes every lane of a checked operation in one branch-free loop:

  • Failure bitmap. Each lane returns its value and whether it failed. A block of up to 1,024 lanes runs in one loop that stores each lane’s failure as one byte, and one call to nervix-simd-kernels packs the block’s bytes into its failure words with vector compares and bitmask extraction at the SIMD level the process selected. No lane shifts its failure into a word, so packing does not decide whether the lane loop vectorizes.
  • Explicit integer lanes. Integer + and -, and * below 64 bits, run no lane loop of their own. CheckedInteger hands the operands’ value buffers, and a scalar operand as the value every lane shares, to CheckedArithmetic in nervix-simd-kernels, which computes a register of lanes at a time and returns each lane’s value together with the failure words, so no failure byte is stored or packed. A sum or a difference wraps in its operands’ own lanes, and the signs of its operands and result, or the carry out of unsigned lanes, give its failure bit at every width. An 8-, 16-, or 32-bit product widens into two registers of lanes twice as wide, where every product is exact: comparing it against the narrow type’s bounds gives the failure bit, and narrowing it back by truncation gives the lane’s value. Every lane holds the wrapped value that overflowing_add, overflowing_sub, or overflowing_mul returns. A 64-bit product has no wider lane to be exact in, so it stays on the lane loop.
  • Failed lanes. A failed lane becomes null and its value is zeroed, so a wrapped result never escapes.
  • Validity. A result lane is null wherever any operand lane is null.
  • No rerun. The kernel never reruns a batch. The failure bitmap, restricted to lanes whose operands are valid, is the only record of a failure.
  • Scalar operands. A scalar operand is folded into the lane function, or handed to an explicit kernel as the value every lane shares.
  • Inlining. Each operator passes its own lane function or kernel operation, so the call inlines.

The datetime kernels share these lanes. Fixed-unit truncation and binning and to_unix prepare one reciprocal divisor at entry. For a non-power-of-two divisor d, the prepared unsigned reciprocal is floor(2^64 / d). Discarding its lower 64-w bits gives floor(2^w / d) at a narrower operand width w; the high half of its product with a dividend gives a quotient at most one below the exact quotient. One remainder comparison corrects it. Powers of two use a shift and mask. Signs are restored after division of unsigned magnitudes, which keeps MIN representable and distinguishes truncation toward zero from Euclidean rounding. Binning normalizes both its value and origin, then corrects their phase difference by at most one stride. It fails a lane if subtracting the distance to the bin start overflows.

Fixed-unit date_diff subtracts in i64 and uses its prepared truncating divisor when the difference fits. Otherwise it computes the difference and division in i128, then checks the result against i64. Calendar differences retain their calendar rules. date_add and from_unix use checked i128 multiplication and addition; from_unix performs no division.

Integer column-by-scalar / and % use the same reciprocal primitive. Division by a column retains the checked scalar lane loop. A quotient by zero or MIN / -1 fails; a remainder fails only for zero, so MIN % -1 remains zero. Operand validity still masks failures, and a null scalar produces an all-null column without running division. The constant-lane benchmark compares each width’s selected kernel, scalar reciprocal and checked lane loop on identical runs; U64 and I64 remainder retain scalar reciprocal loops because their vector forms measured slower on x86-64-v3. The retained paths and generated instructions are recorded in the SIMD kernels 08 report.

Floating-point operations fail a lane that produces NaN or infinity. The transcendental functions come from the platform math library as opaque calls per lane that no loop vectorizes, so those kernels compute valid lanes only, as the decimal rounding and calendar kernels do. They read validity one 64-lane word at a time: a word without a valid lane computes nothing and fails no lane, a fully valid word runs the ordinary lane loop over its 64 lanes, and any other word computes its valid lanes alone with the failure bytes of its null lanes cleared. The words’ failure bytes fill the same blocks the other kernels pack, so a null lane never fails, no lane writes a failure word, and a word with few valid lanes pays for no packing call of its own. round(value, digits) rounds exactly in integer arithmetic on the value’s significand, and a digit count beyond ±400 rounds as ±400 does. A shift reads its count from any integer type and fails a negative one. A count at or beyond the value’s width moves every bit out, and the lanes stay branch-free.

Batch Execution, Compiler Vectorization, And Explicit SIMD

These are three different claims, and the implementation makes them separately:

  • Batch execution. Every function computes a whole column in one call, and the cost of dispatch, type checks, and registers is paid once per batch. Every function in the catalog executes this way, including the irregular ones.
  • Compiler vectorization. A buffer loop is written so that LLVM can widen it to the vector instructions of the CPU the binary targets, and its result is the same whether or not it does. The effective compiler flags determine the baseline: local target-cpu=native builds use the compiling host’s features, while untuned builds use the compiler target’s baseline. The Debian image builds its x86-64 payloads for x86-64-v3 through cargo-sonic. A compiler-vectorized loop needs inspection of the generated instructions for the particular build before claiming a specific instruction set.
  • Explicit SIMD. The VM hands typed buffers to nervix-simd-kernels for failure packing and checked integer operations. The caller catalog also covers JSON, delivery latency, domain/window admission, selections, and text codecs. Library-dispatched functions retain the library’s selection; xxhash chooses when the binary is built.
  • Dispatch. The kernel crate caches one fearless_simd level per process and enters it once per buffer operation. The build-target matrix defines SSE2, SSE4.2, AVX2, Ice Lake class AVX-512, NEON, WASM SIMD, and forced fallback, including the ambient baseline that can coalesce lower test tokens into a higher backend. Every integer lane and failure bit is exact. The VM names no instruction set and uses no std::arch or target_feature of its own.

The VM functions measurement report records what the measurements establish, and Measured Performance turns them into user guidance:

  • Fixed-width work. Checked arithmetic ran at about 127 million rows per second on a clean 1,024-row batch.
  • Variable-length work. Ragged lists, text search, and JSON ran at single-digit millions.
  • Failures. Dense failures made checked arithmetic about six times slower, because each failed row builds an error.
  • The executor hop. Crossing the 1,024-row threshold admits the execution onto the data workers, which costs more than executing a small batch.
  • Conditional arms. A regular expression in an arm that selects half the batch costs about twelve times one that selects none.
  • JSON sharing. Four extractions from one document cost less than a third of four extractions from four documents.

The timings were taken on one development machine with other builds running, so they are diagnostic. Without hardware counters or generated-instruction inspection, they do not establish a SIMD speedup. The SIMD kernels 07 report records the generated instructions and interleaved Criterion rounds of the explicit integer lanes.

Nulls, NaN, Overflow, And Unicode

  • Nulls. A strict kernel’s result is null wherever any operand is null. Membership and length results reuse the input’s null buffer as it is. IS [NOT] DISTINCT FROM combines both validity bitmaps into a never-null result.
  • Cast failures. A strict cast runs Arrow’s safe cast, which nulls a value it cannot convert. It then reports the rows that became null without being null in the input, and skips that check when the null counts are equal. TRY_CAST keeps those nulls silently.
  • NaN. Comparisons are IEEE 754 everywhere except list min and max. An IN set stores floats by bit pattern after canonicalizing -0.0, and never matches NaN.
  • Overflow. Integer overflow, division by zero, and MIN / -1 fail their lane. Datetime results outside the nanosecond range fail their lane. A window’s integer SUM accumulates in i128 and is checked only when the window emits.
  • Unicode. STRING values are valid UTF-8 by Arrow’s contract. Case mapping is full Unicode without a locale. length counts code points by skipping continuation bytes. STRING ordering is bytewise, which for UTF-8 equals code-point order. Nervix has no collation.

Operations That Do Not Reduce To SIMD

Several widely used operations cannot be expressed as a fixed-width loop over value buffers. For the functions this catalog delivers, the implementation optimizes the substeps that can be batched and leaves the irregular remainder per row, still inside one batch call:

OperationOptimized substepsRemaining scalar or irregular work
Unicode case mapping and NFC normalizationA column whose visible text is all ASCII is case-mapped in one pass over its value buffer, reusing its offsets and nulls. NFC writes into one bounded builder.A column with any non-ASCII byte is mapped row by row with allocation. Normalization composes each row. Nervix has no collation to optimize: strings compare bytewise.
Regular expressions and capturesConstant patterns compile once. A batch borrows one search cache per pattern. Field-supplied patterns are cached across batches and deduplicated within one. The regex engine runs its own vectorized literal prefilters. Inside a conditional arm, only selected rows are searched.Each row runs its own search. Captures allocate their slots once per pattern per batch, then each match is extracted and interpolated per row.
JSON extractionsimd-json finds a document’s structure with instructions chosen at run time. Every extraction from one document column shares one parse. A literal document is parsed once per batch.The path walk over each tape, conversion to the declared type, and the depth check are per row.
Formatted datetimesA format compiles once. Writing lays the format out into fixed-width templates and fills one preallocated buffer.Parsing is a strict per-row reader.
Calendar and time-zone logicFixed-length units in UTC or at a fixed offset use the vectorizable fixed-unit lanes. A time zone resolves once from the bundled database. Consecutive lanes inside one offset span reuse one zone lookup.Months, quarters, years, and local days or weeks under an IANA zone compute per row through Jiff’s civil calendar, including disambiguation and month-end clamping.
contains_any and INConstant pattern lists build one Aho-Corasick matcher. Small fixed-width sets compare in turn and larger ones hash, with the threshold chosen by benchmark.Each row is scanned or hashed individually. Per-row pattern sets build a matcher per distinct set per batch.
ListsCounts read offsets. First, last, and nth are index arithmetic plus one take. min and max over fixed-width lists without null elements compare whole columns one element position at a time.Ragged-list extrema, mean, distance, dot, contains, and overlap loop over each row’s elements.
Sketches and window structuresAggregate arguments are evaluated once per batch as Arrow columns. Typed runs fold validity by popcount, exact integer sums by split SIMD lanes, floating sums and centered moments by per-lane folds, and histogram indexes before scattering. Non-finite arguments are classified by column bitmaps.Candidate deque updates, bucket scatter, BLAKE3 per distinct input, t-digest insertion, and Misra-Gries updates remain irregular. Sketch keys reuse a run buffer.
Encodings and hashesBase64, hex, and SHA-256 use library SIMD dispatch per value. Output lengths are checked before encoding.Each value is encoded or hashed separately. md5 is scalar.
IP addresses and URLsConstant networks parse once. A containment test is one mask and compare on fixed-width integers. A scalar URL parses once per batch.Address text and URLs parse per row under the URL Standard.
Roto UDFsOne vectorized call per batch or selection, with column methods over Arrow arrays.Whatever the UDF body does. Its get and builder methods are an explicit per-row path.

Function Families

The reference owns each family’s contract. These notes describe its implementation.

  • Numeric. Checked lanes cover arithmetic, comparison, abs, sign, and rounding.
    • round(value, digits) rounds exactly, with halves away from zero, using integer arithmetic on the significand. Integer rounding to negative digits computes in 128 bits.
    • Bit operations reuse Arrow’s binary and unary for and, or, xor, and not, and branch-free lanes for shifts.
    • Classification never fails.
  • Temporal. A DATETIME is signed nanoseconds since the epoch in an Arrow UTC timestamp.
    • Fixed units compute in checked lanes.
    • Calendar units and zones use Jiff with the IANA database it bundles, and never read the host’s zone or locale.
    • Time zones, formats, units, and widths resolve once when the call is lowered.
    • now() and every execution-local instant come from the execution context.
  • Strings.
    • length, bit_length, octet_length, and trim read or copy contiguous slices.
    • LIKE and ILIKE are Arrow kernels behind a pattern-length bound.
    • split returns an Arrow list, bounded in parts and bytes.
    • Search, capture, and normalization are described above.
    • Only NFC normalization exists.
  • Arrays and vectors. ListColumn reads a variable list’s offsets or a fixed list’s width.
    • Construction and concat use Arrow interleave. slice builds indices for one take.
    • ListElements in the catalog decides, when the call is compiled, which functions reject nested elements, so no batch reaches a kernel with an element type it cannot read.
  • Bytes, encodings, and hashes. BYTES is an Arrow Binary column.
    • Encoders check each output’s length against the column before encoding.
    • bytes_from_utf8 reuses the text’s buffers without copying.
    • Hashing preserves sensitivity like any other function.
  • JSON. One JsonScan instruction per document register and selection parses each document once, into a tape. Every JsonScanOutput then walks its path over that tape and appends directly to a typed column of its declared type, truncating back to a mark when a row fails.
    • A document is never held as an untyped value, and no row becomes a map.
    • JSON_VALUE and TRY_JSON_VALUE are one extraction with different CastFailure choices. Even the tolerant form still reports a result too large for its column.
  • Network. An IP address is a 4- or 16-byte BYTES value, held internally as a 32- or 128-bit integer.
    • A network is a network and mask pair, so containment is one mask and compare.
    • URLs parse with the url crate, and the functions never resolve a host or fetch anything.
  • Conversions. AS and TRY_CAST share one cast path through Arrow’s safe cast. A STRING read as a number follows Arrow’s parser.

Context, Determinism, And Extension Boundaries

Domain Time

The one timestamp an execution sees is ExecutionContext::now. The host takes it from the domain execution snapshot of the unit of work. All of these use it:

  • now(), and every datetime function applied to it
  • uuid_v7(), whose timestamp field it encodes
  • error.occurred_at in a structured message error

now() is Stable with an ExecutionLocal dependency scope. It is therefore never folded, which requires a constant scope, but it is shared and computed once per execution. A paced domain’s time before the epoch reaches uuid_v7() as it is, and each row that evaluates the call fails rather than encoding a different instant. No VM or datetime code calls SystemTime or Instant.

Processor and materialized relay tasks retain the installed domain clock that supplies each unit’s snapshot. Filtered subscriptions retain its lifecycle owner so a subscription opened before START DOMAIN follows the subsequently installed generation. Each subscription generation also retains the node’s executor and supplies it directly to predicate evaluation, preserving bounded admission for extension calls. Batch evaluation reads these publications directly; it does not discover its clock through the runtime registry.

Deterministic And Volatile Functions

Only uuid_v4 and uuid_v7 are Volatile. A volatile builtin:

  • is never shared by the compiler
  • is never computed once for all rows
  • is never removed as dead code

Window aggregates and header reads are Stable, because they answer from their context. A UDF is deterministic unless it is declared VOLATILE. Only a volatile UDF can call now, rand_f64, or uuid_v4, and UDF code never runs during constant folding.

Extension Boundaries

The FunctionInjector trait is the VM’s only extension point. The compiler emits an Inject instruction for three kinds of call: read_header and read_headers, window aggregates, and UDFs.

At run time the VM looks for an injector in two places: the injector in the execution context first, then the one compiled into the program. Every answer is validated. A result of the wrong type or row count, or a side error that names a row outside the call, is a batch error.

The injectors:

  • Header reads. IngestHeaderFunctionInjector answers header reads from the ingest metadata of one execution.
  • Window aggregates. WindowAggregateResults answers window aggregates from the accumulators at emission.
  • UDFs. nervix-roto’s UdfExecutor answers UDF calls:
    • It receives one call per batch or selection, and masks rows whose required arguments are null or already failed.
    • Every execution that calls a UDF runs on the node’s extension workers, and a panic there is a batch error.
    • It reports per-row errors as side errors, and fails the batch for a trap, a wrong type or row count, an unexplained null, or a call that returns after its 5-second watchdog.
    • The watchdog is checked only after the call returns. It cannot reclaim a worker from native code that never returns, as User-Defined Functions explains.

WASM processors are not VM functions. nervix-wasm does not depend on the VM. A WASM processor’s routes are ordinary set-only VM programs over the guest’s output, and the host gathers guest columns with Arrow identity, slice, take, and concatenate. Guest execution, isolation, and state belong to WASM Processor Guests and WASM State And Recovery.

FunctionInjector and retained window accumulator traits declare recurring compiler contracts, so their implementations and dynamic callers retain the VM callback frequency across crate metadata. UDF result builders document the retained column and admitted execution bound; external Arrow, formatting and operator callback effects are declared at their owning callable. Paired Rust API doctests verify that injection accepts the domain timestamp with its selected-row error mask and rejects an unrelated scalar time. The annotations do not replace executor admission, selected-row validation or the concurrency checks. Data-Plane Concurrency owns compiler contract syntax and diagnostics.

Window Aggregates And Sketches

Planning

window.rs lowers a window route’s assignments:

  • What is rejected. It rejects INHERIT and INVOKE, and targets other than a bare field or output.<field>. input may be read only inside an aggregate argument, and an aggregate argument may read only input. Nested aggregates, and configuration arguments that are not constants, are rejected too.
  • Demands. It rewrites each aggregate call into a demand. A demand names its function, its shared storage kind, and its per-row arguments. Two demands merge when their storage, arguments, histogram configuration, and sketch configuration all match. That merging is what lets AVG and STDDEV_POP of one argument share one moments structure.

window/route.rs then compiles two kinds of program for each route:

  • The argument program. One program evaluates every demand’s per-row arguments over an input batch, writing internal window_argument.demand_<id>_<position> fields.
  • The output programs. For each assignment, a one-row output program turns aggregate results into the output field. Each aggregate call becomes an Inject instruction. Aggregate calls may therefore sit inside arithmetic, a conditional, or a list, because they compile like any other scalar operand.

Registry validation compiles the same route with the same compiler when the processor is applied. It also rejects BYTES arguments and checks the sketch budget.

Branch Ownership And Accumulation

A window and every accumulator in it are fields of the live window of exactly one concrete branch. Only that branch’s task changes them, through &mut self, without a lock. What leaves the task is an immutable publication. See Window and WASM state.

For each batch, the task works through these steps:

  1. It evaluates the argument program once. A whole-batch failure fails every message in the batch.
  2. It refuses a row whose argument failed, or whose float argument is not finite where the structure requires finite values. Each floating argument column supplies a non-finite bitmap.
  3. It checks the state budget, then admits the rest in runs of consecutive rows. A run ends where the window’s width fills.
  4. For each run, every structure reads one typed value slice and validity bitmap from the shared argument columns. Counts use bitmap popcounts; integer sums use exact high and low lane halves; float sums use per-lane TwoSum; moments use a compensated mean pass and a centered second pass, normalizing by the largest contributing magnitude when summing finite inputs overflows before division, then one Chan merge into the branch’s aggregate. Histogram indexes are computed for the run and scattered into bucket counts. Each retained row holds its sequence, its timestamp, and a row view of the Arc’d input and argument batches. The normalized mean is clamped to its operand range before rescaling; a constant finite run keeps its exact mean and zero M2 and cross moment even when its sum overflows.
  5. It emits while the width is met, and then steps the window. Stepped rows are acknowledged.

Tumbling and sliding windows use this same path, with different WIDTH and STEP.

StructureFunctionsAlgorithmRetraction when the window steps
counterCOUNTExact 64-bit countSubtract
truth_counterCOUNT_IF, BOOL_AND, BOOL_ORExact true and false countsSubtract
sum over integersSUMExact 128-bit sum from high and low 32-bit SIMD halves, checked against the argument type at emissionSubtract the same run kernel’s result
sum over floatsSUMPer-lane Knuth two-sum in a two-stack window; F32 accumulates in F64Refold typed surviving runs, never subtract
momentsAVG, variances, standard deviationsTwo-pass centered count, mean, and M2 with one Chan merge per run, in a two-stack windowRefold typed surviving runs
co_momentsCovariances, CORRTwo-pass centered co-moments with one Chan merge per run; CORR clamped to [-1, 1]Refold typed surviving runs
extremes, sequence, arg_extremesMIN/MAX, FIRST/LAST, ARG_MIN/ARG_MAXMonotonic candidate deque ordered by value, arrival, or key; ties keep the earliest rowDrop front candidates older than the first survivor
linear_histogramPERCENTILE_LINEAR_HISTOGRAMFixed-range buckets, answered with a bucket midpointImmediately, or after the configured delay on the domain clock
hllAPPROX_COUNT_DISTINCTHyperLogLog over the first 8 bytes of a BLAKE3 hash of a type-tagged key, with linear counting at small cardinalitiesRebuilt from survivors
quantile_sketchAPPROX_QUANTILEt-digest capped at its capacity, merging the least-cost adjacent centroids; exact while it holds every valueRebuilt from survivors
frequent_itemsAPPROX_TOP_KMisra-Gries candidates, ordered by count and then key bytesRebuilt from survivors

The two-stack window keeps its floating-point statistics exact under sliding:

  • Front. A stack whose entries aggregate the oldest retained rows. Its top entry covers the oldest front row together with every newer front row, so dropping that row pops one entry.
  • Back. One aggregate of every retained row after the front, extended as rows are admitted.
  • Refold. A retraction that reaches past the front groups survivors by typed argument run and folds each run from newest to oldest into a fresh front.

Nothing is ever subtracted, so a value that left the window leaves no rounding behind, at amortized constant cost per row.

Run boundaries and SIMD lane order determine the association of floating-point additions and Chan merges. Their results are associative up to floating-point rounding and can differ slightly when batching changes. Integer sums and counts remain bit-identical regardless of run boundaries.

Sketch Panes, Merge, And Expiry

A sketch window keeps one sketch per pane. A pane is a span of the greatest common divisor of WIDTH and STEP, aligned to the Unix epoch, and a window spans at most WIDTH / pane + 2 panes. At emission the window merges every live pane into a fresh sketch:

  • HyperLogLog takes the register-wise maximum.
  • The t-digest inserts every centroid and compresses.
  • Misra-Gries adds weighted counts.

Each top-k value is sliced from the earliest retained row with that key.

The first row admitted into a pane creates its sketch, found or inserted by one search of the window’s ordered pane map; every later row of the pane updates that sketch in place. A row whose argument is absent admits nothing, so a pane holding only such rows keeps no sketch. just bench-window-sketches measures admission at one, 16 and 1,024 rows per pane for each sketch.

A sketch cannot subtract a row. When a window steps, every pane is cleared, and every surviving row is admitted again. This rebuilds the whole window rather than one pane, so a row that left the window never contributes to a later estimate. Mergeability is what makes the pane merge cheap; it never implies that a sketch can retract a row.

Bounded State

A sketch’s reservation per pane is fixed by its configuration:

  • 2^precision + 128 bytes for HyperLogLog
  • 32 * capacity + 128 bytes for the t-digest
  • 160 * capacity + 128 bytes for Misra-Gries

When the processor is applied, processor/sketch.rs requires these declarations:

  • MAX STATE SIZE
  • duration WIDTH and STEP
  • for a branched window, a branch that declares MAX INSTANCES

It rejects a route when 1024 + reservations * (maximum panes + 1) bytes exceed that size. The extra pane is the merged sketch built at emission.

At run time, check_admission charges a branch before any state changes. The charge covers:

  • a fixed base
  • per-row bookkeeping
  • each shared input and argument batch, charged once at the greater of its actual and its estimated size
  • the branch key
  • every sketch in every active pane, plus the merged sketch

A run that does not fit is retried one row at a time. A single row that still does not fit is refused through the node’s message-error policy, which logs it and leaves it unacknowledged. More active panes than the maximum is refused the same way. Restoring a snapshot passes the same check. Only a window that declares MAX STATE SIZE is charged, and every sketch window must declare it. Histogram buckets are not charged, as the reference states.

Publication, Handoff, And Recovery

A branch task marks its window dirty after every admission and every emission that changes it. It publishes an immutable generation through an ArcSwap on these occasions:

  • at each replication poll, while the window is dirty
  • before an ownership-handoff checkpoint
  • when the branch stops

The snapshot task persists publications on its own interval. See Data Plane for the persistence guarantees.

A published window carries its retained rows, each with its sequence, timestamp, key, and the Arrow row views of its input and argument columns. It does not carry the accumulators themselves. The snapshot codec seals those views as bounded Arrow sections on the bulk executor. Only the histogram’s delayed removals ride beside them in a typed section, because the retained rows cannot reproduce them.

Each delayed removal archives its bucket index as a fixed-width 64-bit count, with checked native decoding. The current snapshot frame begins with NVXWIN64, and window state uses runtime-state kind tag 8. These identify the current stored and transferred shape before decoding; an unrecognized frame fails validation, and stored state outside the current namespace must be recreated. The count contract is defined in Archived Counts.

A snapshot from an earlier incarnation of the branch restores an empty window and marks it for publication, so a late checkpoint of a previous lifetime cannot restore its panes. Otherwise, restoring checks that the snapshot’s sequences are consecutive, that its input schema matches, and that every row belongs to this branch. It then re-admits every row in order. That rebuilds every exact structure and every sketch pane from the rows themselves, then reapplies any delayed removals.

The argument columns of a snapshot have one schema, which the window compiler derives from the route’s aggregate demands: one nullable column per argument of every demand, in demand order. Native snapshots and backup archives both use it, and a reader opens a sealed snapshot only against that schema and the window’s input relay schema.

A backup archives the same publication in a public form. Each window branch becomes an rkyv descriptor holding the retained rows’ admission sequences and watermarks, the branch incarnation, the window model digest and every histogram’s delayed removals, together with bounded groups of the retained input rows and their argument columns as Arrow IPC. A restore converts those groups back into a native snapshot, which the branch task restores as it restores any other: every row is re-admitted in order, and the histograms take their delayed removals back. The window model digest is the BLAKE3 hash of the window processor’s canonical NSPL, so a window whose WIDTH, STEP, state limit or aggregates changed starts empty instead.

How each ending treats the window:

EndingEffect on the window
Handoff or detachFlushes a window whose width is met, then publishes what remains.
EvictionResets the window first, so its final publication is empty and a later branch with the same key starts fresh.
Quiesced backup cutPublishes any change since the window’s last publication when the cut’s lifecycle checkpoint asks. The retained rows’ acknowledgement shares stay parked, so they do not hold the cut open; the cut captures them as window state.
A task aborted after its shutdown grace periodLoses the changes made after its last publication.

Snapshot transfer between nodes follows the bulk transfer rules of the Cluster Interconnect. Drain and forced flush at shutdown follow Shutdown And Recovery.

A result that does not fit its type fails the window when it emits:

  • a SUM outside its argument type
  • a statistic that overflows F64
  • a sketch estimate outside I64

That failure fails every retained row’s acknowledgement and clears the window. The routes the window emitted before the failing one keep their output.

Adding A Function

Treat this as the checklist for a new builtin, or for a new family.

  1. Decide the contract first. Write down these, and check that the function respects the reference’s Function Properties:

    • exact argument and result types, and which results are optional
    • null handling, and every error kind and message
    • volatility and dependency scope
    • sensitivity and eligible contexts

    A new type shape, such as a general decimal or a map payload, is a product change that needs its own contract, not a scalar function name.

  2. Register it. Add the following:

    • a FunctionName variant with its parse and as_str arms, plus aliases only when they are part of the contract
    • a BuiltinLowering variant
    • its arms in builtin_descriptor, builtin_semantics_for_lowering, builtin_output_type, and builtin_arm_execution
    • any expr_may_be_null exception

    A datetime function resolves its literal arguments at lowering instead.

  3. Decide folding. Either add a folding arm that applies a shared catalog contract, or list the function in fold_builtin_call’s refusals. Never fold anything that can fail, is volatile, or reads execution-local state.

  4. Decide can_error honestly. A builtin that pushes a row error must declare can_error. Otherwise the compiler shares it and gives it no selection, so the errors of unselected CASE arms would leak into error handling.

  5. Choose the kernel.

    • Reuse an Arrow kernel when its semantics are exactly NSPL’s, including float ordering, overflow, and null rules.
    • Otherwise write one pass over value buffers with the checked lanes, pushing failures from a bitmap.
    • Keep a dependency with runtime SIMD dispatch only when it preserves the semantic contract.
    • Irregular work stays a batch API with per-row substeps.

    Build text or bytes whose length an argument chooses through the bounded column builder. Read integer counts through count.rs. Take scalars as Operands instead of expanding them.

  6. Choose arm execution. Use SelectedRows for work costing well above a few nanoseconds per row, and for any text sized by an argument. Use WholeBatch for vectorized kernels.

  7. Keep errors typed. Add a SideErrorReason variant with typed fields and its ErrorCode. Format nothing per row, and never include an argument’s value in a reason. A failure of the batch as a whole extends RuntimeError and propagates through error-stack.

  8. Preserve the invariants.

    • no implicit conversion
    • sensitivity from every operand
    • no clock read
    • no row-oriented carrier
    • no lock per row
    • no prepared object outside the compiled program
  9. Test it.

    • kernel and compiler unit tests next to the code, through just test-vm, covering nulls, NaN, overflow, sliced arrays, scalar operands, and conditional arms
    • a public Cucumber scenario outline on one and three nodes that proves values and failures through a graph and a session subscription, run with just test-scenarios --input <feature>
    • a row in the qualification ledger
    • for a kernel with an independent reference, a registered Bolero property over its column kernel, as round(value, digits) has in vm-decimal-rounding-floats and vm-decimal-rounding-integers; see Property Testing And Fuzzing
    • window structures also need interleaved branches, eviction, and snapshot restore
  10. Measure it. Add a Criterion case to crates/nervix-vm/benches/vm.rs or its workload shapes, and run it with just bench-vm. Use just bench-vm-alloc for allocation evidence, and the serialized just benchmark-ab for any end-to-end claim.

    • Claim vectorization only after inspecting the generated instructions for the target.
    • Claim a speedup only from a same-host A/B.
    • Record the result beside the existing report.
  11. Document it.

    • Add its contract to Expression Functions, including a catalog row.
    • Update the NSPL skill only where its routing or checks change, then regenerate the book.
    • Update this chapter when the function changes a mechanism rather than adding an instance of one.

Guarantees And Limits

Guarantees:

  • An expression’s value never depends on the other rows in its batch, on the batch’s size, or on whether a kernel was vectorized.
  • A folded, shared, or scalar computation produces the value that per-row evaluation would.
  • Only a conditional expression evaluates an operand for some rows and not others. An unselected arm never reports an error.
  • Every execution reads one domain timestamp, supplied by its caller.
  • Window state belongs to one branch task. It is published only as immutable generations, and after a handoff or recovery it is rebuilt from its retained rows.

Limits:

  • Column limits: 2,147,483,647 bytes of text or bytes in one column; 1,024 rows before execution moves to the node’s data workers.
  • Regular expressions: 10 MiB compiled and a 2 MiB search cache per pattern, and 64 field-supplied patterns cached per call.
  • Pattern sets: 128 patterns and 64 KiB per constant contains_any list, and 64 per-row pattern sets per batch.
  • Text functions: 4 KiB LIKE patterns and 65,536 split parts.
  • JSON: 16 MiB, 128-level documents, and 64-step paths.
  • Datetimes: 256-byte formatted values.
  • Sketches: precision 4–16, capacity 32–4096, and k at most the capacity.

The complete user-facing limits are in Limits.

Current boundaries:

  • Validation compiles to reject invalid statements, while installation compiles the programs selected by the typed revision against local schemas and capabilities.
  • Branched processor routes compile before publication and share their prepared patterns and sets across concrete branch tasks of the same installed node revision.
  • A message-error route binds once per installed revision; each failed record executes that bound program with its captured inputs.
  • The text functions listed under Allocation And Result Bounds build their columns without the size check.
  • A statement that makes a batch’s program slow is bounded only by the input and the limits above. The VM has no instruction budget and no preemption.

Streams And State

RELAY declares a named, schedulable runtime node between producers and consumers. Every relay has one primary owner. Only that owner instantiates the relay buffer, concrete branch presence, relay metrics, subscription fan-out, and optional materialized state.

CREATE IF NOT EXISTS RELAY notifications
  SCHEMA notification
  UNBRANCHED
  CAPACITY 1;

Streams, Branch Keys, And Branches

External feeds commonly contain records for many tenants, users, devices, accounts, or other business groups. Nervix branching lets one declared graph process those groups independently.

Branching is defined by a schema name on a named branch:

  • CREATE BRANCH by_user SCHEMA user_branch TTL 5m isolates each user
  • CREATE BRANCH by_tenant SCHEMA tenant_branch TTL 5m isolates each tenant
  • CREATE BRANCH by_tenant_user SCHEMA tenant_user_branch TTL 5m isolates each tenant/user pair
  • MAX INSTANCES <n> EVICT LRU can cap active concrete branch instances for that branch

The web console’s Create menu also has a branch form. Its schema picker searches internal schemas in the captured domain, including schemas staged earlier in the attached transaction; the form requires a TTL and accepts the optional positive LRU instance limit. Registry validation remains authoritative for whether the selected schema can serve as a branch key. SHOW CREATE BRANCH <name> returns the stored branch’s canonical declaration.

The same menu has a relay form. It selects the relay’s schema and branch from server-backed lists of the captured domain, including models staged earlier in the attached transaction, and requires an explicit UNBRANCHED or BRANCHED BY choice. Capacity starts from the default relay buffer, and materialized state is none or LAST BY TIMESTAMP. SHOW CREATE RELAY <name> returns the stored relay’s canonical declaration, which always states its capacity. See Web Console.

Relays select an explicit branch or declare unbranched execution:

CREATE RELAY notifications SCHEMA notification BRANCHED BY by_tenant_user;
CREATE RELAY global_notifications SCHEMA notification UNBRANCHED;

Altering Relays

ALTER RELAY accepts one or more comma-separated operations. Operations execute in written order, so later operations may replace values set earlier in the same statement:

ALTER RELAY notifications
  SET CAPACITY 8,
  SET SCHEMA notification_v2,
  SET BRANCHED BY by_tenant,
  SET MATERIALIZED STATE LAST BY TIMESTAMP;

The available operations are SET CAPACITY, SET SCHEMA, SET BRANCHED BY, SET UNBRANCHED, SET MATERIALIZED STATE LAST BY TIMESTAMP, and DROP MATERIALIZED STATE. Dropping materialized state when it is not configured is an error. The registry validates the complete resulting graph, so schema or branching changes must be submitted in the same transaction as every dependent model change needed to make the candidate graph valid.

Capacity changes are dynamically applied to a running domain without replacing the owner buffer or its buffered batches. Schema and branching changes use domain pause because Arrow schemas and branch identity are compiled into producers and consumers. Materialized-state add/drop uses entity pause: Nervix gates and drains the relay, changes the relay’s state-replica membership without replacing its owner buffer, advances the state epoch observed by readers, and releases the gate. Adding state therefore has no interval in which post-commit records can bypass materialization. Dropping state purges the relay’s in-memory and persisted materialized records before flow resumes.

An ingestor or reingestor uses BRANCHED BY <branch> to compute the branch key for each record. When records for a key arrive, Nervix uses a branch instance for that key. A branch instance is the runtime execution path for one concrete key.

Inside a branch, records retain their key while moving through the relay owner. Each concrete relay branch has:

  • the declared RELAY name it belongs to
  • a branch identity
  • a schema
  • buffering behavior

Processing node state also belongs to the branch. That gives each group independent deduplicator history, reorder buffers, window accumulators, and materialized entries. Relay buffering is owned once per declared relay and may contain interleaved batches from several concrete branches.

Runtime branch rules:

  • an INGESTOR starts a branch for one concrete branch key through BRANCHED BY <branch>
  • normal downstream processors keep the same named branch and concrete branch key
  • output routes and forwarders send records to downstream relay names inside the same branch
  • stateful processors keep branch-local state for that group
  • a REINGESTOR may consume across a branch boundary and start new downstream branches through BRANCHED BY <branch>
  • an EMITTER consumes records across the whole input relay and terminates the branch at an external sink

branch is a reserved namespace, so a relay cannot be named branch. Expressions inside a concrete branch may read its immutable key with branch.<key>. Bare fields never resolve to branch fields. Unbranched execution has no branch.<key> values, and successful emitter expressions do not expose the branch scope.

Internal Payload Model

After schema application, Nervix does not keep an internal per-message document format on relays. The runtime payload on a relay is an Apache Arrow record batch plus the schema, the row-aligned ACK state, and each row’s low and high ingestion watermarks. The watermarks travel beside the batch in two Arrow buffers of Unix-nanosecond timestamps, which watermark-based logic and delivery latency read without converting each row.

Apache Arrow is used here for two practical reasons:

  • fast vectorized processing over columnar data inside runtime nodes
  • fast serialization and deserialization when batches move between nodes

Operationally that means:

  • ingestors and reingestors batch decoded rows before writing into a relay
  • deduplicators still apply row-level state semantics inside the node, while junctions stay Arrow-native and concatenate compatible branch-local batches before forwarding
  • window processors keep branch-local online aggregate state and construct each route through aggregate expressions in ordered SET assignments
  • batches remain branch-local until a REINGESTOR or EMITTER boundary changes the routing behavior
  • a producer on a nonowner cluster node serializes a batch once and holds one fixed ingress slot until the owner admits it into the relay buffer
  • the owner serializes an admitted batch at most once for its remote runtime consumers and once for remote session subscriptions, and shares each serialized body across the destination nodes; every local runtime consumer on a node shares that node’s delivery, and a session subscription on a node that also hosts a runtime consumer piggybacks on it
  • a delivery that reaches a node after its attached consumer moved away fails its attached acknowledgements, so the source redelivers the record along the owner’s current routes; see Consumers That Leave The Receiver
  • an owner batch that starts fan-out while a schedule swap fences its consumer routes fails its record acknowledgements and is retried by its source after the swap; fan-out already holding a dispatch permit finishes before those routes change
  • an acknowledgement a receiver returns after the sending node restarted names that node’s earlier run, so the restarted node rejects it and its own records stay pending until their own acknowledgements resolve; see Acknowledgement Registrations

Lookup and state-replication control paths are separate from this relay payload model. The Arrow batch path applies to relay movement inside the data plane.

Relay batches and their per-row ACK metadata are hot-path runtime data. They are not persisted or replicated, and ACK guards, tokens, and maps are never part of runtime snapshots. Materialized state, when enabled, is the only replicated part of the relay; it is not a second runtime node.

Capacity

CAPACITY <n> controls the single buffer on the relay owner. It is one cluster-wide backpressure boundary, not a per-producer, per-consumer, or per-branch capacity. If downstream runtime consumers cannot drain the relay quickly enough, upstream dispatch waits once the owner buffer and the fixed dispatch slots leading to it are occupied. A producer on another node also waits while the owner has no relay memory left for the acknowledgement watches of the rows it sends, until the owner has reported enough earlier rows; see Capacity Planning.

The capacity is a positive integer, through 18446744073709551615 on native 64-bit targets. Creation and ALTER RELAY ... SET CAPACITY retain the exact accepted value in storage, replicated commands, and SHOW CREATE after a node or full-cluster restart.

At most the following batches can be admitted or in dispatch for one relay:

  • CAPACITY batches in the owner buffer;
  • one batch from each producer cluster node to the owner; and
  • one batch from the owner to each remote consuming cluster node.

The owner has no additional inbound queue. A producer’s slot is released only after the owner has admitted that batch. A consumer-node slot is released only after that node has admitted its batch. These fixed slots do not scale with the number of runtime consumers or subscriptions on a node.

The capacity can be changed after creation:

ALTER RELAY notifications SET CAPACITY 5;

The updated capacity is persisted in the relay definition and applied in place to the active owner buffer. Existing concrete branches, subscriptions, runtime consumers, and dispatch slots remain attached.

A capacity change applies at once to admission and never discards buffered batches. Increasing capacity admits waiting publishers immediately. When capacity is shrunk below the current buffered depth, every buffered batch is kept and publishers observe relay backpressure until receivers drain the buffer below the new capacity. The one-batch producer and consumer dispatch slots never resize.

Small capacities are useful in tests and tiny examples, but high-throughput graphs should use capacities large enough to absorb several flush intervals of batches. This is especially important for relays written by external ingestors and relays read by reingestors, because a low buffer can multiply short waits across every branch. If omitted, Nervix uses the default relay buffer.

TTL

TTL is a branch contract, not a relay-local setting. CREATE BRANCH declares TTL <duration> after SCHEMA <schema>. UNBRANCHED branch roots do not declare TTL because there are no concrete branch instances to expire.

TTL controls:

  • concrete relay-branch presence on the owner
  • materialized-state cleanup when the relay is materialized
  • downstream processor state cleanup for the same concrete branch

Expiration semantics:

  • paced domains use domain logical time
  • unpaced domains use wall clock time
  • a branch’s idle time is measured from the time at which its last input was accepted. A relay owner or processor that spends a long time waiting for input does not spend that wait out of the branch’s TTL, so every branch receives its complete declared lifetime from its own last record
  • every relay owner and processor using the branch applies that branch’s TTL to its own branch-local runtime state
  • relay TTL and MAX INSTANCES ... EVICT LRU are enforced once by the relay owner across all producers in the cluster

TTL is the expiration boundary; releasing the expired branch is a separate maintenance scan that runs on the physical clock. A branch is never released before its TTL has elapsed in the domain’s clock, and it is released at the first scan after that boundary. The scan interval is therefore release latency added on top of the TTL, never early expiry, and accelerating TIME RATE shortens the TTL in real time without changing the scan interval.

Materialized State

Materialized relay state is enabled with:

CREATE IF NOT EXISTS RELAY notifications
  SCHEMA notification
  UNBRANCHED
  WITH MATERIALIZED STATE LAST BY TIMESTAMP;

Current semantics:

  • materialized state is keyed by the branch key
  • a branch grouped by nothing has one root entry
  • Nervix keeps the latest full record per branch group according to record metadata watermarks
  • materialized state is persisted to Fjall
  • persisted snapshots are replicated to scheduler-selected state replicas
  • when a concrete branch-local relay expires, Nervix deletes the matching materialized entry and replicates that deletion

Because watermark and timestamp metadata travel alongside rows inside relay batches, batching does not change LAST BY TIMESTAMP semantics. Materialized state still compares records using the preserved runtime metadata for each row.

Operational notes:

  • STOP preserves persisted materialized state
  • START clears materialized state for the active domain before new execution proceeds
  • after a crash, Nervix restores persisted materialized entries from Fjall
  • a backup captures fresh materialized rows, exact schemas, typed branch keys and watermarks; RESTORE ... RESUME installs them on owners and replicas before generators start, retaining the archived domain start generation. A default stopped restore followed by START clears them. See Backup And Restore.
  • per-group TTL metadata is not yet persisted, so crash recovery does not currently perform a startup sweep of stale materialized entries

Materialized state is also the readable snapshot surface for GENERATOR nodes. A generator declares exactly one materialized relay with USING MATERIALIZED STATE <relay> and reads it through relay_state.<relay>.<field>.

SHOW RELAY <relay> MATERIALIZED STATE reports kind: RELAY, the relay owner, and its scheduler-selected state replicas before the materialized entries or empty-state message. Each entry keeps its typed branch key through collection and renders it at this reporting boundary; unbranched entries render as key=(root). An entry’s payload is written as JSON, which has no number for a NaN or an infinity, so a float field holding one is written as the string NaN, Infinity or -Infinity. A record holds one when a payload’s number rounds past its F32 field’s range, or when an Avro payload or a producer’s Arrow batch carries one.

DESCRIBE RELAY <relay> reports the owner and state replicas immediately after kind: RELAY, then the logical definition and owner buffer-utilization metrics. An ordinary relay reports replicas: -. Traffic metrics remain on the producing or consuming runtime-node edge.

DESCRIBE RELAY <relay> WHERE (...) is answered only by the current relay owner and reports owner-authoritative concrete-branch existence and buffer metrics. A branch exists from the moment the owner accepts a batch for it until the owner evicts or expires it. Relay presence and metrics are not replicated. A planned owner move drains admitted work before cutover; an owner failure loses buffered batches, presence, and relay metrics. A new owner, whether on another node or after the relay’s execution is rebuilt on the same one, reports a branch only after it accepts a batch for that branch itself. Materialized records survive when a current state replica can become owner. Prometheus exports aggregate relay metrics without branch-key labels; see Metrics And Observability.

Other Replicated Runtime State

Materialized state is only one example of replicated runtime state. Others include:

  • Kafka offsets for OFFSET BY DOMAIN
  • deduplicator state
  • graph metric summaries

Kafka partition assignment for OFFSET BY DOMAIN is not runtime-local replicated state. The leader observes Kafka partition topology, computes instance assignment, and commits that assignment into the Raft-backed domain schedule. That committed schedule is then persisted through the control-plane storage path and applied by runtime nodes.

This is still recovery-oriented state, not transactional exactly-once storage.

WASM State And Recovery

A WASM processor runs one guest instance per concrete branch, and each guest keeps computation state that Nervix makes durable. The host checkpoints that state at the end of every guest callback, confirms it with the processor’s replicas, restores it into every instance it recreates, and replaces it when a command, the guest itself, or the processor’s rejected-state policy asks for a new lifetime. This chapter defines that contract end to end: who owns each piece of state, what is durable and what is not, how a checkpoint becomes the state a recreated instance restores, which identity fences out every earlier lifetime, how a lifetime is replaced, and what each kind of failure leaves behind.

The central rule is that guest state and message delivery are separate guarantees. A checkpoint makes the guest’s computation state durable before the source acknowledgements it covers are released. It does not make the guest, its output, and external sinks one transaction, so a path through a WASM processor is at least once.

Execution Plans explains how the committed schedule carries a pinned module and state-generation identity into node-local WASM preparation and recovery.

This chapter describes the architecture. WASM Processor Guests owns the guest ABI and the user-facing account of checkpoints, resets, and failure diagnostics, the Rust WASM Guest SDK owns the Rust guest surface, and Runtime Nodes owns the NSPL statements.

Ownership By Layer

LayerOwnerWhat it owns
VocabularyWASM state modelsThe guest-state generation of every branch, a reset’s scope, phase, and reason, the recovery attempts refused lifetimes have spent, and the typed state inspection. The processor’s entry in the committed domain schedule carries all of them.
LanguageNSPLON REJECTED STATE, RESET WASM PROCESSOR ... STATE, and DESCRIBE WASM PROCESSOR, lowered into Models.
Engines and infrastructureThe WASM hostCompiling a pinned module, instantiating a branch store, the fuel and memory limits of every guest operation, the domain snapshot each operation reads, and answering a guest’s reset request.
Engines and infrastructureThe guest SDKThe guest side of the ABI: the snapshot envelope, strict restore verdicts, and keeping its own execution state out of every save.
Engines and infrastructureThe runtime state storeWriting a checkpoint under its placement, and the durability barrier that synchronizes the node’s storage for every writer waiting on it.
Engines and infrastructureThe interconnectReplica synchronization and acknowledgements, and the coordination requests a reset and a recovery send.
DecisionsRegistry and schedulingPlanning a reset as a transaction step, deciding which schedule publication starts a new generation, and binding the pinned module and generation identity to that scheduled processor revision.
Data planeThe branch taskRunning guest callbacks one at a time, holding back the acknowledgements each callback decides, and checkpointing the instance the callback leaves behind.
Data planeThe processor supervisorFencing the scope of an unfinished reset, and preparing, aborting, and committing a reset on the owner.
Control planeThe reset coordinator on the leaderValidating a reset, gating its scope, preparing it, and publishing it through its Publishing and Ready phases.
Control planeThe recovery coordinator on the leaderDeciding whether a refused lifetime still has its one recovery attempt, recording the decision, and driving the reset it admits.
EdgesThe session service and clientsAdmitting an NSPL reset as a transaction step, and returning the typed state inspection beside the text of DESCRIBE WASM PROCESSOR.

During normal execution, guest bytes stay in the data plane and state store. A backup is an explicit control-plane export of durable checkpoints to an archive; restore installs those bytes under the generations of the newly published schedule. The data plane executes the lifetime the committed schedule names and never decides one.

Registered representation properties compare complete guest snapshots, SDK restore values, archive state descriptors and stored checkpoint headers. The oracle includes every raw guest byte, typed name, schema, branch field, generation and revision; segmented headers retain their full length and digest. These serial conversions establish value preservation. Callback completion, replica durability and publication ordering retain the scenario and concurrency evidence of their production owners. The WASM representation register records each conversion, its exact oracle and the limits of its claim.

Backup And Restore

A normal backup holds a quiesced cut for each running domain. Once intake and acknowledged work drain, each owner applies the selected cut revision and its runtime plan, then requests a fresh branch lifecycle checkpoint from its active supervisors before reading durable WASM saves together with its other state from one database snapshot. The archive stores a typed descriptor for each saved branch: processor, schema fingerprint, typed branch key and its fingerprint, generation, and checkpoint revision. Raw guest bytes occupy a separate section with a measured length and digest. WITHOUT PAUSE exports the latest published checkpoints with crash-consistent semantics; WITHOUT STATE omits them. Capture filters the current scheduled identity and active lifecycle before reconstructing branch keys. Retained checkpoints for TTL or LRU evicted branches are omitted; they remain available for local branch reappearance without preventing a backup of the current lifecycle. The owner checks the committed generation before opening each selected save from the cut’s database view. It reads and stages one save at a time under an admitted restore_metadata charge for the inline checkpoint and three fixed 64 KiB bulk buffers, so an accepted guest save can exceed the node’s bulk budget. The raw section and its descriptor take their bytes and revision from the same read; a superseded generation is not opened. A failed or interrupted write leaves no published archive, and retry takes a new cut.

A restore creates the target domain stopped and publishes its models and schedule before installing state. It stages a complete replacement state set on each target node. It accepts a guest save only when its entity and schema fingerprint match the restored schedule, maps the saved branch to the generation that schedule names, and stages the checkpoint on the assigned owner and replicas. Branch lifecycle is staged first. Each node streams saves into bounded chunks in an invisible installation namespace, verifies the complete inventory, synchronizes the namespace and durably selects it with one atomic pointer. Lifecycle, offsets and saves become visible together. Passive handles are cleared only after pointer durability and bounded obsolete-namespace cleanup, under the same installation authority. The installation and publication jobs reserve 2 MiB independently of total guest bytes and checkpoint count; loading a saved guest at START still materializes its bytes under the WASM host’s own limits. Snapshot readers retain one complete selected generation, and queued checkpoint writers cannot cross an installation namespace. The every-node checkpoint maintenance owner also reclaims restored chunks after ordinary, replica, handoff or recovery checkpoints replace their selected header, or after state purge. It retains the exact revision a selected segmented header names, and preexisting snapshot readers keep complete saves across deletion. Backup And Restore owns the sweep’s bounded buffers, accounting, cancellation and separate database allocation limits. The replicated start gate remains closed until every node completes publication, even if the restore fails or its mutation lease is released. Authority binds leader tenure, execution, lease and installation generation; stale local and remote requests cannot republish or clear handles. A checkpoint from a different schema or an entity absent from the schedule is skipped with a diagnostic. The source domain name and owner node are not carried into the restored placement. The guest still validates its saved bytes when the restored domain later starts. The Rust SDK allows the domain name to change when the branch key, domain type, and input and output schemas match; a guest with its own snapshot format may apply stricter identity rules and reject the save.

Branch Ownership

The runtime creates one guest instance per concrete branch. Each instance has its own Wasmtime store, linear memory, guest state, and timeout handles; a node shares only the compiled module between them. See Module Sharing And Branch Memory.

An unbranched processor has exactly one execution, the explicit unbranched instance. It is never a branch with an empty key: its BranchInit payload carries no branch_key, its state placement names no branch, and a reset selects it with the explicit UNBRANCHED scope. A branched processor never has an unbranched instance.

The committed schedule gives each processor one owner and zero or more replicas. The owner runs every branch’s instance and is the only node that checkpoints guest state. A replica holds durable copies of the owner’s checkpoints. An ownership move for an active domain runs a guest at the destination to prove it can restore a checkpoint. A passive revision for a stopped domain carries the complete validated checkpoint inventory and preserves its guest saves without executing guest callbacks or reading domain time. The guest validates the save when START installs the running revision. A stopped clock is not a missing-checkpoint outcome and cannot justify a state reset. The control plane, the inspection, and the diagnostics and logs of a branch’s guest name a concrete branch by its fingerprint, a fixed-size digest of the canonical branch-key text, never by its field values.

A concrete branch that is evicted for its TTL or its instance limit stops without a quiesce flush, and its live instance and the work it buffered are dropped with it, as for every evicted processor branch. Its guest-state checkpoint stays. Guest state is addressed by branch key and generation, not by the incarnation of the branch, so the same key appearing again restores the last checkpoint that branch committed. Window state differs: an evicted window branch publishes an empty final window.

What Is Durable

A guest saves only the computation state a recreated instance needs to continue: counters, aggregates, open windows, or whatever else it derives from the input it has accepted. Everything an instance uses to execute belongs to that instance.

StateWhere it livesSurvives
Guest computation state, the bytes nervix_dump_state returnsThe state store of the owner and of every replica that confirmed itA restart, and an owner loss when a replica holds it
Generations, the latest reset, and the recovery attempts refused lifetimes spentThe committed domain scheduleEverything consensus survives
Checkpoint revisionsStored with each checkpoint; the owner’s next revision is derived from the one it restoredWith the checkpoint
Input the guest buffers, with its ACK tokens, row sidecars, and input-column referencesThe instanceNothing
Output groups the guest has not emitted, and output already dispatched to relaysThe instance and the relaysNothing
Timeout handlesThe instance’s host storeNothing
A guest’s latched error stateThe instanceNothing
Acknowledgements held back for a checkpointThe branch taskNothing
Guest reset requests not yet coordinated, and raised rejected-state recoveriesThe owner’s memoryNothing
Checkpoint progress observationsThe owner’s memoryNothing
Compiled modulesEach assigned node’s memoryNothing; recompiled from the pinned version

A recreated instance therefore starts without buffered input, pending output, pending timeouts, or error state. The host keeps an instance whose callback failed unless the failure exhausted MAX FUEL or MAX MEMORY, so a Rust SDK guest that latched into error state stays latched, and keeps saving its computation state, until its instance is recreated. Acknowledgement trees and tokens are in-memory hot-path state everywhere in Nervix; see Acknowledgement state.

Guest Lifecycle

The host creates an instance when a branch has none and work arrives for it: the branch’s first input, the first input after a callback exhausted a limit or a checkpoint failed, a node restart, an ownership move, and a replaced lifetime. Creating one runs these operations in order:

  1. The host instantiates the module into a new branch store.
  2. It calls nervix_init with the branch configuration.
  3. When the branch’s committed checkpoint holds bytes, it calls nervix_load_state with them. A branch in a new lifetime has no checkpoint yet, so the instance starts from its initialized state.

The instance then runs callbacks: nervix_process_batch for input, nervix_on_timeout for each due timeout, and nervix_flush whenever the host force-flushes the branch, which every entity or domain pause, ownership handoff, reset preparation, and shutdown drain does. After a callback the host drains nervix_read_emit and saves the instance with nervix_dump_state. A guest asks for a new lifetime with the nervix_request_state_reset import, which the host accepts only while one of those three callbacks is running. The ABI also requires a nervix_reset_state export. The host resolves it when it instantiates a branch and never calls it: every new lifetime starts in a new instance.

Every guest operation receives its own MAX FUEL budget and one explicit domain execution snapshot. Instantiation, initialization, restore, each callback, and each save are separate operations; the emit reads after a callback share that callback’s budget. See Execution Limits and Execution-Time Snapshots.

The FlatBuffers protocol decoder, Rust guest SDK callbacks, and Wasmtime host return typed error-stack reports within their Rust layers. The guest SDK preserves protocol and application causes through snapshot decoding and restore, then renders a reason at the ABI boundary. The host keeps the typed call cause beneath the failed guest operation, including its export and execution limit classification. Rendering does not change the ABI return codes, the global-error latch, or the rejection verdicts; the same restore reason reaches the host, and a failed callback still reaches the checkpoint and ACK decisions described below.

A zero-length save means the guest has no state: the next instance is initialized without a nervix_load_state call. The Rust SDK wraps every save in a GuestSnapshot envelope that also carries the branch configuration, so empty application state is still restored as state, and a snapshot taken under another branch configuration is rejected. For a restore into a differently named domain, the SDK accepts that name change while still checking the domain type, concrete branch key, and input and output schemas. The saved application bytes are passed through unchanged.

A restore either succeeds, or the guest rejects the saved state with one of the two reserved verdict codes, or it fails without a verdict: a trap, an exhausted limit, or another negative code. Only a verdict classifies the saved bytes as unusable. Each outcome is reported at its own failure stage, the inputs that were waiting for the instance go through ON GLOBAL ERROR, and the saved state stays in place, so the next input hands the same revision to a new instance. Only the processor’s rejected-state policy acts on a verdict, and only a forced recovery can discard state after a failed restore.

Placements, Generations, And Revisions

Placements

Every checkpoint is stored, served, replicated, transferred, and restored under a placement: the domain, the processor, the concrete branch or the unbranched instance, the fingerprint of the schemas the processor’s records are laid out by, and the branch’s guest-state generation. A node places guest state only under the identity derived from the committed schedule in its applied typed execution revision, and acts on a placement only while that placement is current. The same revision carries the processor’s reset and pinned module plans through a rebuild or owner change. A replica acknowledgement counts only toward the placement it names. Installed replication routes retain the same entity assignment publication as guest checkpoint execution. Reset lifecycle confirmation retains its route, follows replica reassignment, and refuses replacement or loss of primary ownership before declaring confirmation; neither that wait nor its announcer reads execution maps. See Checkpoint Identity and Membership, Consensus, And Bulk Transfer.

Generations

A generation is one lifetime of a branch’s guest state. The processor’s schedule entry holds a default generation for every branch and a generation of its own for each concrete branch reset separately since the default last advanced. A new generation number is always the successor of the highest one the processor has handed out, so a branch’s generation only moves forward.

EventGeneration effect
The processor is createdEvery branch starts in generation 1
A reset of one concrete branchThat branch moves to the next generation; every other branch keeps its own
A reset of every branch, or of the unbranched instanceThe default moves to the next generation and every per-branch generation is cleared, which also covers branches that exist only as stored checkpoints
A forced recovery after owner lossEvery branch moves to the next generation, in the publication that names the new owner
A binding change: another resource, version, or module fileEvery branch moves to the next generation, in the publication that commits the change
A change of the schemas the processor’s records are laid out byNo change; the state moves to a placement under the new fingerprint, which starts without a checkpoint
A planned ownership handoff, a limits or policy change, a no-op rebindingNo change

Only a committed schedule publishes a generation, so every transition is serialized with every other mutation of the domain through the same alteration lease or automatic-decision fence as the schedule itself. Reapplying a committed schedule, after a restart or a domain rebuild, publishes nothing new.

Revisions

The owner stamps each capture with the next revision of its placement. A revision is never reused, including the revision of a checkpoint that failed, because this node’s storage or a replica may still hold it. An owner that restores a placement continues from the revision it restored.

Each branch keeps two checkpoints on its owner. The committed checkpoint is the last one that reached its boundary; a live instance that the owner recreates restores it. The published checkpoint is the newest one on the owner’s stable storage; replicas fetch it, and a restarted owner reopens it. The two differ only after a checkpoint that reached the owner’s storage failed at its replicas.

The Checkpoint

A branch runs one callback at a time, and every callback that leaves an instance behind ends with a checkpoint of that instance:

  • an input batch, whether the callback succeeded or failed;
  • each due timeout callback, one checkpoint per timeout;
  • a quiesce flush that emitted output;
  • the checkpoint an ownership handoff takes of an idle instance.

No checkpoint follows a flush that emitted nothing, because it decided no input. No checkpoint follows a callback whose instance exhausted a limit: the instance is discarded, the committed checkpoint already holds the state the next instance restores, and the callback’s decisions are released at once. No checkpoint follows a callback that asked for a new lifetime, because the state it would save is the state the reset discards.

Stages

  1. Boundary. Before the guest saves, the owner confirms that the placement is current and that the committed schedule still has this node execute the processor. The replicas the schedule assigns at this moment are the checkpoint’s boundary; with none, which is always the case with a replica count of 0, the boundary is the owner’s storage alone. A branch whose generation or ownership has moved on is refused here, before anything is saved, persisted, or replicated.
  2. Captured. The guest saves with nervix_dump_state, and the bytes are stamped with the next revision. The owner shares the buffer with persistence and replication rather than copying it.
  3. Locally durable. The state store writes the checkpoint on its storage workers, never on the worker running the guest, and returns once a synchronization of the node’s storage covers the write. The owner then announces the revision to its replicas.
  4. Replica confirmed. Every replica in the boundary has the revision on its own stable storage.
  5. Committed. The checkpoint becomes the committed checkpoint, and the acknowledgements the callback held back are released. Only then does the branch run its next callback.

The whole checkpoint, from the guest’s save to the last replica’s confirmation, has ninety seconds. While it is under way, the branch reports every input the callback holds back alive to its source every 100 milliseconds, as a remote receiver reports an acknowledgement it holds. A checkpoint is progress of those inputs: a source whose ACK TIMEOUT is shorter than the checkpoint does not time them out and send them again while the checkpoint can still commit them, whether the source runs on the owner’s node or on another. Input the guest keeps undecided past its callback is not held for a checkpoint and is not reported alive. The replica first receives a small revision, length, and digest description, then fetches that revision through the bulk pool in 64 KiB chunks. It verifies the complete byte count and digest before writing the checkpoint to its own storage. A save up to the host’s 64 MiB guest buffer can therefore cross the 2 MiB replication-message limit. The bulk fetch has a sixty-second progress deadline inside the checkpoint’s ninety-second completion budget.

Local Durability

The state store’s durability barrier issues a ticket to every writer after its write is applied. At most one writer runs a synchronization at a time; it covers every ticket issued before it started, and the writers still waiting elect the next runner when it did not cover them. Branches that checkpoint at the same time therefore share one synchronization instead of queuing one each. No lock is held across the wait; see Bounded Synchronization Outside The Open Path.

A failed synchronization is not retried. The operating system may have dropped the writes it failed to flush, and the database refuses every later synchronization, so from then on no write on that node is reported durable and every later checkpoint there fails until the node restarts.

Replica Confirmation

A replica acts on an announcement only while the placement is current on it and the announcing node is the owner its schedule names. The announcement wakes the replica task that keeps the processor’s branches current, which fetches the checkpoint in its next round. While idle, the task also starts a round after the one-second replication poll interval, so a lost announcement is recovered by periodic synchronization. Lifecycle, catalog and checkpoint requests each retain their typed five-second response deadline, including time waiting for transport capacity; the poll interval does not cancel a request still within that deadline. A busy or failing replica can take longer than one interval to catch up. The subsequent bulk fetch has a sixty-second deadline, and the owner’s whole checkpoint has ninety seconds to complete. The replica installs a revision newer than the one it holds, synchronizes its storage, and only then acknowledges; a replica that already holds the announced revision or a newer one synchronizes and acknowledges what it holds, so a lost acknowledgement is replaced by the next announcement. Each round synchronizes the owner’s branch lifecycle before it installs a branch checkpoint, and the replica refuses a checkpoint only when that lifecycle does not name the branch, as for an evicted branch. A node without stable storage acknowledges nothing.

While the checkpoint waits, it wakes on each replica report and rereads the schedule at least every 100 milliseconds. A replica the schedule replaces is replaced in the wait. The wait fails when the schedule assigns fewer replicas than the checkpoint was captured for, when this node no longer executes the processor, and when the deadline passes with a replica still missing.

Acknowledgement Holds

A callback’s output is validated as a whole and dispatched before its checkpoint; only the acknowledgements wait. For every input the callback decided successfully — carried into an output row, listed as acked, routed through ON MESSAGE ERROR, or accepted by ON GLOBAL ERROR IGNORE after a failed callback — the branch task attaches one more share to the input’s acknowledgement and owns it for the length of the checkpoint. The input succeeds once every delivery the callback made for it succeeded and that share is released. nacked inputs are negatively acknowledged at once. See Checkpoints And Acknowledgements.

The holds gate the source only on an ATTACHED processor, which is the default. A DETACHED processor receives its input after relay fan-out has already acknowledged it upstream, so no source acknowledgement waits for its checkpoints, although its guest state is checkpointed the same way.

When A Checkpoint Fails

StageWhat failed
state authority checkThe placement is no longer current on this node, or the schedule no longer has this node execute the processor.
state snapshotnervix_dump_state returned a negative code, trapped, exhausted a limit, or exceeded the host’s 64 MiB guest buffer.
local state persistenceThe write or its synchronization failed, or did not finish before the deadline.
state replicationA replica did not confirm before the deadline, or the schedule assigns fewer replicas than the checkpoint was captured for.

Whatever the processor’s ON GLOBAL ERROR policy, the failure is reported as a runtime error at its stage. Every input the callback decided, and every input the guest still buffers, is negatively acknowledged. The committed checkpoint stays where it was. The instance is discarded with its buffered input, pending output, and timeouts, and the next input for the branch recreates the guest from the committed checkpoint.

Failure At Each Boundary

A checkpoint’s stages are durability boundaries, and schedule publications are authority boundaries. Where an owner stops relative to them decides which state survives.

The owner stoppedIts storage holdsA replica holdsA restart of that node continues fromAfter owner loss, the branch continues from
After dispatching output, before the saveThe previous checkpointThe previous checkpointThe previous checkpointThe previous checkpoint
After capture, before local durabilityThe previous checkpointThe previous checkpointThe previous checkpointThe previous checkpoint
After local durability, before every replica confirmedThe new checkpointThe previous or the new oneThe new checkpointWhichever the promoted node selects as newest
After every replica confirmed, before releaseThe new checkpointThe new checkpointThe new checkpointThe new checkpoint
After the acknowledgements were releasedThe new checkpointThe new checkpointThe new checkpointThe new checkpoint

In every row but the last, the callback’s output may already have left the node and its inputs were not acknowledged, so a source with acknowledgements redelivers them. Without replicas, only the owner’s storage holds a branch’s checkpoints, so recovering without that node resets the branch. Replay, Duplicates, And Exactly-Once describes what a redelivered input does.

Authority boundaryFailure before itFailure after it
A reset’s Publishing schedule is appliedThe previous lifetime stays authoritative; the owner restores the branch tasks it stoppedThe new generation is authoritative and cannot be rolled back; the scope stays fenced until the same request reaches Ready
A reset’s Ready schedule is appliedThe reset is committed but not usable, and input of the scope is refusedThe new lifetime is usable and the scope reopens
A recovery attempt is recordedNothing is spent; the next refusal raises the lifetime againThe attempt is spent whatever the reset achieves
A forced recovery schedule is publishedEvery checkpoint stays where it was, and the next recovery prepares againThe new owner continues in the new generation from the checkpoints it staged, or without state when its preparation failed
A binding change is publishedThe previous binding and its checkpoints stay currentEvery branch starts the new binding without guest state

Ownership Fencing

Recovered And Moved Owners

A restarted node executes nothing until a linearizable read proves it has applied the committed schedule, so a former owner cannot resume a branch the cluster moved while it was away. See Recovered Ownership Is Fenced. During execution, the boundary check at the start of every checkpoint, and the schedule rereads while it waits for replicas, refuse a checkpoint whose placement or ownership moved on while its callback ran.

A planned ownership handoff keeps the generation. Intake and boundary admission close first. Affected branches remain runnable until their admitted work and requested force flush complete; only then does the participant freeze their state for capture. The capture reads the completed checkpoints without asking a frozen guest to flush. The source sends the destination a small revision, length and digest description for each branch. The destination fetches each exact checkpoint that capture retained in the source’s stable storage through the bulk stream and verifies it before restoring the guest. A fetch or restore failure ends the handoff before the schedule changes. The replacement instances then restore the transferred checkpoints. See Planned Ownership Handoffs And Failover.

Forced Recovery

When an owner is lost, the leader publishes the new owner together with a new generation for every branch. The destination prepares the recovery within five seconds: for each branch it collects the checkpoints of the generation being replaced from its own storage and from every other node the new schedule assigns the processor, and stages the newest valid one. Two different checkpoints at the same highest revision are a conflict, and the branch is reset. A branch with no surviving checkpoint is reset. The destination restores every staged checkpoint into a guest before the schedule is published, a promoted replica from the module it compiled while it was a replica, and activation publishes the staged checkpoints in the new generation. SHOW CLUSTER STATUS reports the transition with state_recovery=reset and resets=wasm_processor:<cause> when it reset branches.

A preparation that fails for any reason — a guest that refuses or fails to restore a staged checkpoint, a module the destination cannot compile, or the five seconds running out — makes the leader publish the recovery with recreated state: every state component of the processor is reset with the cause missing_checkpoint. ON REJECTED STATE does not govern this path. When the processor has replicas, a replica holds every checkpoint whose acknowledgements the lost owner released, so a recovery that prepares successfully continues each branch from at least the state its acknowledged inputs produced. See When The Former Owner Is Gone.

A leader that has just started reconciling, as the first leader after a whole-cluster restart is, makes no automatic scheduling decision for ten seconds while any voter is neither reported live nor declared dead, so an owner that returns within that grace keeps its work and restores its own checkpoints instead of being failed over. See Whole-Cluster Restart Keeps Ownership.

Stale-Node Catch-Up

A node that was away installs the committed schedule before it accepts any runtime state, and then synchronizes only the placements that schedule names. Applying the committed schedule of a running domain removes every stored checkpoint whose placement is no longer current: state of a replaced generation, state laid out by a replaced schema fingerprint, and state of a node the schedule dropped. A reset or a forced recovery therefore never sweeps the cluster to delete old bytes: addressing by generation makes them unreachable at once, and each node removes them locally when it applies the schedule.

Why State Does Not Resurrect

A lifetime that a reset, a forced recovery, or a binding change replaced cannot become current again:

  • a generation only moves forward, and only a committed schedule publishes one;
  • every checkpoint is written, served, installed, transferred, staged, and restored under the generation the committed schedule names for its branch, so a snapshot of an earlier generation is never current, whatever revision it carries;
  • a replica acknowledgement counts only toward the placement it names, so an acknowledgement for a replaced generation never satisfies the current one;
  • a handoff or forced-recovery preparation is bound to the fingerprint of its complete target schedule, which covers every generation, so a preparation staged before a transition cannot activate after it;
  • a restarted former owner is inert until it has applied the committed schedule, and a checkpoint of a branch whose generation moved on is refused before it is saved.

These fences compare generations and schema fingerprints, and they order checkpoints within one placement by revision. A schema change keeps the generation and moves the state to a placement with the new fingerprint, and each node removes the replaced placement’s checkpoints when it applies the committed schedule of the running domain. A node that did not apply that schedule while the domain ran, because it was down or the domain was stopped, keeps them. When the processor’s schemas later return to the earlier fingerprint, that placement is current again, and so is every checkpoint kept under it: an owner that kept one restores it, a replica that kept one with a higher revision than the owner’s acknowledges each new checkpoint with the revision it already holds, and a forced recovery selects the kept checkpoint as the newest.

Quiescence

A WASM processor participates in quiescing like every other stateful node. An ENTITY_PAUSE model change, RELOCATE, a drain, and a planned handoff gate the processor’s input relays, force-flush its branches, which calls nervix_flush, checkpoint them, and wait for the acknowledgements those checkpoints hold. A guest that keeps input past nervix_flush leaves it unacknowledged until the branch resumes. See ALTER Lock And Quiesce Classification and Quiesce Flush.

A reset gates more narrowly: its branch-selective relay gate holds back only dispatches to the selected scope, and sibling branches keep running. See Dispatch-gate engagement.

Coordinated Reset

A coordinated reset replaces the lifetime of one scope of one WASM processor: the explicit unbranched instance, one concrete branch, or every concrete branch. One operation on the leader serves every trigger.

TriggerReason reportedRequest referenceEntry
RESET WASM PROCESSOR ... STATETRANSACTIONThe statement’s command execution referenceAn ordered transaction step
A guest requestGUESTwasm-guest-reset. followed by a digest of the domain, processor, branch, and generationThe owner hands the request to the leader
Rejected-state recoveryREJECTED_SNAPSHOTwasm-recovery.<generation>. followed by the branch fingerprint or unbranchedThe owner raises the refusal to the leader

OPERATOR names a reset requested of the coordinator directly rather than through a transaction; no public interface issues one.

Admission

The leader admits a reset only when the domain exists and is running, the processor is scheduled with an owner, and the scope matches the processor’s declaration: UNBRANCHED only for an unbranched processor, and a concrete branch or every branch only for a branched one. A concrete branch is named by exactly the declared branch-key fields with exact typed values, and it must be a branch the owner is running. The reset takes the domain’s exclusive alteration lock, so a concurrent reset, lifecycle, model, placement, handoff, or rebinding operation is rejected rather than queued. While a reset of the processor is Publishing, only the same request can proceed, and reusing a request reference with a different scope or reason is rejected.

Phases

  1. Gate. The leader engages the reset’s branch-selective gate on every live node for each input relay of the processor, bound to its coordination identity and scope, and waits for dispatches already admitted to that scope.
  2. Prepare. The owner stops each selected branch at its callback boundary through the processor’s command lane. Accepted input drains first, and completed callbacks with their buffered output are finalized once through the ordinary output, checkpoint, and acknowledgement paths. Work suspended on a materialized dependency is discarded and negatively acknowledged, and the old timeout handles are cancelled. The owner then creates a fresh instance for every selected branch, initializes it without nervix_load_state, and keeps its first save in memory. A failure here restores the stopped branch tasks and releases the gate; nothing durable has changed.
  3. Publishing. The leader commits the schedule that advances the selected generation and records the reset with its request, scope, reason, and the Publishing phase. Once this schedule is applied the old checkpoints are unreachable and the reset cannot roll back. Live replicas install the new placement before the owner, in an activation ordered by the coordinator rather than the ordinary cluster runtime-revision barrier, because an owner whose initial checkpoint fails is the node that would keep that barrier incomplete.
  4. Initial checkpoint. The owner installs the fresh branch tasks, republishes the branch lifecycle that names them and waits for its replicas to hold it, writes each fresh instance’s first save as the initial checkpoint of the new generation, and waits for every assigned replica.
  5. Ready. The leader commits the same reset as Ready, every live node applies it while the scope is still gated, and the gate is released.

From Publishing until Ready, the owner negatively acknowledges every input of the selected scope, so a source with acknowledgements redelivers it into the new lifetime; sibling branches run. A restart, a leader change, or an owner recovery reads the published phase and keeps the scope fenced. A reset does not touch the domain clock: it changes no lifecycle generation, mapping, authority, or frontier, and no deadline armed by the replaced instance fires in the new lifetime. See Execution-Time Snapshots.

Failure And Retry

A failure before Publishing reports an ordinary reset failure, and the previous lifetime stays usable. A failure after it reports that the reset was committed but its new lifetime is not usable. Retrying the same request reference resumes the missing initial checkpoints and Ready publication without advancing the generation again. An NSPL reset completes only at Ready; its transaction step is recovered with its original execution reference after a leader change, a lost reply returns the retained outcome, and an expired reference cannot start another reset. See Coordinated WASM Reset Publications, Command Completion, and Coordinated WASM Guest-State Reset.

Guest-Requested Reset

The host answers nervix_request_state_reset with 0 while nervix_process_batch, nervix_on_timeout, or nervix_flush runs, and with -9 during every other operation, which schedules nothing. An accepted request is terminal for the callback that made it, whether the callback then succeeds or fails:

  • output the callback emitted is discarded;
  • every input the branch holds, including input the guest still buffers, is negatively acknowledged;
  • no checkpoint is taken;
  • the instance is dropped with its pending timeouts;
  • effects earlier callbacks published stand, because their checkpoints completed.

The branch then fences itself: it refuses, and negatively acknowledges, every input until the reset stops its task, so it can never instantiate the guest again from the lifetime being replaced. The owner holds at most one request per branch in memory and hands it to the leader, and drops it instead when the committed schedule shows the branch has already left the generation it was made from. The request reference is derived from the domain, processor, branch, and generation, so every request the guests of one lifetime make is the same reset, and the first request from the next lifetime is a new one.

A reset that fails is reported as a runtime error naming the processor and domain. If it failed before the owner stopped the branch, the branch stays fenced, and each input it refuses states the request again. If the owner had stopped the branch, it restored it, and the guest continues in the lifetime that could not be replaced and asks again from its next callback. A node that stops before a request is coordinated loses it; the instance recreated from the same committed checkpoint asks again. Acceptance is never proof of a new lifetime: a guest learns that only by being initialized without a restore. See Guest-requested reset.

Rejected-State Recovery

A guest’s verdict on the snapshot it is handed is -7, when it cannot decode the snapshot envelope, or -8, when it refuses the application state inside it. ON REJECTED STATE PRESERVE, the default, keeps the refused snapshot: every later instance of the branch is handed the same revision and reports the same failure until a reset or a binding change replaces the lifetime. ON REJECTED STATE RESET replaces the refused lifetime once. No other failure reaches the policy, so compilation, initialization, fuel, memory, trap, storage, replication, and authority failures never discard state through it.

The owner raises a refused lifetime once per placement and holds the raise until the leader answers; at most 32 distinct refused lifetimes wait on one node, and a refusal beyond that is raised when the branch is refused again. A node that is not the leader forwards the raise. The leader then:

  1. refuses the recovery when the processor’s policy is not RESET or the domain is not running, and does nothing when the branch has already left the refused generation;
  2. records in the committed schedule that this refused lifetime is spending its one attempt, before anything is reset, or finds the attempt already recorded;
  3. runs the coordinated reset under the reference derived from the scope and the refused generation;
  4. records what the attempt achieved.

A refusal or failure is reported as a runtime error naming the processor and domain.

Recorded outcomeMeaning
attemptedThe attempt is admitted and its reset has not reported yet. A resumed attempt drives the same reset.
recoveredThe reset published a fresh lifetime and the branch resumed on it. A later refusal of that lifetime is a new failure with an attempt of its own.
failedThe attempt ended without a usable lifetime. The budget is spent; the branch keeps reporting its refusal instead of resetting again on every record.

A reset that fails after Publishing leaves the scope fenced exactly like any other committed but unusable reset. The attempts are control-plane state, so a restart, a leader change, and an owner change read the same budget, and a binding change clears them because it starts every lifetime anew. Only an instance the owner creates for live work raises a refusal: a planned handoff whose destination is refused fails, and a refused forced-recovery preparation resets the processor as described in Forced Recovery. See Recovering A Rejected Snapshot.

Rebinding And Rollback

A generation belongs to the module binding it was published for: the resource, its pinned version, and the module file. A model change that binds another one starts a new generation for every branch in the publication that commits it, clears the processor’s latest reset and its recovery attempts, and the replacement instances initialize from the new module without guest state. No state is migrated between module versions. A change of the execution limits, the global error policy, or the rejected-state policy, and a rebinding that leaves the version unchanged, keep every generation.

The leader compiles the candidate module before the change commits, and activation prepares the same pinned module from the committed schedule’s typed resource plan. Running builds and entity swaps prepare it before replacing execution; a retained compiled module is reused. The prepared processor revision also includes the schedule’s guest-state generations, so a reset or rebinding cannot reuse a plan from the previous lifetime. A module that does not compile rejects the whole statement before any effect, while the previous binding and its checkpoints are still current. A rebinding is ENTITY_PAUSE for a running domain: the processor and its downstream pause and each branch is flushed. A rebinding in a stopped domain publishes the new generations as well, so the processor starts without guest state. A reset after a rebinding starts another lifetime on the rebound module, and DESCRIBE WASM PROCESSOR never presents a reset of the previous binding as a reset of the new one. See Classification And State Effects.

Shutdown And Restart

A shutdown drain waits for the acknowledgements a WASM branch holds, as it waits for any outstanding acknowledgement, and the checkpoint deadline bounds that wait. Runtime teardown gives each processor task two seconds to stop, and the processor task gives each of its branch tasks two seconds inside that. A branch task never outlives its processor task: one still in its checkpoint is ended with it, its unreleased acknowledgements are negatively acknowledged, and a restart finds whatever that checkpoint had already written. See Draining Admitted Work.

A restarted node reopens each branch at the newest checkpoint on its own storage under the identity the committed schedule publishes, which covers every input its acknowledgements released. A reset interrupted before Publishing leaves the previous generation authoritative; one interrupted after it resumes the new generation on restart. Guest reset requests and raised recoveries do not survive a restart; the next callback or refusal raises them again, and a spent recovery attempt stays spent. See What Survives for the kinds of ending and the state each keeps.

Replay, Duplicates, And Exactly-Once

Snapshot recovery guarantees one thing: once a source has received a successful acknowledgement for an input, the guest state that reflects the input is on the stable storage of the branch’s owner and of every replica the checkpoint was confirmed by. It is not an exactly-once guarantee for the guest or for external delivery:

  • output is dispatched before its checkpoint, so a sink can publish output whose input is later negatively acknowledged and redelivered, and the redelivered input produces it again;
  • the state a branch continues from can already reflect a redelivered input: a guest counts buffered input as it accepts it, a checkpoint that failed at its replicas stays on the owner’s storage, and a replica can hold a checkpoint whose acknowledgement arrived too late;
  • a source that keeps redelivering while no owner accepts the input can deliver it to the recovered branch more than once;
  • a source without acknowledgements redelivers nothing, and loses the input in every window before local durability.

A guest therefore applies a redelivered input again unless it recognizes the input itself, and exactly-once external effects need a sink whose writes are idempotent. See Recovery, Replay And Duplicates and ACK Semantics And Effective Delivery.

Observability

SignalWhat it shows
DESCRIBE WASM PROCESSOR <name>The pinned resource, resource version, and file, the rejected state policy, state default generation, the latest reset as state reset: <PUBLISHING or READY>, <scope>, generation <n> with its reason and readiness, one rejected state recovery line per recorded attempt, the checkpoint counts, and one checkpoint line per branch with its fingerprint, generation, committed and latest revision, stage, and required and confirmed replica counts
DESCRIBE WASM PROCESSOR <name> FORMAT JSONThe same typed state inspection, including each reset’s request reference; null for a processor that is not scheduled
Client outcomeCommandOutcome::wasm_state carries the typed inspection beside either text rendering
DESCRIBE TRANSACTIONThe planned and actual effect of a transaction’s RESET WASM PROCESSOR step
SHOW CLUSTER STATUSA forced recovery’s state_recovery outcome and the wasm_processor component it reset with the cause
Runtime errorsEvery failure in the diagnostic shape, a reset that could not replace a guest-requested lifetime, and a rejected-state recovery that failed or was refused; delivered to the sessions attached to any node
Server logAt info: WASM guest-state reset generation published and WASM guest-state reset became usable on the leader, wasm guest requested a new branch state lifetime and raised a refused WASM guest-state lifetime for recovery on the owner, and WASM rejected-state recovery admitted and WASM rejected-state recovery settled on the leader

The inspection is read-only. It samples the owner’s published checkpoint observations without taking a branch’s execution lane, requesting synchronization, or advancing a checkpoint, and it counts only checkpoints of the generation the committed schedule names. Checkpoint counts cover every branch the owner holds state for; branch and recovery details are limited to 128 entries each, with the rest reported as omitted. A stage is EMPTY while the branch has no checkpoint in its lifetime, CAPTURED before local storage, LOCALLY_DURABLE before its boundary is complete, REPLICA_CONFIRMED once it is, which without replicas reads 0 of 0, and FAILED when the latest checkpoint failed, with the previous committed revision still current. A checkpoint the owner restored rather than took reports LOCALLY_DURABLE with unknown replica counts, because its earlier boundary cannot be reconstructed. Reset readiness is RESETTING while the new lifetime’s checkpoint is unfinished, AWAITING_USABLE_EXECUTION when a selected single-scope checkpoint is confirmed but the reset is still Publishing, and READY once Ready is published; an all-branches reset stays RESETTING until then, because a sample of the current branches cannot prove every selected branch completed. Nervix exports no WASM-specific metric.

Checkpoint stage totals and omitted-entry counts use the vocabulary’s fixed-width 64-bit archive adapter, so an interconnect response preserves their complete native magnitude. Decoding refuses a count the receiving target cannot represent. See Archived Counts.

Guarantees And Limits

Nervix guarantees:

  1. No acknowledgement before durability. An ATTACHED WASM processor releases no successful acknowledgement until the checkpoint covering it is synchronized on the owner and on every replica its boundary requires.
  2. Failed checkpoints change nothing committed. A checkpoint that fails leaves the committed checkpoint in place, negatively acknowledges what it covers, and discards the instance.
  3. Branch isolation. Every branch has its own instance, checkpoints, holds, and lifetime; a reset of one branch leaves every sibling’s generation and task intact.
  4. One lifetime per request. Retrying, forwarding, or resuming a reset under its request reference never advances the generation twice, and a spent recovery attempt is never spent again.
  5. No resurrection by transition. A lifetime replaced by a reset, a forced recovery, or a binding change never becomes current again.
  6. Strict restoration. Only a guest’s own verdict can make ON REJECTED STATE RESET discard a lifetime.

Nervix does not provide, and these limits apply:

  • Exactly-once processing or delivery. See Replay, Duplicates, And Exactly-Once.
  • Guest state across module versions. A binding change always starts a new lifetime.
  • A checkpoint deadline of ninety seconds, from the save to the last replica, and one callback per branch at a time, whose acknowledgements are held for at most that long.
  • A save of at most 64 MiB, the host’s guest buffer limit.
  • Prepared handoff memory grows with its branch inventory. The destination retains every verified branch checkpoint until activation; the 256 MiB limit applies to each transfer, not the entity’s aggregate state.
  • Loss of state without replicas. Recovering a processor without its owner resets every branch whose only checkpoints were on that owner.
  • Forced recovery can reset state without a verdict. A recovery preparation that fails or runs past five seconds recreates the processor’s state.
  • A failed storage synchronization is permanent until the node restarts.
  • A whole-cluster restart keeps ownership only for owners that rejoin within the ten-second voter observation grace.
  • Evicted branches keep their state. A branch key that returns after eviction restores its last committed checkpoint.
  • A schema that returns to an earlier fingerprint can make a checkpoint kept by a node that missed the intermediate schedule current again; see Why State Does Not Resurrect.
  • A DETACHED processor gates no source acknowledgement on its checkpoints.
  • Guest requests and recovery raises are volatile, and are raised again by the next callback or refusal.

Qualification Evidence

The WASM state qualification pins each checkpoint window with a harness-only pause and ends the owner there, on one node and on three nodes with one replica, with interleaved records of two branches.

GuaranteeEvidence
Crash windows and replaywasm_state_qualification.feature: the four checkpoint windows on a single-node restart, owner loss at the windows it can tell apart, a cluster stopped while its owner holds a checkpoint, and a reset committed but not usable across a cluster restart
No acknowledgement before durabilitywasm_checkpoint_durability.feature, and the Shuttle checks of ReplicatedWasmProcessorState, WasmCheckpointHolds, and DurabilityBarrier under random, PCT, and DFS schedules
No replay while a checkpoint is under waywasm_checkpoint_durability.feature: a checkpoint held past its source’s ACK TIMEOUT on one node and on three nodes with one replica commits its input without the source sending it again, and the WasmCheckpointHolds Shuttle checks race its keepalive with the input’s delivery
No resurrection, no double resetwasm_state_reset.feature: failover, retry, a lost reply across a leader change, an expired identity, and rebinding
Recovery budgetwasm_state_recovery.feature: preserve, recover once, and a failed fresh initialization that spends the attempt across a restart
Restartwasm_processor.feature: guest state restored after a cluster restart and not resurrected by a returning former owner
Large backup and restorebackup_guest_saves.feature: two 40 MiB branch saves on one and three nodes, bounded bulk capture, interrupted capture cleanup, exact restored bytes and the next guest callback

The WASM state qualification ledger records the commands, the defects the qualification found and fixed, the validation record, and the measured cost of a durable checkpoint: 3 to 18 milliseconds per round for one branch, and 6 to 37 milliseconds for sixteen concurrent branches that share a synchronization.

Retained Checkpoint Assignment

A branch’s guest-state owner retains its entity assignment publication. Schedule installation publishes state identity and checkpoint executors/replicas together before a reset callback can checkpoint. Each callback and replica-boundary replan reads that retained slot. Replacement lifetimes and removed entities are rejected before guest capture; losing local execution ownership also rejects the checkpoint. The captured replica count and checkpoint deadline keep their existing meaning. Removal publishes absence before dropping registry interest, so a retained reader cannot resurrect an assignment.

Shutdown And Recovery

Shutdown is the ordered transition from a running node to a stopped process. It has one owner, three phases, and one deadline. The owner establishes that the node is terminating, stops new intake, keeps the services that admitted work depends on alive until that work finishes, and only then tears down the node’s own tasks, connections, and storage.

This chapter defines the externally observable contract: what starts a shutdown, what each phase guarantees, what bounds it, what survives it, and what a restart recovers. It covers both endings. A graceful shutdown completes admitted work within its deadline. A forced ending — a repeated signal, an expired deadline, or SIGKILL — abandons the remaining phases, and the rest of the cluster observes it exactly as it observes a crash.

Nervix is not an exactly-once system, and shutdown does not make it one. The guarantees below stop at durable boundaries that are stated explicitly. See What It Is Not for the persistence boundary and ACK Semantics And Effective Delivery for the delivery consequences at each sink.

Stop Requests

A node begins shutting down for exactly two reasons:

  • It receives its first SIGINT or SIGTERM.
  • One of its public listeners stops, whether it failed or returned.

There is no NSPL statement, API call, or cluster command that stops a node. CORDON NODE, DRAIN NODE, RELOCATE, and DROP NODE move work and change eligibility; none of them ends a process. Stopping a process is an operator or supervisor action delivered as a signal.

Both signals are registered before the node starts any other work, and they stay registered until the process exits. A node that cannot register them refuses to start, reporting failed to register termination signal handlers and exiting with status 1. A diagnostic node, the server built in the deloxide mode, then installs its deadlock detector before it starts a runtime worker, so the detector’s threads exist only once neither signal can end the process by its default action; a diagnostic node whose deadlock diagnostics cannot start refuses to start, reporting failed to start the diagnostic node's deadlock diagnostics and exiting with status 1. A signal that arrives while the node is still starting is held rather than lost: it takes effect once startup completes, while its deadline runs from the moment the signal arrived.

Repeated requests are idempotent. A later stop request never restarts, extends, or replaces the first one; it can only end the process sooner.

Phases

Shutdown reports three phases in order. Each logs when it finishes and with what outcome.

PhaseLog messageWhat it owns
Stop admissionshutdown admission phase finishedAdvertising the terminating incarnation, then closing the public listeners and the client connections they had accepted
Drain supportshutdown drain-support phase finishedMoving scheduled work to a replacement node when one exists, then completing admitted work in place, while the services that work depends on stay alive
Terminal teardownshutdown terminal-teardown phase finishedStopping the node’s tasks, then the runtime, consensus, cluster membership, and interconnect, and releasing its storage
stop request                                                             exit 0
(first SIGINT or SIGTERM,                                                   ^
 or a public listener that stopped)                                         |
       |                                                                    |
       v                                                                    |
  StopRequested ------> DrainSupport ------> TerminalTeardown ------> Finished
       |                     |                      |
       +---------------------+----------------------+
                             |
         repeated SIGINT or SIGTERM  ->  exit 130 or 143
         shutdown deadline expired   ->  exit 1
         (the remaining phases do not run)

Each phase reports one of three outcomes:

  • Completed. The phase finished its work.
  • Abandoned. The phase gave up on work it could not finish, such as a drain that timed out or a leader it could not reach, and shutdown continued.
  • Forced. The deadline passed while the phase was running.

A process that reaches terminal teardown with any phase Forced exits with status 1 rather than 0, because graceful shutdown did not finish within its deadline. The absence of shutdown terminal-teardown phase finished in a node’s log is the definitive sign that the process did not shut down gracefully.

The Shutdown Deadline

One deadline bounds all three phases. It is set by --shutdown-timeout (NERVIX_SHUTDOWN_TIMEOUT, default 50s) and measured on the process monotonic clock from the first stop request.

The deadline is absolute. A later signal does not extend it, a client holding a session or an upload open does not extend it, and domain pacing never changes its physical length: a domain running a million times slower or faster than real time reaches the same physical deadline. A separate supervisor enforces it independently of the runtime, so a wedged or saturated node cannot outlive its own deadline by failing to schedule work.

When the deadline passes, the process logs the phase it had reached, reports shutdown deadline expired; abandoning graceful shutdown, and exits at once with status 1 without running its remaining phases.

Inside that deadline sits the drain timeout, --drain-timeout (NERVIX_DRAIN_TIMEOUT, default 30s), which bounds the drain-support phase alone. The two defaults are ordered so that a full drain still leaves time for terminal teardown. Every drain step waits for the smaller of its remaining drain budget and the remaining shutdown deadline, so shortening the shutdown timeout below the drain timeout makes the shutdown deadline the effective bound. The one exception is the release of the cordon the node’s own drain set, which keeps a one-second grace when moving the node’s work used the whole drain timeout; see Releasing The Drain Cordon.

Two bounds are deliberately outside the deadline. Terminal teardown’s final stops — the runtime, consensus, cluster membership, and the interconnect — run to completion rather than being cut short, because stopping them is what releases the node’s tasks, connections, and storage. The interconnect applies its own ten-second transport drain. Closing the node’s databases synchronizes their journals and blocks until their background workers have left, so the services that own them are dropped last, as one job on the node’s filesystem storage workers; a node that cannot take that job reports teardown Abandoned. A background worker whose flush or compaction failed never holds that close open; see Terminal Teardown. The deadline supervisor, not those bounds, is what guarantees the process ends.

Exit Status

EndingStatus
Graceful shutdown finished, every phase Completed or Abandoned0
Graceful shutdown ran to the end but a phase was Forced1
Shutdown deadline expired1
A public listener, cluster shutdown, or storage release reported an error1
Termination signal handlers could not be registered at startup1
A diagnostic node’s deadlock diagnostics could not start1
A diagnostic node reported an active deadlock among its tracked locks and recorded it3
A diagnostic node reported a deadlock it could not record, lost findings, or outlived the budget for recording one4
A command-line option or its environment variable holds a value the node cannot read, such as duration text that names no duration2
Repeated SIGINT130
Repeated SIGTERM143
SIGKILLTerminated by signal, no exit status

Every SIGINT or SIGTERM after the first abandons graceful shutdown. The process logs repeated termination signal received; abandoning graceful shutdown with the phase it had reached and exits immediately with the status a shell reports for that signal, whichever signal it was that started the shutdown. No destructor, exit handler, or remaining phase runs. Whichever comes first, the repeated signal or the deadline, decides how the process exits.

A diagnostic node that reports an active deadlock ends the same way, immediately, whatever phase it is in: its blocked threads hold work it can no longer finish. It describes the cycle on standard error, records it as evidence, and exits with 3, or with 4 when the recording failed, without running a destructor, an exit handler or a remaining phase, as Diagnostic deadlock detection describes. Its durable state recovers as after any forced exit. Execution Primitives owns how the diagnostic binary is selected and why it is a separate artifact from an ordinary release.

Terminating Incarnation And Placement Eligibility

An incarnation is one process run of a named cluster node. A restart produces a new incarnation of the same stable node name.

The first act of shutdown is to advertise that the current incarnation is terminating. That advertisement belongs to the incarnation, not to the node name, and is carried by cluster gossip rather than persisted. A restarted process is a new incarnation and does not inherit it.

Terminating removes the incarnation from consideration as a new placement destination. It does not remove the node from anything else: the incarnation stays a live Raft voter, keeps its interconnect listener and registered handlers, keeps carrying admitted relay work and acknowledgements, and can still complete an ownership handoff that is already in progress. A node that is shutting down does not leave the Raft membership, does not step down, and does not transfer leadership away. It simply stops, and its peers observe it going silent.

An administrative cordon is the other eligibility control, and the two are independent:

CordonTerminating
Applies toThe stable node nameOne process incarnation
Set byCORDON NODE <node_id>, and by DRAIN NODEThe process itself, at its first stop request
Stored inReplicated consensus stateCluster gossip
Survives restartYesNo
Cleared byUNCORDON NODE <node_id>Process exit
Visible asraft.cordoned_nodes in SHOW CLUSTER STATUSterminating: true in node status

Both filters compose when the scheduler chooses an owner: a candidate must be a live Raft voter that is neither cordoned nor a terminating incarnation. RELOCATE and DESCRIBE RELOCATION apply the same checks in a fixed order and name the first one that fails — not a Raft member, then not a live Raft voter, then node '<node_id>' is terminating, then node '<node_id>' is cordoned. The planning snapshot retains terminating live voters separately from the composed destination set. A committed cordon cannot classify a live process as terminating, and a process with both states retains its termination reason.

The planning fence uses those composed destination candidates. After the drain’s cordon commits, observing the former owner’s terminating advertisement does not invalidate its pending move: that owner was already excluded as a destination. Its live incarnation remains a required input, so a restart or loss of that owner still invalidates the captured plan. Changes to other eligible destinations and to the committed topology retain their own fences.

Cordon Preservation

A graceful shutdown that moves work away cordons the node so its own work does not come back to it mid-drain, and clears that cordon afterwards. It records whether the node was already cordoned before it started, and clears only a cordon it set itself.

An operator cordon therefore survives shutdown and restart. A node cordoned by an operator, stopped, and started again comes back cordoned. When the node never requested its drain, because the drain timeout or shutdown deadline passed before it observed a leader, nothing was cordoned and nothing is cleared. A request the leader may have received counts as requested, even when its answer never arrived, so the node still clears the cordon that request may have set.

Releasing The Drain Cordon

Releasing the cordon is one consensus write, made by the leader: by the node itself while it leads, and otherwise by the leader it asks over the cluster interconnect. The stopping node waits for that write for what remains of the drain timeout, and for at least one second when moving its work used the whole drain timeout, so a drain that timed out still clears the cordon its request may have set. The wait never reaches past the shutdown deadline. A consensus write that takes seconds on a loaded worker therefore still completes the release, and a node that handed all of its work over reports the drain-support phase Completed.

That wait applies while the stopping node and the voters that are live and not terminating form a quorum, so the release can commit without voters that are stopping too. When they do not, as when the whole cluster stops together, the release keeps only the one-second grace: only voters that are about to stop could commit it, and waiting longer would only delay the stop of the last node of a cluster that is going away. The node decides this from its own gossip view when the release begins.

The release runs beside the completion of admitted work in place, which waits for what remains of the same drain timeout, so a slow release does not shorten the time admitted work has to finish. Placement still excludes the node once the release commits, because its terminating incarnation is not a placement candidate whether or not it is cordoned.

A stopping leader writes the release itself and waits until a quorum has committed it, so the release survives the node’s stop like any other committed entry. It does not transfer leadership first: the release would still be one consensus write, and every stop of a leader would add a leadership change before its drain. A leader that loses its leadership while it writes the release answers as a node that does not lead, and the stopping node asks the leader it observes next within the same bound; clearing a cordon twice is harmless.

When the release does not complete within its bound, the node logs timed out clearing shutdown drain cordon with the timeout it waited, reports the drain-support phase Abandoned, and continues to terminal teardown. A leader that refused the release or never answered it is logged as failed to clear shutdown drain cordon before graceful shutdown or the leader did not answer the release of the shutdown drain cordon, with the same outcome. What the cordon becomes then depends on the write:

  • A leader on another node that received the request finishes it in a service task of its own, so the cordon still clears after the stopping node stops waiting.
  • A release the stopping leader appended but had not committed survives only if the node that leads next holds it in its log, and that leader then commits it.
  • A release that never commits leaves the cordon in replicated state. The node starts again cordoned, exactly as after an operator cordon: raft.cordoned_nodes lists it, it takes no new placements, and UNCORDON NODE <node_id> clears it once the cluster has a leader.

Stopping Intake

Stop admission closes the node’s public surface. It stops accepting on the session gRPC, connector, observability, and console listeners and closes the client connections those listeners had accepted.

Closing a connection cancels the requests it carries. A console session is told that the server is shutting down, after the replies already queued for its client, and that ending is its last frame. A native gRPC session’s connection is cut when admission closes, so its client sees the stream fail as it would after any transport loss. Neither waits for the client to read anything: a session whose client reads nothing ends just as promptly, and its subscriptions stop and release the relays they held. Every request the session had not yet admitted is cancelled before admission and never begins an effect, so the client can send it again, with the same execution reference, to another node. A session stream or a resource upload waiting on its client ends at once, so no client can hold the drain or the process open. A backup download or a restore stream that the web console carries over a WebSocket of its own ends at once as well, with close code 1001, as a native gRPC download or restore call is cut; a restore admitted with its whole archive goes on without its call. An authenticated upload held open at any point of its progress — before its first message, between chunks, or trickling chunks indefinitely — is cancelled this way and does not delay the exit. Node Stop And Restart As A Client Observes Them describes what a client sees at each ending and what it recovers.

Work that a session command had already admitted is not cancelled here. Its session stops waiting for it, and no reply follows for it, but its effect keeps running. A transaction commit in progress keeps running, and terminal teardown waits for it until the shutdown deadline. A commit still running when the deadline passes is cancelled with the process; its replicated progress survives, and a later leader resumes it from its recorded step, so the transaction reaches COMMITTED after the node restarts.

The terminating node then stops new intake on every one of its ingestors, including endpoint and Syslog ingestors that serve on every node and ingestors whose scheduled owner did not move. Its generators stop producing. Intake never reopens: there is no resume path out of a shutdown drain. If a broker-style source is still resuming, the host cancels that pending resume when shutdown arrives, drops its DNS, socket and handshake work, and closes the source before exiting. A quiesce change also cancels an in-progress resume so the next loop turn observes the new intake state.

Source-instance readiness retirement is final and names the ending host’s exact handle. Shutdown retires installed readiness before withdrawing the registry. Terminal domain execution teardown cancels its relay channel publication after owned tasks stop; retained producer or channel handles then reject further dispatch and cannot attach to a restarted execution.

This intake stop ignores ON QUIESCE. That clause governs what an external source experiences during a resumable hold — a model alteration, a domain pause, or memory-pressure shedding — where the ingestor will run again. Shutdown and ownership handoff are not resumable, so polling and endpoint admission simply stop, and no SUSPEND, BUFFER, DROP, or REJECT policy is applied on behalf of the stop. An endpoint refuses new requests outright, without offering a retry delay.

Endpoint source close and terminal table clearing end each exact intake lifetime before withdrawing its binding. HTTP requests and WebSocket sessions that retained a route then see absent intake on later admission; a replacement source does not reopen that retained lifetime. A request already holding an intake lease may finish within the existing shutdown deadline. Endpoint definitions and intake leases are volatile publications and are reconstructed from the installed revision on startup.

Payloads already admitted continue through their routes. Each source host retains the quiesce publication it observed before awaiting dispatch. Its next change wait compares against that publication after registering the waiter, so a shutdown or ownership-handoff engagement during dispatch is observed on the next loop turn even when the notification arrived before the wait began.

A client ingestor stops intake the same way: from the moment intake stops, a batch that arrives or waits unadmitted is refused as draining, which its producer must not send again to this execution, while admitted batches continue through their routes. Closing the node’s sessions detaches the producers they held; their admitted batches still drain. Producers another node forwards here stay attached through the drain and learn every outcome it decides. Terminal teardown then ends every producer still attached with shutting down, reporting each batch whose acknowledgement is still unresolved as of unknown outcome with cause interrupted.

A raw quiesce buffer is not part of the drain. Payloads that a BUFFER mode retained during an earlier hold are outside runtime graph work: a shutdown does not replay them, and they are discarded and counted as dropped when the ingestor stops. A retained payload whose unfolding was still waiting for the extension workers stays in the buffer, so the stop ends that wait at once and discards it with the rest. A live payload a source handed over without an acknowledgement, such as a paced poll, whose unfolding was still waiting for the extension workers is dropped when its ingestor stops; the stop ends that wait at once as well. Only work already admitted into the graph is drained.

Draining Admitted Work

Drain support has two parts that share the drain timeout. Both are also bounded by the shutdown deadline.

The stopping node closes its local source intake at the start of drain support, before it asks the leader to move any scheduled work. Kafka and other source ingestors stop taking new input, and generators stop producing. A payload already admitted and its acknowledgement chain continue through the installed relays and emitters. This prevents a source from admitting a new ACK root while a downstream emitter is moving. A destination node can start its replacement source after the handoff; intake on that node remains open. The local intake closure is permanent for the stopping process and is reused by the later in-place drain.

Moving scheduled work. When another live, schedulable Raft voter exists, the node first moves its scheduled work there through the planned ownership handoff described below. This is the same operation DRAIN NODE performs, requested against the terminating node itself: it visits domains and schedule units in canonical order and moves one hard colocation group or independent runtime node at a time. The request gives the leader the time left in the stopping node’s drain budget. For each unit, the leader bounds its gate, state preparation, and activation waits by the time still left on that request minus one sixth of the request’s initial budget. Preparation, including the entity gate’s quiescence wait and state capture, has at most four fifths of that unit bound; activation uses the same unit deadline. The ordinary entity-gate limit can shorten either phase. If a unit cannot prepare within its bound, the gate is released, the leader records that unit and its reason as failed, and it attempts other units while time remains. If no unit budget remains, the leader reports the unfinished drain. A stopping node that receives a failed or unanswered drain reports its drain-support phase Abandoned, then still runs the in-place drain with whatever time remains. The node’s own timeout and shutdown deadline bound its wait for a leader even when that leader continues cleanup after the node stops waiting.

Completing admitted work in place. The node then finishes what it has already admitted. This part always runs, whatever happened in the first part. It is the whole drain when no replacement exists, and it also covers listener ingestors, which bind on every node and are never moved, and any unit whose move failed.

While admitted work finishes, the node’s relays, processors, emitters, and acknowledgement paths stay alive, and so do the supporting services that work depends on: the domain clock and its progress delivery, schedule activation, runtime-state replication and checkpoints, ownership-handoff coordination, and the interconnect listener and its handlers. Work admitted before the listeners closed can therefore still take execution snapshots, wait on logical deadlines, and reach its sinks.

Nervix repeatedly force-flushes ingestor route buffers, processor collections and route buffers, message-error routes, reingestors, and emitters, whatever their FLUSH EACH cadence, until nothing remains: no relay batch, no runtime-node work item, no emitter buffer or active publish, and no admitted acknowledgement root. A stopped or extremely slow domain clock cannot hold rows out of the drain, because the force flush does not wait for a logical cadence to come due.

Window processors are the one exception to that release. A force flush purges timed-out aggregate state but emits only windows that have met their declared WIDTH. A partially filled window is not emitted early by a shutdown, so its rows do not reach the sink. A row’s acknowledgement completes when the window steps past the row; until then its shares are parked, so the drain does not wait for them. The branch’s final checkpoint keeps the retained rows for the next task of the branch to restore, and teardown ends their shares unacknowledged: a source with external acknowledgements redelivers those rows after the restart, and the restored window admits them beside the rows it restored.

An ownership handoff publishes the remaining window for the destination to restore. Evicting a concrete branch has a different endpoint: it drops the branch’s retained rows and aggregate state before the final checkpoint, so a later branch with the same key begins with an empty window. The lifecycle checkpoint carries each branch incarnation; window restore accepts retained state only from that same incarnation. A restarted owner resumes those incarnations before it accepts any input; see Restoring Processor Branches.

Draining ends with a confirmation pass. After a flush generation observes nothing outstanding, one more generation must also observe nothing, so work that an upstream node publishes after a downstream node finished its own flush is not left behind. A domain is quiescent only when that confirming generation completes with nothing visible. Quiesced backup applies the same admitted-work view to its selected domain across all live nodes. It requests a separate cluster-wide confirming generation before capturing checkpoints. Parked REQUIRED WAIT messages and the rows windows retain do not hold that cut open, and a failed drain resumes the domain. The cut captures retained window rows as window state. When a parked message has crossed nodes, remote ACK progress carries its parked state back through the source’s acknowledgement chain. The drain excludes that chain while the message is parked; resuming it reactivates the chain, and only a terminal ACK completes the source attempt.

Work keeps moving while the drain looks for it, so “nothing visible” is an exact observation rather than a series of separate reads. Each observation reads the domain’s outstanding force-flush obligations first, so a flush it sees complete has also left everything it released or resumed visible to the reads that follow. It reads every relay’s admission sequence before and after the other counts, and a relay that admitted a batch in between counts as work still moving: a batch that reached a relay or a node only after the read that would have counted it there entered some relay while the observation read. Node quiesce accounting describes the counts and the order of the reads.

Each domain therefore advances independently through three states:

  intake closed on            a generation requested with nothing
  every ingestor              visible completes with nothing visible
        |                                   |
        v                                   v
    Draining  ------------------------> Confirming ------------------------> Quiescent
        ^                                   |
        |   admitted work became visible    |
        +-----------------------------------+

    drain timeout or shutdown deadline reached, in either state
        |
        v
    Abandoned  ->  remaining work is negatively acknowledged

A WASM processor branch keeps the acknowledgements of its latest guest callback open until that callback’s checkpoint reaches stable storage and every assigned replica, so a drain waits for the checkpoint as it waits for any other outstanding acknowledgement. The checkpoint has a ninety-second completion deadline, while the drain timeout may end the node’s wait sooner. A checkpoint that cannot complete fails and negatively acknowledges what it held.

Runtime teardown gives each processor task a stop grace, and the processor task gives each of its branch tasks one of its own. A processor task still stopping its branches when its grace ends is ended together with every branch task it holds. A branch still waiting for its checkpoint therefore never outlives its node: it cannot keep the node’s runtime database open past terminal teardown, continue a checkpoint, or settle acknowledgements after the node stopped. Its unreleased acknowledgements are negatively acknowledged, and a restart finds whatever that checkpoint had already written to the node’s storage, exactly as after a forced ending.

A deduplicator or window branch also has a snapshot task, which writes what the branch published last on every snapshot interval and once more as the branch stops. That task belongs to the runtime, not to its branch task, because it goes on after it: a branch ended at a stop grace before its own stop was reached published nothing as it went, so what it published before is the newest state a restart can restore, and the snapshot task still writes it. A branch that stops in time publishes its state first and waits for that write itself. Either way the write is finished before terminal teardown releases the node’s storage, as Terminal Teardown describes.

A coordinated WASM state reset is serialized with domain lifecycle, placement, ownership movement, resource rebinding, and model mutation by the domain alteration lease and its entity gate. If node shutdown interrupts a preparation before reset publication, the old generation remains authoritative; ordinary reset error handling restores the stopped task when it can, while terminal teardown may discard its volatile handoff and negatively acknowledge what it held. Restart then restores the old generation. If the reset’s Publishing schedule wins, the old generation is already fenced permanently. Drain support keeps schedule activation, state storage, replication, and interconnect handlers alive so the fresh initial checkpoint and Ready publication can finish within their ordinary bounds. If shutdown ends first, restart observes Publishing and resumes the new generation rather than restoring the old one.

Read-only WASM state inspection after restart uses the committed schedule’s generation and retained reset and recovery outcomes. A pre-publication failure leaves the preceding generation visible; a published but not yet usable reset remains PUBLISHING until its initial checkpoint and activation finish. Runtime checkpoint observations from a replaced generation are excluded, and a restored checkpoint reports unknown prior replica confirmation when that boundary cannot be reconstructed. Inspection never settles an uncertain transaction outcome or resumes a reset.

An admitted NSPL reset is recorded as an ordered transaction effect. If shutdown interrupts the command after its effect is recorded, recovery resumes it with the original execution reference; it does not admit the text as a fresh reset. The client receives success only after the replacement is usable, or a retained failure when that cannot be established.

Resetting one concrete branch does not turn a sibling branch into shutdown work. Its scoped relay gate and processor command lane select only that branch; sibling callbacks, checkpoints, outputs, ACKs, and timers continue until shutdown itself reaches them. Old timeout handles belong to the discarded branch instance and are never transferred to the fresh instance.

Three kinds of work deliberately do not hold the drain open:

  • Pending REQUIRED WAIT records. A message suspended on absent materialized state cannot finish, because the dependency is not there. Every force flush retries it against the state that is present; whatever still waits at the end is negatively acknowledged. Its source redelivers it after the restart, and the record is processed then against the state that has since arrived.
  • Rows a window retains. Only further input steps a window past them, and the window’s checkpoint keeps them, as described above.
  • Outstanding force-flush obligations. They are the mechanism of the drain, not admitted work waiting inside it.

Payloads that other live nodes publish into this node’s relays are admitted work like any other, so a peer that keeps publishing extends the drain until its timeout. When every node terminates at once, each completes its own admitted work within its own drain timeout, and work that reaches a peer after that peer finished draining is negatively acknowledged.

When The Drain Does Not Finish

A drain that reaches its timeout reports itself abandoned and shutdown continues. Terminal teardown then negatively acknowledges the remaining work. A source with external acknowledgements redelivers it after a restart; a source in MODE NO ACK loses it.

This is the behavior an unavailable sink produces. An emitter whose sink will not accept records holds the drain until the timeout, its source offsets are never committed, and the records are redelivered and published after the node restarts and the sink recovers.

The log distinguishes the two ways a drain runs out of time:

  • local graph drain timed out with admitted work outstanding, with per-domain counts of admitting ingestors, active generators, outstanding acknowledgements, buffered relay batches, runtime-node work items, buffered emitter messages, publishing emitters, pending REQUIRED WAIT records, and force-flush obligations.
  • local graph drain timed out before confirming that no admitted work is still moving, where the work finished but the confirming generation did not complete in time.

Ownership Handoff During Shutdown

The ownership move at the start of drain support is the same planned handoff used by DRAIN NODE, RELOCATE, and placement consolidation. Control Plane owns the general contract; this section states what shutdown adds.

The complete target schedule is computed before anything is held. The coordinator then fences dispatch at the affected subgraph boundary on every live node, stops intake for each moved ingestor, and drains the work already admitted to the moved unit, including moved-ingestor acknowledgement roots, emitter buffers and active publishing, and an Iceberg emitter’s staged commit. The new schedule is written only after that drain succeeds, and the fence opens only after the destination activates the published revision.

Each participant closes intake and boundary gates, requests a force flush and confirms its admission hold. The coordinator polls the exact operation’s drain status while installed routes and processors finish the admitted work. Returning a drained ownership status publishes that participant’s capture freeze under its retained hold. Capture verifies both the held operation and its exact freeze identity, so a pending flush cannot make mutable state ready for capture. Frozen owners retain state until capture and activation finish. Preparation retains its original deadline, distinct from the activation lease. A pending-drain timeout reports its node and counts, releases every attempted hold, and leaves independent schedule units available for later moves. Repeated engagement requests use the same identity and scope; a bounded reply timeout creates no additional flush and restarts no lease.

Every step of the handoff carries one coordination identity, composed of the coordinating node, its process incarnation, and a sequence. The receiver verifies that identity against the authenticated connection before the request is handled, so a different node cannot use it and a restarted coordinator process cannot replay it. Engagement binds the identity to one domain, relay set, affected-entity set, and purpose; a retry succeeds only for the same identity and the complete scope.

Gate leases are deadline-bound and release themselves if the coordinator disappears. The default lease is sixty seconds and a planned handoff budgets two of them, one for preparation and one for activation. Both are longer than the default shutdown timeout, so a gate a terminating node engaged can outlive that node’s own process; the lease deadline, not the node, is what reopens it.

When activation fails after the schedule is committed, the gate stays shut until its lease expires rather than opening before the destination is ready.

Interrupted Handoffs

Restart or owner replacement for a stopped WASM domain preserves the complete validated lifecycle and guest checkpoint inventory in its passive revision. Ownership preparation performs no guest callbacks and does not read the stopped domain clock. START restores those saves under the active clock generation; stopped time does not authorize discarding valid checkpoints or resetting state.

A preparation written durably at a destination outlives the process that wrote it. The coordinator records every destination as an attempted participant before it sends the side-effecting request, so a timeout, a cancellation, or a lost response after the destination persisted the request remains explicit cleanup work rather than an unknown.

The current leader reconciles durable preparations after a leadership or live-incarnation change. It first commits a consensus barrier and sends its log position with the reconciliation request; each participant applies through that position before consulting its own committed schedule. A preparation survives when its exact transition is the committed owner change, regardless of which processes have since restarted. An uncommitted preparation survives only while the original coordinator process and both bound participant incarnations are live, the base schedule still has the source owning the entity, and the exact gate is still held. Every other preparation is discarded durably. Discard is exact and idempotent over the operation identity, so a delayed discard for one operation cannot remove a replacement prepared by another.

A destination that restarts between preparation and activation invalidates the handoff: the operation fails with destination node '<node_id>' changed process incarnation during ownership handoff, the schedule is unchanged, and a later relocation onto that node succeeds normally.

When The Former Owner Is Gone

Unexpected owner loss is not a handoff. The failed tasks and their volatile buffers disappear immediately, attached work is negatively acknowledged, and the scheduler promotes a live replica or chooses a fresh owner without waiting for a gate.

The scheduler acts on what application health and gossip report, so a forced recovery can also move work off a node that is still running. That node stops the moved runtime when it applies the published schedule. A batch a relay owner routed to it for a moved consumer, and that reaches its runtime afterwards, finds no attached consumer and fails its attached acknowledgements, so the source redelivers the record to the new owner rather than committing it; see Consumers That Leave The Receiver.

Forced recovery is the path that publishes a schedule when the source cannot participate. It stages the destination’s checkpoint inventory under the destination’s process incarnation and the complete target-schedule fingerprint, and applying staged checkpoints accepts only that exact preparation. A missing or mismatched preparation does not imply a reset: without an exhaustive reset outcome in the schedule, activation fails and leaves every saved checkpoint unchanged. State is recreated only when the accepted decision either stages a recreated inventory or reports a reset for every state component the entity owns.

A forced recovery of a WASM processor starts a new guest-state generation for every branch in the same schedule publication, and activation publishes the staged checkpoints in that generation. The former owner’s saves, and those of any replica that missed the recovery, belong to the generation it replaced, so a node that restarts or rejoins with them never restores, serves, or supplies them to a later recovery. A later owner loss therefore resets a branch whose only surviving checkpoints are of an earlier generation instead of reviving them. When the processor has replicas, a replica holds every checkpoint whose acknowledgements the lost owner released, because the owner released them only after its replicas had synchronized the checkpoint, so forced recovery continues each branch from at least the state its acknowledged inputs produced. The promoted replica restores every staged checkpoint into a guest before the schedule is published, from the module it compiled while it was a replica, so the recovery does not wait for the module to compile. A preparation that fails, including a guest that cannot restore a staged checkpoint, publishes the recovery with recreated state; see Forced Recovery.

Topology Cases

CaseBehavior
Single nodeNo replacement exists. Nothing is cordoned, no ownership moves, and all admitted work drains in place before the process exits.
Follower with a reachable leaderThe node asks the leader, over the cluster interconnect, to drain it, then completes what remains in place.
LeaderThe leader drains itself in process and commits the release of its own cordon before it stops. Leadership is not transferred first; the cluster elects a new leader after it stops.
Last schedulable nodeSame as a single node: no replacement candidate exists, so everything completes in place, including source offset commits.
Only peer is itself terminatingA terminating incarnation is not a placement candidate, so the node takes the no-replacement path and completes its work in place.
Every node at onceEach node advertises terminating, finds no eligible destination, and drains locally within its own drain timeout. A node that asked for its drain before it saw the others terminating gives the release of its cordon only the one-second grace once it sees them terminating, because only voters that are stopping too could commit it.
Leader unreachable, or no quorumThe node waits for a leader only until its drain budget runs out, reports the drain-support phase abandoned, and still completes its local drain. Nothing is cordoned.

The no-replacement path is explicit in the log: no live schedulable replacement node remains; admitted work completes in place.

A follower requests its drain, and then the release of the cordon that drain set, with the stopping_node_drain request over the cluster interconnect. The request names no node: the leader acts for the node whose certificate authenticated the connection, so a node can drain only itself, and no user credential takes part. A follower started without --init-default-user-password, as every node but the bootstrap node is in the documented Docker deployments, therefore drains through the leader like any other. A node that receives the request without leading changes nothing and says so. A leader that loses leadership before it completes the drain also answers as not leading, including when a participant fences its coordination identity during a handoff. It logs the interrupted drain report before answering. The stopping node asks the leader it observes next within the same shrinking drain budget. Already committed moves remain committed; the next leader reads the current schedule and moves only the work still owned by the stopping node. A failed drain from a node that still leads remains a failed outcome. The leader runs the drain in a task of its own, so a drain that has begun finishes, and releases the gates it engaged, even when the follower’s drain timeout ends its wait first. The interconnect resolves the leader’s advertised endpoint through the follower’s loaded resolver, described in Name Resolution, within the drain and shutdown deadlines.

Connector Contracts

Connector Crates And The Connector Contract defines normal source and sink ownership and completion points. This section describes what a node’s drain can complete before its shared stop deadline.

Shutdown does not change any connector’s delivery contract. It changes only whether a connector reaches its completion point before the process ends.

Sources

A source acknowledges or commits the work that reached its sinks before its session stops. A batch acknowledged through the graph stays outstanding admitted work until its source has confirmed the batch’s positions on its transport, so the drain waits for that confirmation as it waits for any other acknowledgement root, within the drain timeout. What a source’s confirmation means is the acknowledgement confirmation its entity declares; see Confirmed acknowledgements. An SQS deletion is answered by the broker and a RabbitMQ acknowledgement is written to the connection before the drain moves on, while a Kafka consumer-group commit, a Pulsar acknowledgement and an MQTT PUBACK are only queued by their clients, so the drain does not wait for their brokers to answer them.

Work whose acknowledgement root is still unresolved when the drain ends is negatively acknowledged, so the source’s own redelivery contract applies. A source instance that stops while it still waits on such a batch logs source instance stopped before its transport confirmed positions it read; the transport presents them again at info, with the count of positions. This is why a drain that times out leaves Kafka offsets uncommitted rather than advancing them.

What that contract is depends on the source, and three groups differ sharply:

SourceSource acknowledgementAdmitted work when a drain does not finish
Kafka, Pulsar, RabbitMQ, SQS, and MQTT in an ACK modeThe offset is committed, the broker acknowledged, or the message deleted only after the record is acknowledged through the graphRedelivered after the restart
HTTP polling, PrometheusNone; the poller re-reads its source each cadenceRead again by a later poll
NATS, Redis Pub/Sub, ZeroMQ, WebSocket clients, HTTP endpoints, SyslogNone exists; these sources offer no acknowledged modeLost, with nothing to redeliver it
Client ingestorsThe producer receives each batch’s outcome once its acknowledgement root resolvesReported to its producer as of unknown outcome, or not at all when the producer’s session ended first; the application decides whether to submit it again

The last row is the one to plan around. Those sources have no acknowledged delivery mode at all, so a record admitted from them and not yet emitted is lost both by a drain that runs out of time and by every forced ending. An endpoint rejects new requests as soon as intake stops, so a client that handles the rejection can retry; a payload it already accepted is not redelivered by anything.

A general failure handled by ON GENERAL ERROR IGNORE acknowledges the record, while ON GENERAL ERROR LOG negatively acknowledges it. During a drain that choice decides whether an acknowledged source redelivers the record after the restart.

Sinks

An emitter reaches the sink completion point its MODE declares, and the drain waits for it. An emitter that cannot finish reports emitter '<name>' did not drain before its configured deadline and holds the drain until the timeout. A batching emitter’s drain also writes every batch payload an earlier attempt left unanswered, with the bytes and members it was first written with, as an OTEL emitter’s drain sends every Export request it prepared and did not learn the outcome of, and the emitter buffer counts those members as work the node still holds until they resolve.

A native client emitter reaches its success boundary only on application ACK. Force flush prepares its bounded Arrow IPC batches, but a waiting consumer read or a batch already sent to a session does not drain them. Closing public sessions during intake stop detaches their consumers and revokes attempts; remaining prepared batches wait for another consumer within the physical drain deadline. On deadline expiry terminal teardown cancels the emitter task and discards volatile attempt history. An attached upstream source may replay after restart, including a batch whose application effect happened but whose ACK was lost. No durable consumer cursor is restored.

An HTTP emitter force-flushes its collected records into one request per eligible record, then waits for complete successful final response headers. Each attempt and retry wait remains bounded by the remaining physical drain deadline, even in a paced domain. A request still unanswered at that deadline is not marked delivered. Its prepared bytes, selected destination, headers, and retry state are volatile and disappear when the process ends. An attached acknowledged source can redeliver the record after restart; an unacknowledged endpoint source cannot. A destination that applied a request whose successful response was lost can receive it again after that redelivery. Once the graph drain budget ends, terminal teardown cancels an emitter task waiting in an HTTP attempt. It does not wait for that client’s full attempt timeout or turn the canceled request into a record-specific success or rejection.

Kafka is the only sink whose client-side queue shutdown drains explicitly: after its buffered batches are published, the emitter host calls the sink contract’s finish hook with the remaining stop deadline. Kafka implements that hook by flushing the producer’s local queue within the same deadline. Other sinks hold no such queue, so their default finish hook completes immediately and the publish itself is the completion point.

A sink that stages what it accepts and publishes it later reaches its completion point at its commit instead. After the buffered batches are written into such a sink, the drain calls the sink contract’s commit hook and forces it, so the commit runs whatever its commit cadence says and without reading the domain clock, which a stopped domain no longer has. Until that commit succeeds the sink holds the acknowledgements of every row it staged, and the drain counts those rows as work the node still owes. A commit that fails is retried on the emitter’s declared backoff, reported as the emitter’s commit retry, and a shutdown that cuts the backoff short fails the drain.

Iceberg is the sink that does this. It commits its staged data through its catalog, forced by the drain rather than waiting for the COMMIT EACH cadence. Staged rows live as local files that only a successful commit removes, so a forced ending leaves them behind: the rows are not in the table, and a later run does not reclaim or commit them. An attached source redelivers that work; a detached one loses it.

Duplication

A graceful shutdown creates no new duplicate condition, but redelivery after a forced ending or an abandoned drain produces exactly the duplicates the source and sink combination already allows. A sink that already published successfully can receive a record again after a restart, because acknowledgement state is in memory and does not survive the process. See ACK Semantics And Effective Delivery for the per-sink duplicate and loss conditions and the idempotency options each sink offers.

Branch Isolation

Branch isolation is preserved throughout. Draining walks concrete branches one at a time, branch-local state stays branch-local, and interleaved branches complete independently. Branch expiry is suspended while an entity is frozen for an ownership handoff, so a drain cannot silently expire branch state mid-handoff. Nothing about shutdown merges branches or moves work between them.

Terminal Teardown

Terminal teardown begins only after drain support completes or reports itself abandoned.

It stops the node’s background tasks, giving each at most two seconds, then waits for work that sessions and commands started until the shutdown deadline and cancels whatever still runs. It then stops the runtime, consensus, cluster membership, and the interconnect in that order, and finally releases the node’s storage.

Runtime teardown stops one domain at a time. It publishes the domain’s stop to its processors, emitters, generators, reingestors, and placement tasks at once, and then waits for the domain’s tasks kind by kind: relay owners and materialized relay state, each of which is told to stop when its turn comes, then processors, emitters, generators, reingestors, and the placement tasks that checkpoint and replicate state. A task that has not ended when its stop grace runs out is aborted. The node logs shutdown task exceeded grace period; aborting at warn with the domain, the task’s kind, and its name, or relay owner task did not stop cleanly and relay state task did not stop cleanly for a relay’s tasks. The grace is two seconds, except that a relay owner and an emitter are given the domain drain timeout first.

Generators and reingestors share their grace. A generator reads its source relay from the node that owns it, and both kinds publish to the node that owns their output relay, so while they work they wait on other nodes. When every node of a cluster stops at once those peers stop answering, and every generator and reingestor caught in such a call outlasts the grace at the same moment. Neither kind owns state that the node persists or replicates, so the waits of one kind run together. Each task still has the whole grace to release what its routes buffered, and a domain’s generators cost its teardown one grace and its reingestors one more, however many of them a stopping peer holds up. The other kinds persist or replicate state they own, or drain a sink, as they stop, and are waited for one after another, each with a grace of its own.

Once every domain’s tasks have ended, runtime teardown tells the snapshot task of every deduplicator and window branch that is still waiting for its branch to write what that branch published last, and waits for all of them. A snapshot task holds the runtime and its storage, so this wait is what keeps the node database from being released under a write: it has no grace and cancels nothing, and lasts as long as the slowest of those writes. A window whose retained rows exceed one snapshot section is sealed and published in bounded pieces through the storage and bulk workers, so its write waits for those workers like any other job. The node logs nothing for this wait; a write that fails is logged at warn as failed to flush branch state snapshot during shutdown, and a restart then finds the checkpoint written before it.

Runtime teardown ends each resolved replication route before clearing its state registry. A retained route cannot attach to replacement state; a request already admitted through a borrowed checkpoint handle may finish on that exact state. After domain drain, closing the replication task owner cancels its announcers, including pending dispatches and retry waits, and waits for their retained handles to be released before these routes are cleared. Teardown does not wait for an announcement’s remote dispatch timeout; synchronization supplies any missed availability hint.

Closing a Kafka offset replica task also cancels its pending revision request or bulk checkpoint stream immediately. A partially received or cancelled conversion never publishes a checkpoint or acknowledges its revision. Native conversion checks cancellation between entries and before publication; the replica’s assignment token still fences installation if ownership changed.

Consensus stops Raft and then waits for its storage to reach an idle barrier, which proves every earlier durable write has returned and released the store, before its dedicated database handle is dropped. The registry and runtime database is released separately after the services that hold its handles. Terminal teardown does not finish until both database locks have been released. A build with the testing feature is the exception: its fault injection keeps an observer of each running node’s consensus state, so an in-process test node’s consensus database closes only once the scenario harness gives that registration back, after the node has stopped; see Integration Test Lifecycle. See Consensus Storage And Replication for the write, replay, retention, and snapshot contracts behind this barrier.

Releasing a database stops its background workers, which flush memtables into tables and compact them, and waits until none is left. A worker normally leaves when it takes its stop request, but a flush or compaction that fails, for example on a full or failing disk, ends its worker at once, without taking one. The close never waits for such a worker to answer. It keeps checking whether any worker is left, discards the flushes and compactions still queued, and finishes once the last worker is gone, so a storage failure during the close cannot hold terminal teardown open. Discarding that work loses nothing: every write it would have moved into a table is in the database’s journal, which the close synchronizes and the next start replays. The storage engine logs each failed worker at error as Worker #N crashed with its cause, and a journal it cannot synchronize as Flush error on drop. Either failure reaches the node’s log, while teardown goes on to release the lock.

The interconnect rejects new admission, cancels pool and operation waiters, and retires the pool and peer protocol owners. An ending peer owner cancels unadmitted records and releases item and terminal-outcome permits even if the runtime retains an intake already admitted; that intake’s verdict remains admitted. A batch the runtime holds back for room for its acknowledgement watches is unadmitted, so that cancellation ends its hold, and so does the end of the relay intake lane when the node stops; it is never admitted afterwards. Decoded metadata stays charged while a borrower keeps it. Runtime teardown cancels and joins its charged ACK watcher tasks, closes delivery and admission routing queues, and negatively resolves remaining volatile correlations exactly once. The once-a-second silence sweep is one of the joined tasks. A sweep is synchronous, so its task cannot observe the cancellation while one runs, and a sweep that overran the second between sweeps finds the next tick already due, which does not suspend the task. The task therefore returns to its executor after every sweep and observes the cancellation there, whatever the sweep took, so the join waits for the sweep in progress and for no other. The receiver-side acknowledgement watchers the same join waits for have no cadence that can fall behind: each of their steps takes one row’s progress and awaits its delivery to the forwarding node, so they return to the executor within their cooperative budget. A sweep, like the resolution that follows, visits only the correlation positions the node’s deliveries and admissions have claimed, never its fixed capacity, which is what keeps it inside that second. No ACK owner state is stored for restart. The interconnect also retires the pool connections the node opened, giving their leased streams up to ten seconds before closing whatever remains. Connections that peers opened to the node close at once, together with the handlers still serving their streams, so a peer’s request the node has not answered fails instead of completing. A forced ending skips this entirely, so peers observe the connections ending exactly as they do when a process crashes.

The node’s own OTLP trace exporter is a process service. After the application returns, its tracing guard closes resolver installation and asks the SDK to flush before the process drops its Tokio runtime. An installed resolver remains available to queued exports. If startup failed before DNS installation, closing the publication ends a pending collector connection. Export failures remain telemetry diagnostics with no data-plane acknowledgement consequence, and a forced process ending does not guarantee a final export. Node Trace Export owns the resolver lifetime and export budgets.

Consensus Work At The Ending Boundary

Consensus remains live through stop admission and drain support. A terminating node can still append and apply control-plane entries during those phases, answer peer heartbeats, and carry replication. The boundary below begins when terminal teardown stops Raft.

Work in progressGraceful SIGINT or SIGTERMRepeated signal, expired deadline, or SIGKILL
Append batchA storage job already admitted to the ordered worker completes before the idle barrier can pass. Its complete atomic batch is synchronized before the database closes. An append alone is not a client acknowledgement.There is no idle barrier. Recovery finds the last complete synchronized batch or its predecessor; Raft may commit or truncate an uncommitted tail.
Applied rangeA write already submitted completes atomically with its final applied position. Entries committed but not yet submitted remain in the log and are replayed on restart.Recovery finds the last complete applied range and streams every later committed entry from the log. A response can be lost after durable application, so client silence remains an uncertain outcome.
Snapshot build or transferCompleted section writes remain durable. A generation with no published manifest stays inactive and is reclaimed later. A published installation marker is finished on restart if teardown did not reach its final publication.The same durable boundaries decide recovery. Partial section writes are absent; staged unpublished generations stay inactive; a marked installation is redone in full.

Stopping Raft ends each append-stream generation while the interconnect is still running. The stream stops accepting new submissions, releases its charged follower batches, and its peer observes a normal stream end or failure and resumes from its last confirmed progress if another leader path exists. The terminating node is still a Raft member and does not transfer leadership as part of shutdown, so peers otherwise react to its silence through the ordinary heartbeat and election rules. Interconnect shutdown follows and records any remaining reset with reason="shutdown". A forced ending skips the protocol close; peers see an abrupt stream or connection loss.

What Survives

Three persistence boundaries decide what a restart finds. Control-plane state is replicated and strongly consistent, selected runtime state is checkpointed, and the hot path is memory only.

StateGraceful shutdownForced ending or SIGKILL
Committed control-plane state: models, schedules, domain lifecycle, cordons, users, resourcesReopens from the committed generationReopens from the last committed generation; nothing acknowledged is lost
Published consensus snapshotsReopen from the current manifest; a marked installation is finished before state is exposedReopen from the current manifest; a marked installation is redone before state is exposed
Durable handoff and forced-recovery preparationsPreserved, then reconciled or activatedPreserved, then reconciled or activated
Runtime-state checkpoints: Kafka domain offsets, deduplicator and window state, materialized relay recordsFlushed again as runtime tasks stop; a deduplicator or window branch ended at its stop grace has what it published last written before terminal teardown ends; a materialized relay, and a window whose retained rows exceed one 8 MiB snapshot section, are sealed to quota-owned files and written as segments synchronized before their headerReopen at the last completed periodic checkpoint
WASM guest-state checkpointsEvery checkpoint that released an acknowledgement is already synchronizedReopen at the newest checkpoint on the node’s storage, which covers every acknowledged input
Restored backup state: materialized relay rows, WASM guest saves, Kafka domain offsets, branch lifecycle, deduplicator keys and window stateA restore synchronizes a complete chunked namespace and its atomic active-generation pointer before releasing the stopped domain’s START gate or resuming its archived lifecycleReopens pointer and checkpoints from one complete database view on each assigned owner and replica; a durable pointer alone does not release an incomplete replicated installation gate. A resumed domain retains every restored kind. A normal START continues saved guest state, source positions, branch incarnations, deduplicator keys and windows, and resets materialized rows
External source offsets and sink commitsComplete when the drain succeedsOnly the external connector’s own delivery and transaction guarantee applies
Relay batches, queued payload attempts, suspended work, ACK guards, ACK tokens, ACK maps, handoff payloads, gate leases, clock progressThe drain tries to resolve them before its deadlineVolatile; lost
Client producers, their credit and queued batches, producer linksEnded as shutting down after the drain, or detached with their sessionsVolatile; lost with the process. Every producer another node forwarded here ends there as owner lost: its batches that node never cleared for admission are refused as producer ended, and only the cleared ones are of unknown outcome. The node’s own sessions end, so their clients report every batch they sent without an outcome as of unknown outcome, and the nodes executing the ingestors detach those producers and finish what they admitted

A materialized backup captures the current relay generation independently of the periodic snapshot interval. Restore streams its complete sealed container into the generation store, including containers larger than the bulk memory budget. RESUME activates the archived domain generation only after every assigned owner and replica has published its complete set. These rows reopen from one pinned database snapshot across a node restart; the stored clock mapping projects downtime under the recovered authority. A failed installation retains its durable activation gate across restart. A normal START creates a new generation and clears materialized rows while retaining compatible WASM, offset, branch lifecycle, deduplicator and window state. Periodic materialized flushes seal to quota-owned files and write bounded database segments, synchronizing their data before the replacement header, so a large relay has the same shutdown durability boundary. See Backup And Restore. A deduplicator or window backup likewise captures what each branch task publishes when the cut’s lifecycle checkpoint asks it to, independently of the periodic interval, and a restore rebuilds each branch’s native checkpoint from its archived Arrow groups and streams it through the same publisher, one bounded group at a time, including a keyspace or a window larger than the bulk memory budget. A window whose retained rows exceed one 8 MiB snapshot section is persisted the way a materialized relay is: its periodic and stopping checkpoints seal in bounded pieces to a quota-owned file, and its segments are synchronized before the header that selects them, so a restored window larger than the bulk memory budget survives a graceful restart with the rows it admitted afterwards.

Durability is not uniform across those rows, and the difference is operationally visible:

  • Consensus acknowledges a vote, an appended batch, or an applied range only after synchronizing both data and filesystem metadata, so committed control-plane state survives host power loss on a device that honors those requests. Synchronization is per reservation-bounded batch or applied range rather than per entry.
  • Every handoff and forced-recovery preparation, activation, and discard is synchronized the same way.
  • Periodic runtime checkpoints are written to the operating system without forcing a synchronization on every update. They therefore survive the death of the process, including SIGKILL, but a host power loss can lose the most recent ones. This is the boundary the crash qualification asserts: exact counts are guaranteed only for checkpoints known durable before the kill. Segmented checkpoints, those of materialized relays and of windows beyond one snapshot section, synchronize their segments and then their header, so a power loss keeps the previous complete checkpoint.
  • A WASM guest-state checkpoint is synchronized, on the branch’s owner and on every replica the schedule assigns, before the source acknowledgements it covers are released, so every checkpoint that released an acknowledgement survives a host power loss. Checkpoints that branches take at the same time share one synchronization. See Failure At Each Boundary and WASM Processor Guests for what a recovered branch continues from and which inputs its source redelivers.

Branch-local processor state survives only to its latest publication. A branch task aborted after exceeding its grace period keeps what it had published and loses the changes it made afterwards.

Restart And Recovery

Recovered Ownership Is Fenced

A restarting node does not execute the ownership recorded in its own database until it has proven it is caught up with the cluster. On each process start it asks consensus for an admitted runtime state through a linearizable read, which confirms the leader’s authority with a quorum and returns a committed log boundary. The node applies through exactly that boundary before it reads the domain, clock-authority, and schedule state it installs, and it retries every half second until that read succeeds.

After admission, the control plane converts the committed schedule into a complete typed execution revision before runtime installation. Running and stopped domains use that same revision for local placement, state identities, passive recovery, and ownership handoff. The handoff fingerprint keeps the committed schedule’s exact bytes. If an installation fails, the next attempt is planned from the last successfully applied schedule; the failed revision is not recorded as applied.

Until admission succeeds, the node is live but inert with respect to ownership. Its public listeners and its interconnect answer requests, which keeps configured listening entities available on every live node, but no runtime routes exist, so a payload it accepts cannot reach recovered graph execution. A client producer or consumer open is refused as temporarily unavailable until the node has passed the catch-up barrier; the node does not report a stale local missing or stopped domain as a terminal endpoint refusal during that interval. The client can retry the open on its restored session. A former owner restarted while cut off from consensus therefore produces no output, and once connectivity is restored it observes the current schedule and forwards traffic to the node that now owns the work.

The node’s log marks the boundary. When its first read attempt fails, it logs this once per process at warn: runtime execution is waiting for linearizable consensus catch-up. When admission succeeds, it logs runtime execution admitted after linearizable consensus catch-up at info, with the committed_log_index it applied through.

At startup the node offers the peer endpoints retained in its Raft membership as gossip seeds. A former bootstrap node therefore has a path back to surviving peers even if it was originally configured without a bootstrap host. The endpoints only initiate authenticated contact; gossip establishes each peer’s current incarnation and endpoint before normal peer routing and runtime admission proceed. A recovered endpoint that does not resolve is skipped while other seeds and incoming gossip remain available. A configured bootstrap host that does not resolve is skipped on that restart when recovered peer endpoints are available; a node without them still needs the bootstrap host for first contact.

This is the fence that prevents crash recovery from reviving an obsolete owner. It is a process-start admission proof only: connectivity lost after admission does not revoke execution.

Whole-Cluster Restart Keeps Ownership

When every node restarts, the first node to lead can form a quorum while the others are still starting and gossip has not observed them live yet. Its automatic scheduling therefore waits, for the first ten seconds of its reconciliation, while any current voter has never been observed live by this process’s reconciliation task. A first heartbeat relayed by another peer can initially put the voter in Chitchat’s dead set before sufficient heartbeat intervals establish liveness. That initial dead verdict keeps the voter unobserved during the grace. Each owner that returns in that time keeps its work and restores it from its own storage and replicas. Live observations made while following survive a leadership change, and the original ten-second deadline continues running. Once it expires, a voter that never returned is eligible for ordinary failover. The observation history is private to this process and is discarded after the grace. See Planned Ownership Handoffs And Failover.

Restoring Processor Branches

A processor task restores the branches its branch lifecycle checkpoint names before it takes any input. It reads the checkpoint an ownership transfer left for the node, else the lifecycle the node holds, else the one its storage keeps, and each branch resumes the incarnation the checkpoint records. A window branch therefore reopens the window its retained rows hold, and a deduplicator or WASM branch resumes the state it checkpointed. The restore reads only the node’s state and the processor’s execution plan, which carries the schema a window’s retained rows are read under, so it does not wait for the domain’s routing, which installation publishes after it starts the tasks.

The entity’s branch-aggregate metric checkpoint also retains exact bytes for its revision, including elapsed metric fields. Capture, persistence and bulk transfer agree on those bytes while the handoff gate is held, and the recovering owner retains them until its next dirty metric revision. See checkpoint synchronization.

A restore installs every branch or none. It builds each branch and opens its retained state first, starts the branch tasks only once all of them are built, and releases a transferred checkpoint only after that. When a branch cannot be built, because storage cannot be read, a checkpoint does not decode, or the bounded executor refuses the decode, the task installs nothing and logs failed to restore processor branches; their input waits for the next attempt at warn with the cause. It tries again after a backoff that starts at a quarter of a second and doubles up to thirty seconds. Until an attempt succeeds:

  • The processor dequeues no input. Its relays keep their records and apply backpressure upstream, so no record starts a new lifetime for a branch the restore resumes and discards its retained state. Input a drain still delivers is negatively acknowledged.
  • A lifecycle checkpoint request is refused, so a backup capture or an ownership handoff that needs one fails instead of recording a lifecycle without the processor’s branches. A WASM guest-state reset is refused the same way.
  • A task that replaces it receives the branches it never installed, and restores them itself.

A failure that persists, such as a checkpoint written in a shape the node no longer reads, keeps the processor waiting and repeats its warning on every attempt.

Checkpoint Identity

A restart reopens a runtime-state checkpoint only under the identity the committed schedule publishes for its entity. Kafka domain offsets and the metric summaries behind DESCRIBE output depend on no schema: they are keyed by their entity alone and survive a restart whatever schemas changed while the node was down. Every other checkpoint, including deduplicator, window, and materialized relay state, branch lifecycle records, and WASM guest state, is keyed by the fingerprint of the schemas its entity lays records out by, and WASM guest state also by its generation. Window state additionally includes the current window model in its identity, so a replacement that changes WIDTH, STEP, or aggregate expressions begins with an empty window even when its schemas are unchanged. A checkpoint written under a replaced fingerprint is never restored as the new layout, served, replicated, handed over, or selected by a forced recovery. Applying the committed schedule of a running domain removes it. Until the node has applied a schedule that names an entity, it has no fingerprint for that entity’s schema-bound state and does not place that state at all.

Recovery also validates the current representation before decoding its counts. Registry Model frames and the dedicated consensus database identify their fixed-width 64-bit count shape; unrecognized stored state fails with an instruction to recreate it. Window checkpoints use the current runtime-state kind and NVXWIN64 frame signature. Native decoding of an archived count is checked and cannot truncate it to fit the target. Consensus recovery accepts only the records its state writer stores, each under its own canonical key and in a state its transitions reach, and fails on anything else with the same instruction. See Archived Counts and Storage Layout And Compatibility.

Interrupted Snapshot Installation

Installing a consensus snapshot publishes the manifest and a marker naming the generation whose records still have to replace the state machine, then replaces those records and clears the marker. A node that stops anywhere between the two finds the marker on its next start and redoes the replacement in full, however far the interrupted attempt had got. It publishes no recovered state until that replacement finishes. Sections staged before any manifest was published never become active; startup marks every generation the current manifest does not name as unreferenced, and a later durable consensus batch removes it. See Transfer And Installation for the section, marker, and final-publication boundaries.

Replayed Forced Recovery

A completed forced recovery is recorded durably against the ownership transition itself, not against the process or schedule that executed it. Reapplying that retained schedule after a destination restart or a later domain rebuild is therefore a no-op rather than a second recovery, and any newer checkpoints the destination has published since are preserved. The WASM guest-state generation the recovery published is part of that committed schedule, so reapplying it names the same generation and starts no further lifetime.

Abandoned Preparations And Staging

A new leader reconciles durable handoff preparations after a coordinator or participant is lost, as described above. Resource uploads that were staged but never promoted are removed at startup, so an upload interrupted by a forced ending leaves no partial version behind. Only the store’s own staging directories begin with a dot: a resource whose name does keeps a directory whose name writes that dot as %2E, so startup never removes its versions. See Resource Versions And Bindings.

A backup archive a node retains for download is a temporary file in its staging area and is never durable. Stopping the node, gracefully or not, loses it: a later download is refused, while the backup’s recorded outcome stays retained under its execution reference. See Backup And Restore.

Unpublished restore checkpoint namespaces remain in the runtime database after an interrupted installation. Every restarted node’s admitted maintenance borrows its applied consensus revision before taking the checkpoint installation barrier. It retains selected publications, applying installations and generations ahead of catch-up; without an applied log it retains all generations. Terminal or superseded applied attempts are reclaimed in bounded deletion batches, including chunks without receipts. Terminal teardown cancels the maintenance caller, and its storage job checks cancellation between bounded units. A later startup resumes from the remaining keys. The same owner reclaims selected-namespace chunks made unreachable by ordinary or replica checkpoint replacement, ownership recovery or purge, including after restart. It keeps the selected publication and every currently referenced segmented revision. Reclamation preserves snapshot readers and never completes an installation or opens its START gate. See restore checkpoint storage for quotas, metrics and physical storage limits.

Domain Time

The paced mapping, lifecycle generation, and authority fence recover from consensus state. Tick progress, delivery loops, local read watermarks, and process-monotonic deadlines do not. After a restart, nodes install the committed mapping and the leader reconciles an authority among the current live voter incarnations. Because the authority identity includes the process incarnation, a restarted process with the same node name cannot act as the authority its predecessor was. See Domain Clock.

Observability

Shutdown is observed through its log, not through a dedicated metric family. There is no shutdown-specific metric; a node that is shutting down is identified by its phase log lines and by terminating: true in its status.

The phase records are the primary signal:

LevelMessage
infotermination signal received; requesting graceful shutdown
infotermination signal received while graceful shutdown is already in progress
infoadvertised terminating process incarnation
infoshutdown admission phase finished
infoshutdown drain-support phase finished
infoshutdown terminal-teardown phase finished
warnrepeated termination signal received; abandoning graceful shutdown
warnshutdown deadline expired; abandoning graceful shutdown

Drain decisions and failures are logged beside them: stopping-node drain lost leadership; remaining work needs the next leader, preserving operator cordon across graceful shutdown, no live schedulable replacement node remains; admitted work completes in place, timed out reaching the leader before requesting a graceful shutdown drain, drained local node before graceful shutdown, failed to drain local node before graceful shutdown, the leader did not answer the graceful shutdown drain of the local node, timed out moving scheduled work off the local node before graceful shutdown, cleared shutdown drain cordon before graceful shutdown, failed to clear shutdown drain cordon before graceful shutdown, the leader did not answer the release of the shutdown drain cordon, timed out clearing shutdown drain cordon, and the two drain-timeout records above. The drain and cordon records name the leader that acted, which is the node itself when it leads, and carry the leader’s account of each moved unit as message; timed out clearing shutdown drain cordon carries the timeout the release waited. Each phase record carries its outcome, so outcome=Completed distinguishes a finished phase from an abandoned or forced one.

A source instance that stops before its transport confirmed positions it read records source instance stopped before its transport confirmed positions it read; the transport presents them again at info, with the domain, the ingestor, the instance and the count of positions.

Runtime teardown records every domain task it had to abort, at warn: shutdown task exceeded grace period; aborting carries the domain, the task’s entity as ingestor, its task_kind (scheduled node, scheduled emitter, generator, reingestor, or scheduled node placement) and the grace_period it outlasted, and relay owner task did not stop cleanly and relay state task did not stop cleanly carry the domain, the relay and the reason. The generators and the reingestors of a domain that outlast their shared grace are recorded at the same moment, one record for each.

Existing metric families move during shutdown without naming it. Interconnect stream resets count reason="shutdown". The ingestor quiesce families change as intake stops, but they carry no cause label, so a shutdown hold is not distinguishable there from another hold. Live branch instances fall without incrementing the eviction counter, because a stopping node is not evicting branches. A client ingestor’s endpoint logs ended the producers of a client ingestor at info with the count and reason shutting down, its gauges fall to zero, and the batches it answered while draining are counted under nervix_client_ingestor_submissions_total as draining refusals or as the outcomes their roots resolved to. See Metrics And Observability.

The health endpoints do not describe shutdown. /livez answers while the process is alive, and /readyz reports whether the node currently knows a leader. Neither turns negative when the node starts terminating, so an orchestrator that removes endpoints on readiness failure will not remove a draining node on that signal alone. Closing the public listeners in the stop-admission phase is what ends client traffic.

Troubleshooting An Incomplete Drain

Start from the outcome on each phase record.

  • shutdown terminal-teardown phase finished absent, process exited 130 or 143. Something sent a second signal. Give the node its full shutdown timeout, or raise the supervisor’s grace period so it does not signal twice.
  • shutdown deadline expired, exit status 1. The node could not finish in its shutdown timeout. Read the drain-timeout record for the domain counts, and raise --shutdown-timeout together with the deployment grace period if the workload legitimately needs longer.
  • local graph drain timed out with admitted work outstanding. Read the counts. Non-zero publishing emitters or buffered emitter messages mean a sink is not accepting; non-zero outstanding acknowledgements mean an ACK chain has not resolved; non-zero pending REQUIRED WAIT records are expected and do not hold the drain.
  • local graph drain timed out before confirming that no admitted work is still moving. A peer is still publishing into this node’s relays. Drain or stop the upstream node first.
  • timed out reaching the leader before requesting a graceful shutdown drain. The node observed no leader before its drain timeout. It still completed its local drain; its scheduled work is reassigned by failover once it is gone.
  • failed to drain local node before graceful shutdown. The leader moved only part of the node’s work. Its message names each unit and why its move failed; those units fail over once the node is gone.
  • the leader did not answer the graceful shutdown drain of the local node. The request did not come back from the leader the record names, and error says why, such as a leader that left the cluster or a connection that could not be opened. The leader may still have moved some of the node’s work; whatever it did not move fails over once the node is gone.
  • timed out moving scheduled work off the local node before graceful shutdown. The drain timeout or the shutdown deadline ended the wait for the leader’s answer. A leader on another node still finishes the moves it began; whatever is not moved fails over once the node is gone.
  • timed out clearing shutdown drain cordon, failed to clear shutdown drain cordon before graceful shutdown or the leader did not answer the release of the shutdown drain cordon. The release of the cordon the node’s own drain set did not complete, so the drain-support phase reports Abandoned even when drained local node before graceful shutdown shows that every unit was handed over. Read raft.cordoned_nodes through another node. When it no longer lists the node, the release committed after all; when it still does once the cluster has a leader, the node takes no new placements, and UNCORDON NODE <node_id> clears the cordon, before or after the node starts again. See Releasing The Drain Cordon.
  • Work reappears after a restart. That is redelivery, not duplication of committed work: the drain ended before those records were acknowledged, so their source offered them again.

Deployment Grace Periods

A supervisor must allow a node to finish its own shutdown. Every deployment should set its grace period longer than the node’s shutdown timeout, so the node exits on its own terms rather than being killed partway through a drain.

DeploymentSettingValue alongside the 50s default
docker run--stop-timeout60
Docker Composestop_grace_period60s
KubernetesterminationGracePeriodSeconds60

Docker’s own default of ten seconds is shorter than the default drain timeout and would kill a node mid-drain. When you raise --shutdown-timeout, raise the deployment grace period with it; when the grace period ends first, the supervisor sends SIGKILL and the node loses whatever the drain had not yet completed.

Raising --drain-timeout above --shutdown-timeout does not extend the drain, because every drain step is also bounded by the shutdown deadline. Raise both together.

Retained Drain Dependencies

Running source, generator and sink tasks retain their acknowledgement trackers and confirmation counters. A drain observes those same registrations. Emitter retry state remains publishing work even when its buffer is empty. Idle and already-claimed force-flush participants use a retained readiness hint; only an available obligation enters the coordinator to claim its generation. A released claim becomes available again. Task teardown removes its confirmation/status registration, and pooled sink teardown removes its wait registration; cancellation clears a pending borrow’s wait through its guard. Handoff watches register before reading their retained entity publication, and release publishes thaw before waking them.

Integration Test Lifecycle

Paced Interconnect Fixture

The rate-limited follower catch-up scenario starts a TCP byte pacer before restarting a stopped node. Its listener takes the existing advertised endpoint and forwards opaque bytes to a private interconnect listener on the restarted node. The advertised endpoint stays fixed so retained peer routes also reach the pacer. Its port remains reserved for the test run, as for other retired test endpoints. The host, TLS identity, resolver policy and product deadlines stay fixed. Each accepted connection uses a 1024-byte inbound buffer, delays each chunk by its configured byte rate before forwarding, and forwards responses promptly. A scenario-owned task set retains and reaps connections. Cancellation or fixture drop closes the listener and aborts all its connections; the fixture cannot survive its node specification.

The scenario starts the follower once without the harness’s catch-up retries, records append stream opening baselines before that start, and waits for the applied NSPL domain backlog within its explicit assertion bound. The opening count is a diagnostic observation because normal initial catch-up probes can also open streams. An atomic maximum of bytes forwarded on one connection proves that the paced path carried more than one production progress interval. This observation does not inspect TLS or frame bytes and establishes no ordering the product relies on.

The Cucumber scenario suite runs Nervix the way an operator does. It starts clusters, drives them through the session API, brokers, and HTTP endpoints, and asserts on what they do. Many scenarios run at once in one test process, each with nodes of its own, so the harness around them is a concurrent system with failure modes of its own: a node that never becomes ready, a status request that never gets a reply, a node that never stops, a step that never returns.

This chapter defines the harness’s lifecycle contract: how a node, a scenario, and the whole run start, how the harness observes them, how it stops them, and which deadline bounds each of those steps. Three rules hold throughout.

  • Every lifecycle operation has one owner and one absolute deadline, fixed when its phase starts. A retry, a poll, or a cleanup inside the phase receives only what is left of it.
  • Every ending has a typed outcome. A node task is running or ended in one of four ways, a readiness probe reports what it observed, and a scenario publishes the phase it is in. No harness path reports an unknown reason.
  • The run ends itself before the CI job limit and leaves the job time to upload the diagnostics it produced.

The harness is test code outside the layer order: it may name any layer, and no product code names it. It observes product behavior and never redefines it. Product deadlines stay with the chapters that own them: node shutdown with Shutdown And Recovery, node-to-node communication with Cluster Interconnect, client requests, redirects, reconnects, and exact recovery with Client Session Protocol, and command identity and completion with Command Completion.

What Runs Where

Before starting ordinary CI producers, Check verifies its exact tested merge and every changed path. A verified documentation-only selection excludes scenarios with the other ordinary suites; validation and title checks remain enabled. This exclusion records a source decision rather than scenario execution, and cancellation of a required run never supplies successful evidence. Explicit mode labels retain their own selections. Documentation-only Selection owns the allowed paths, immutable event snapshot and retained CI selection record.

The suite is the scenarios test target, tests/scenarios.rs, running the features under tests/features. Its lifecycle owners live in tests/common.

ElementWhere it runsOwner
Scenario steps and hooksOne runner task: Cucumber polls every running scenario, and the suite watchdog around them, from the task the binary’s main thread blocks ontests/scenarios.rs
Scenario admission and timingFeature permits, prioritized run slots, queue reasons, and accumulated attempt times in the runner processscenario_schedule.rs and scenario_phase.rs
In-process nodesOne Tokio task per node on the binary’s multi-threaded runtime, which has one worker thread per CPUThe cluster fixture, tests/common/cluster.rs
Server processesChild processes executing the nervix-server binaryThe server-process fixture, tests/common/server_process.rs
Real-process clusterThree server children with separate durable stores, ports and identities under one test certificate authoritytests/common/server_process_cluster.rs, using the server-process fixture
Deadlock diagnosticsIn a scenario binary built for the deloxide mode only: one deadlock detector for the whole process, installed by the binary’s main, and a diagnostic run for each server child, each recording evidence in a directory of its owntests/scenarios.rs; the @deadlock_diagnostics steps, tests/deadlock_diagnostics
CLI sessionsChild processes executing nervix-cli. One-shot commands and the subscribe and domain-clock subcommands run on pipes; the subscription reader retains at most 256 output lines and the clock reader retains at most 2,048. The interactive REPL runs on a pseudo-terminal, whose fixture retains the newest 256 KiB the terminal displayedThe scenario world, tests/scenarios.rs; the terminal fixture, tests/common/cli_terminal.rs
Test dependenciesContainers started on first use and shared by every scenario of the runnervix-test-environment, through tests/common/dependencies.rs
HTTP receiversTasks on the binary’s runtime, one listener and one task per connection, owned by the scenario that started themThe HTTP receiver fixture, tests/common/http_receiver.rs
Injected holdsOne task on the binary’s runtime per sequence of holds a step injects at controlled gates, aborted when the step drops ittests/common/injected_hold.rs
Synchronous step workOne task on the runtime’s blocking pool per job a step hands off the runner task, such as reading, verifying or rewriting a backup archive or inspecting a stopped node’s database; the step awaits the job and fails with its panictests/common/runner_offload.rs
Runner-lag observerA tick polled beside the whole run on the runner task, which logs every stall of that task of one second or moretests/common/runner_lag.rs, wrapped around the run in tests/scenarios.rs
gRPC receiversTasks on the binary’s runtime, one listener, one task per connection and one per call, owned by the scenario that started themThe gRPC receiver fixture, tests/common/grpc_receiver.rs
Broker observersOne task on the binary’s runtime per observer, which reads the broker’s client and hands each message to the step over a bounded channel and is aborted when the scenario drops the observer. A core NATS observer has no task of its own, because its client already buffers what the server deliversThe cluster fixture, tests/common/cluster.rs, and the NATS observer, tests/common/nats_observer.rs
Client probesA child process per probe of another language, or one blocking task for the in-process probe of the shared Rust binding, owned by the scenario that started itThe client probe fixture, tests/common/client_conformance.rs

A scenario binary built for the deloxide mode, which just test-deloxide, just test-deloxide-order, just test-deloxide-stress or just test-deloxide-stress-restore builds, starts its deadlock diagnostics in main, right after it configures the lifecycle of its test dependencies and before it builds its runtime, so its one detector is installed before any tracked lock or runtime worker exists and every in-process node shares it; the helper process that holds a dependency container open starts its own. It records its evidence in NERVIX_DEADLOCK_EVIDENCE when that is set. A binary whose diagnostics cannot start exits with status 4 before any scenario runs, and the first active deadlock any of its tracked locks reports ends the whole run with status 3, its description on standard error. A server child started with the fixture’s deadlock evidence option records its own evidence under its root, and is a diagnostic node only when the binary beside the scenario binary was built for the mode. The ordinary suite leaves the @deadlock_diagnostics scenarios out by default: their steps assert a detector an ordinary build does not have.

The diagnostic lane also selects the ordinary @restore_installation, @client_ingestor_alter_drain, @memory_pressure_pause, @client_io_03_consumer_restore, @client_io_03_generation, @udf_column_builder, @vhost_tls_rebinding and @inferencer_branch_batches scenarios and the local @deadlock_reports inspection/export/triage workflow. They exercise the blocking applied-state guard through interrupted checkpoint staging, complete publication and runtime handle clearing, including a delayed coordinator after leadership transfer and a successor’s START, the memory-pressure pause of starting and running ingestors, Rust client consumers restored after a cluster restart and closed by a domain stop, a Roto function’s column builder, the HTTPS listener’s TLS configuration as a VHOST’s bundle is rebound under HTTPS ingestion, and a batched inferencer’s ONNX session across interleaved branches. The report tool is an ordinary local executable, even when the scenario process selects order analysis. Potential findings remain recorded without ending the process; any unreviewed workload finding prevents diagnostic qualification. Real server children and the scenario process record the same compile-time/runtime selection. The ordinary CLI built by the test dependencies drives these diagnostic nodes through its public protocol; the lane supplies its path explicitly because the diagnostic binary has a separate build directory. The feature input is a quoted file glob, and the lane requires every tagged scenario run to execute and pass without retries. The recorded evidence covers tracked blocking locks reached by those workloads; async waits, capture atomics, dependency locks and cross-node waits retain their other concurrency checks.

The @restarted_voter_observation scenario runs in both selections, reaching whole-cluster teardown, reopening each node’s store, and automatic scheduling after the first relayed voter heartbeat. Its watch-channel barrier and concurrent fault map are untracked; the diagnostic evidence covers blocking locks reached by restart and scheduling. The native metadata restore scenario also runs in both selections after its Kafka replication stream and acknowledgement boundaries are repaired, retaining complete primary and replica metadata comparisons and distributed placement checks.

The measured restore and backup steps sample each node’s public bulk and restore-metadata executor gauges every 20 milliseconds while the ordinary CLI runs. They also record the harness process’s jemalloc allocated and resident bytes. The executor measurements separate the fixed checkpoint I/O and publication grant from retained archive descriptions, typed native values and planning allocations. Process measurements include every in-process node, dependency fixture and harness allocation; isolated one-node and three-node runs provide attributable comparisons. Sampled peaks are observations, not continuous maxima, and allocator resident bytes exclude mappings outside jemalloc. The scenarios also compare bulk refusal counters before and after each restore, and a measured backup’s check compares them and every node’s sampled bulk peak with that node’s published bulk budget. The large-native-metadata oracle stops the cluster after public restore and exact replay, then opens each node’s database through the production snapshot reader. It compares every lifecycle key, timestamp, incarnation, revision and Kafka topic/partition/offset on primary and replica copies. Those complete-value inspection allocations occur outside the restore measurement. The runtime’s testing-only inspection API returns decoded current metadata; the harness owns all archive comparisons and assertions. The scenario takes the CLI’s execution reference and replays it through the public session before and after restart, comparing the complete typed restore report. It saves the restored work’s public owners and replicas before restarting, requires distributed placement in the three-node case, and compares every saved placement after START and after receiving isolated output for every tenant. A failover that drops replicas or recreates guest state cannot make this oracle pass by moving all execution onto one node.

just test-native-metadata-restore-repeat runs that scenario’s three-node example ten consecutive times at concurrency four with zero retries, preserving its normal harness deadlines. It stops at the first failure or a summary that does not contain exactly one passing scenario. Every attempt keeps its source revision and patch, each process output and the available node log under target/native-metadata-restore; its completion record is written only after all ten pass.

The complete-generation fixture allows 120 seconds for every post-restore input to reach its raw relay. Its forty one-mebibyte guest saves exercise sequential source acknowledgements and durable replica checkpoints beside the other diagnostic workloads. The full active diagnostic selection reached 77 of 80 inputs at the former 30-second bound, while the exact same binary reached all inputs and preserved every branch in the focused four-case replay. This condition ends as soon as the exact expected count is observed; it retains the complete output, state and branch assertions.

The large materialized restore fixture prepares its rows through an acknowledged client producer. One round has a 120-second budget and one in-flight batch, including explicit replays of reported processing failures. Each replay replaces the same materialized keys with the same complete values, so partial effects retain the fixture’s value oracle. Every attempt and terminal result is logged; an unknown outcome, final admission refusal or expired round fails preparation. The producer closes after every tenant’s batch has completed, before the scenario checks all generator rows and takes the backup cut. Source admission, input delivery and the final archive and restore assertions remain separate observable steps.

The lane then selects @paced_simulation_reopen from the public paced-driver feature. It builds the Rust driver in the lane’s selection and supplies its path to the same scenario fixture, so the driver installs its own detector before entering its runtime. Both drivers exercise endpoint replacement, pending submission ownership, explicit replay and replacement refusal against diagnostic nodes on one and three nodes, without retries. Python uses the ordinary shared binding; its locks and condition variables are outside this detector.

The buffered-route replacement scenario accounts for every reading in the first run’s outcomes and explicitly replays unresolved ledger entries. Ending a producer can refuse a pending or already planned submission even when its replacement opens in the same START generation. The scenario requires every planned reading to have exactly one effect after replay; each invocation’s summary counts only that invocation, including an empty replay when no reading was refused.

The lane’s diagnostic owner tests each start their detector in a fresh process before any store, publication or executor lock: the state store’s staging, snapshot views, queued writers, interrupted publication and bounded cleanup, and the materialized publication lifetimes. Each must pass its libtest accounting and record exactly one qualifying process observation in both diagnostic selections. The tracked locks’ conformance checks of nervix-primitives run the same way, each in a fresh process in both selections. They record no evidence file, because the primitive crate sits below the crate that owns evidence: a finding reaches a sink that aborts the check, and the lane fails on that ending.

Test dependencies start through one suite-owned environment. If Docker creates a named container but cannot bind its randomly selected host port, that owner removes the failed container and tries up to three more times with a new port selection. It also removes the failed container when those attempts are exhausted, so a scenario retry does not collide with its name. Other startup failures reach the scenario directly.

The OpenTelemetry Collector dependency exposes its stdout and stderr to scenario assertions through one follower that the suite-owned environment starts with the container. The follower streams both outputs from the container’s start into an in-memory transcript of at most 1 GiB per output, and every scenario observes that transcript without the environment’s lock or another read of the container’s logs. A Docker logs read answers with the output as it stood when the read began, and the Collector’s output grows for the whole run, so no assertion re-reads it. The environment stops the follower before it tears the container down. A batching scenario reads the Collector’s debug exporter output to check the number and order of records in each received export request, using unique test markers to distinguish simultaneous scenarios sharing that container.

The debug exporter prints each export as two log entries: a summary that counts the export’s records, and then the export’s body. The Collector prints each entry whole, but an entry of a concurrent export can print between a summary and its body, so a summary does not identify the body it counts. The batching scenario therefore reads each export from its body alone. The body’s first line names its signal, and each log record, span or data point in it starts a line of its own, which is what the scenario counts. The logger prints the entry’s fields after the body’s last line, so a body is read only once its fields have arrived. The suite’s Collector configuration turns off the sampling of the Collector’s own logger. By default that logger prints only the first ten entries that share a message in each ten-second interval, and every hundredth after that, so it drops the summaries of exports that arrive close together.

The raw session fixture records domain-clock replies and frames in the order it reads them. A wait for a detach reply can queue ticks that arrived before the reply; the post-detach assertion checks the recorded wire order, clears those already queued frames, and waits for any new frame. An unread pre-reply tick therefore does not masquerade as delivery after detach.

The generator cadence assertion groups equal logical timestamps across concrete branches and checks increasing timestamps within each branch. It allows records from separate branch tasks to arrive in either order, as their delivery has no shared ordering contract.

tests-deps builds the CLI, the NSPL formatter, the paced simulation’s Rust driver and the shared C binding its Python driver loads in the normal target directory. The full and focused client coverage recipes build a standalone instrumented CLI beside their instrumented server binary and place the normal NSPL formatter there. The scenario runner selects the covered CLI through NERVIX_TEST_CLI_PATH, so its one-shot completion and command paths contribute to the same LCOV report as the CLI’s binary unit tests and the server’s public scenarios. The general coverage-scenarios and coverage-scenarios-append recipes provision the same instrumented CLI and formatter, so a selection containing CLI scenarios needs no separate binary setup. The focused CLI process coverage recipe exercises transaction inspection and the clock-following process scenarios.

test-scenarios-coverage, coverage-scenarios and coverage-scenarios-append also build the paced simulation’s Rust driver and the shared binding with coverage flags, and the scenario runner selects them through NERVIX_PACED_SIMULATION_PATH and NERVIX_CLIENT_LIBRARY. A driver process inherits the profile location of the scenario binary that starts it, so the Rust driver and the binding loaded by the Python driver write their profiles into the same report when they exit.

The CI scenarios job uses the shared native CI linker for the server, CLI and scenario harness, including their instrumented builds. Coverage flags do not replace Wild linker selection.

The suite has one pool of run slots. Its size is the number of CPUs times the concurrency factor, set by NERVIX_TEST_CONCURRENCY_FACTOR or --concurrency-factor and 1 by default; --concurrency sets an absolute slot count. The CI scenarios job uses factor 3, so its 16-CPU runner has 48 slots. The instrumented inventory consumed 78,433 slot-seconds with 115 attempts still active, almost the entire 78,720 slot-seconds that 32 slots could supply in the unchanged 41-minute suite budget. The additional slots use the observed CPU and memory headroom; their complete-suite qualification retains runner load and the actual margin rather than assuming that work stays constant as concurrency changes. Cucumber may take up all parsed scenarios without charging a slot. Admission grants feature capacity and a run slot in one decision, after both are available. A scenario holds both through the end of teardown. The active-scenario diagnostic names the feature limit or run slot a waiting scenario needs. No scenario runs alone or prevents unrelated scenarios from starting.

The coordinated WASM state-reset feature runs one scenario at a time. The web console REPL, execution graph, transaction inspector, and domain clock features share a limit of two. Both limits affect only their named features. The parser takes these limited features up before the bulk of the suite, and available run slots favor a queued limited successor over queued ordinary work. The web console group grants its two slots to the feature with the fewest prior grants, so one feature cannot keep the other three waiting behind its whole queue. Their chains can therefore make progress throughout the suite rather than after it. Cucumber retries a failed scenario twice; --retry 0 turns retries off for a focused run. The before hook reads the attempt kind from its active-scenario registration. Each feature queue keeps retries ahead of first attempts, and admission selects an eligible retry before any first attempt, including a limited feature’s first attempt. A retry still needs its feature capacity and a run slot together: one waiting for a full feature group leaves free slots available to other work. Ordinary and WASM-reset queues retain FIFO order within an attempt kind; the web-console group retains its feature fairness. The finite retry count bounds this priority; first attempts cannot be kept waiting by an unlimited retry stream. Cancelled waiters return both capacities before dispatch continues.

The CI jobs divide the work at the scenario boundary:

JobWork
testsInstrumented workspace build and all tests except the scenario target
scenariosInstrumented server, CLI and paced simulation drivers, the unsharded scenario suite at factor 3, and scenario logs
coverageAfter tests, scenarios and extra tests, just coverage-merge builds the one repository-relative ordinary workspace report from their artifacts and fails on an incomplete one. just check-coverage then scores the repository’s Rust sources against that report with the pinned cargo-crap, which the job builds through kache, and fails only when the analysis is vacuous: no CRAP score fails the job. The retained artifact and an advisory patch line coverage comment, which carries the CRAP section for the changed files, read the same report
extra-testsNative coverage collectors plus capability doctests, Miri, mutation, compiler, and primitive conformance checks
shuttleOnly for a pull request labeled shuttle: modeled in-process concurrency checks, uncontrolled-nondeterminism rechecks, and failure schedules
loomOnly for a pull request labeled loom: exhaustive memory-ordering models and failure checkpoints
loom-qualificationOnly for a pull request labeled loom: two shards of the Loom weakening qualifications, one weakened server build for each, and the runs of a failed qualification
deloxideOnly for a pull request labeled deloxide: the diagnostic lane of each Deloxide selection side by side, the two stress selections included: its diagnostic builds, probes, owner tests and tagged one- and three-node scenarios under the native coverage collector, the supervision qualification, and every attempt’s record and evidence

The checks, tests, and scenarios jobs use Go 1.26 and check out the source before installing their pinned Go formatters. This makes the installer and the later cache publication and report scripts available when a tool setup step fails. The installer retains every attempt in a go-tool-bootstrap-<job> artifact and retries only a checksum database transport error within its eight-minute budget. The publication and report steps check for their scripts before calling them, so a failed checkout does not add a second failure that obscures the original one. The jobs’ existing deadlines and test budgets are unchanged.

The tests and scenarios jobs also sample runner CPU utilization and steal time every five seconds. Every kache-backed job uses kache 1.2.1, records doctor output without making it a test failure, publishes a cache report, and diagnoses its five most expensive misses with why-miss. The shared S3 cache keeps executable and test-binary outputs, with stores sized for the full builds: every kache-backed job and Docker image build uses a 1 TiB store ceiling. This is an upper bound, not a disk reservation. The runner’s actual disk capacity remains the practical limit.

The shared remote is Cloudflare R2 through its S3 API. Kache reads the base artifacts prefix first. Pull-request jobs also read and publish entries, manifests, and shards under the shared artifacts-pr prefix, configured in the daemon’s TOML file. This lets successive PR runs reuse their builds. Kache lets only a protected-branch push publish to artifacts, and no workflow runs on one; scheduled and manually dispatched native jobs retain kache’s read-only policy. Docker image builds run kache inside BuildKit, which does not see the GitHub Actions environment, so their builder publishes to artifacts. Fork PRs still need credentials to access R2. Kache’s doctor displays the generic read-only CI policy even when the PR prefix is enabled; the job’s daemon reports its actual write mode, which the start step prints and publication records.

CI configures the cache bucket through the required repository variables KACHE_BUCKET (bucket name), KACHE_BUCKET_REGION (S3 region), and KACHE_BUCKET_ENDPOINT (S3 service URL), and reads its credentials from the repository secrets KACHE_BUCKET_ACCESS_KEY_ID and KACHE_BUCKET_SECRET_ACCESS_KEY. Each kache-backed job passes them, as kache’s KACHE_S3_* environment variables, to python3 scripts/kache_remote.py start right after the setup action. Docker image builds pass the same settings to their builder, whose build command runs scripts/kache_remote.py configure before it starts the daemon. Either way the service URL is written into the daemon’s TOML configuration before startup.

For Cloudflare R2, set KACHE_BUCKET_REGION to auto and KACHE_BUCKET_ENDPOINT to https://<account-id>.r2.cloudflarestorage.com, without the bucket name in the URL. The bucket name belongs in KACHE_BUCKET. scripts/kache_remote.py rejects URLs with a bucket path; supplying the full bucket URL can make the remote cache appear empty.

Cache Publication

scripts/kache_remote.py owns the remote of every kache-backed job: the check.yaml, Bolero, benchmark and build-book jobs, and the builder stage of every Docker image build.

kunobi-ninja/kache-action installs kache, sets RUSTC_WRAPPER, the store and a job-scoped runtime directory, and stops the job’s daemon in its post step. It receives no S3 inputs, so its post step neither saves a manifest nor pushes. The Start kache remote step that follows it:

  1. writes the daemon’s configuration, $RUNNER_TEMP/kache-remote.toml, with the bucket, region, service URL, base prefix and pull-request prefix. The credentials stay in the environment.
  2. exports KACHE_CONFIG, the credentials, and the job’s KACHE_MANIFEST_KEY and KACHE_NAMESPACE to the job’s later steps. A matrix job’s key includes its matrix value, so every build variant keeps its own manifest and shards.
  3. starts the daemon, and fails the job unless kache --json daemon status reports it running with that file and kache --json stats reports that the file names s3://<bucket>/artifacts. A daemon that has not answered yet gets three polls, two seconds apart. The step prints whether the daemon publishes uploads or, under kache’s read-only policy, skips them.

During the build the daemon publishes each entry the build stores: kache records a durable upload intent, uploads the entry in the background, and retries an upload that failed for a retryable reason every five seconds.

Publish kache cache runs scripts/kache_remote.py publish whatever the job’s result: just before the kache report where the job publishes one, and as the job’s last step otherwise. For a job whose work succeeded, it saves the build manifest with kache save-manifest, then reads the daemon’s upload counters from kache telemetry write every two seconds until no upload is queued or in flight. It waits at most 120 seconds, its kache commands included. The step’s 4-minute timeout-minutes only backstops that bound. Every kache answer it reads is a versioned kache document, and an answer outside its schema is reported rather than read. The outcome is one of:

OutcomeWhenWhat remains
publishedThe manifest is saved and the daemon reports every upload of the job finishedNothing
incompleteThe budget ended with uploads queued or in flight, the manifest failed or did not finish, an upload failed for good, or the daemon stopped answering during the waitQueued uploads go to the daemon’s shutdown drain, which kache bounds at 30 seconds
read-onlyKache made the job’s remote read-only, as for a scheduled or manually dispatched runNothing: such a job publishes nothing
not-runThe job’s work failed or was cancelled, so publication returns at onceQueued uploads go to the shutdown drain
unavailableThe job has no kache command or no manifest key, or its daemon does not answerWhatever a stopped daemon left queued: publication asks kache --json daemon status first because kache stats would start a new daemon
cancelledThe step received SIGINT or SIGTERMThe record names the phase the step reached

Publication never changes the job’s verdict. Every outcome exits 0 except cancelled, whose step ends with the signal’s status, as the runner asked. incomplete, unavailable and cancelled also add a workflow warning. A job that failed before its checkout has no script to run, so its step adds only a notice. The record, publication.json and publication.md, lands in $RUNNER_TEMP/kache-report, which the kache report artifact uploads where the job has one, and in the job summary. An image build runs publish --job-status success as the last command of its build stage, which reaches it only when every artifact has been built.

No job runs kache sync --push. Kache’s push-only sync lists every key under both prefixes before it decides what to push. On 2026-10-14 and 2026-10-15 that listing grew from 650,859 to 690,780 keys, took six to eight and a half minutes in every kache-backed job, and found nothing the daemon had not already published.

Product Deadlines And Harness Deadlines

Two kinds of deadline meet in every scenario.

A product deadline is configuration a node enforces on itself: its shutdown and drain timeouts, its command retry validity and transaction idle timeout, its node-unavailability and election timeouts. Test clusters start with test values for some of them. A scenario about one of them configures it, drives the node to it, and asserts the outcome the owning chapter defines. No harness deadline shortens a product deadline while the scenario can still observe it.

A harness deadline bounds how long the harness waits: for a status reply, for a node to become ready, for a cluster to stop, for the whole run. Its expiry changes nothing inside a node. It ends a wait with a typed failure, takes apart a receiver or a child process the harness owns, or ends the run. It never ends a node. A node’s task is only the future the node was started as: the node spawns the tasks that hold its databases, its listeners and its peers, and they would outlive an abort of that future with nothing left to join them. A node that has not stopped when a deadline passes is therefore left to end by its own shutdown, keeps what it holds until it has, and is recorded as still stopping, not as something the harness ended.

The cluster health scenario can fail application-health responses for one ordered probing-node and responding-node pair while other probes remain healthy. It can also hold one node immediately before that node reports a runtime revision prepared, wait until the hold is reached, and release it after asserting command completion. These are per-scenario injected conditions; their waits have harness bounds and do not shorten the product’s completion deadline.

An injected hold is the opposite case: a product deadline races it. The scenario holds a controlled operation at a gate for a workload duration, counted from the moment it observes the gate, and the node keeps its own deadline running while the operation waits. The backup wait scenarios hold one domain’s quiesced cut for 12 seconds and the next one’s for 14 seconds, each inside its own 20-second cut budget. The scenario’s margin is the part of the budget the hold leaves, and a runner task that another scenario’s synchronous step stalls for longer than that margin would lengthen a hold timed on the runner task until the product deadline expires. tests/common/injected_hold.rs therefore waits for each gate, holds it for its duration and releases it on a task of its own, and the step that injects the holds only awaits that task. A stalled runner delays when the step learns of a release, never the release itself. The step still watches the controlled operation, and a command that ends before its gates were held fails the step with the command’s own outcome.

The boundary between them is kept in four places.

  • Ordinary commands. The NSPL commands a scenario runs go through the production Rust client, with its execution identity, redirects, and reconnects. This includes the transaction qualification graph’s setup commands, which retain each execution reference if leadership changes while setup is applying. When a command document has server statements after a subscription, the harness sends those statements through the client and opens a raw session for the subscription on the node requested by the step. A standalone subscription stays on that requested node, including after failover. The large materialized backup workload submits each graph statement through the production client so a leadership change during its many relay definitions recovers that statement by reference; its Row subscription opens on a raw session after the graph is ready. A leader setup step whose other commands need one session, because they create a subscription or open a transaction, runs them over a raw wire session; when leadership moves before one of them reached the leader, or after the leader admitted it and before its outcome was known, the step sends that command again with the same execution reference to the current leader, as a client must, and goes on there, within 60 seconds per command and only while the session holds no subscription or transaction yet. The paced simulation’s published graph is loaded through one native client so its BEGIN/COMMIT state and command references survive an interrupted finalization. Raw wire probes retain the protocol’s received dispositions. A paced-driver file fixture writes only the scenario’s ledger or effect file before the driver starts. The published driver reads that file, and the scenario checks the process exit and the occurrence count of its terminal cause. No harness deadline shortens native commands. Only the status path described below is harness-owned, and it is a separate test-only boundary: it opens its own session and never redirects, reconnects, or retries by itself, so it adds no second client policy.
  • Cleanup. Scenario cleanup runs after the scenario’s assertions, so it spends the harness cleanup budget rather than waiting for a product shutdown deadline. A scenario whose subject is shutdown, drain, or deadline expiry stops its nodes itself, inside its body, under the product deadlines it configured.
  • How nodes stop. The harness stops an in-process node through its shutdown coordinator, with the same stop request a first SIGTERM makes to a server process, and the node runs its shutdown phases under its configured deadline. Registering termination signals, the forced-exit supervisor, and exit statuses belong to the process boundary, which only the server-process fixture crosses. See Shutdown And Recovery. A real-process cluster can deliver SIGKILL to all three voters before waiting for any child to exit. On restart it launches all existing stores before awaiting readiness, so no one voter is required to answer without the persisted quorum. Each node must then report the same leader and all three voters through its public status endpoint. It can also fault one voter while the other two keep the quorum: SIGKILL, after which a step waits for that child to exit and checks the signal, or SIGSTOP, which freezes the process with its connections open, so its peers hear nothing from it and see no reset until SIGCONT lets it run again. A killed voter restarts from its own database and ports, and the step then waits for the same leader and three voters as a whole restart does. While a voter is killed or frozen, the fixture asks only the running ones which node leads. The restarted-voter observation fixture stops every in-process node before arming its fault. It blocks direct gossip between the first leader and one voter, then holds that voter’s first relayed heartbeat while allowing the other voters’ heartbeats to advance. The leader’s reconciliation starts after that sample arrives, so slow harness startup cannot consume the product’s observation grace. A one-shot barrier captures a scheduling pass inside that grace with the voter marked unavailable; its elapsed-time decision is captured before the barrier waits. The scenario checks the failure-detector verdict and final ownership through public cluster status. Releasing the barrier waits for that pass to finish before restoring heartbeats and application health. While the heartbeat is held, peer startup waits for listeners; Raft catch-up is checked after release, so the fixture never waits for progress its own fault prevents. This is an in-process scheduling regression; immutable-image external Chaos runs retain the real-process crash and restart evidence. The durable follower catch-up fixture creates client writes while the restarted follower applies its backlog and samples commands memory throughout that interval. Once the backlog is applied, stopping the writer finishes its admitted command before counting completed writes: the response can arrive after all voters have already applied that command. The fixture still checks the storage-derived catch-up bound, append-stream limit and commands-memory budget. Its initial burst of domain creation also uses one production client, retaining each command’s execution identity across a leadership change during finalization. A raw session’s recoverable disposition cannot end the fixture before follower catch-up starts. A listener bind failure retries only that listener family: the gRPC pair, HTTP, HTTPS, observability, web console, or interconnect. A readiness timeout may still move all listeners within the same startup budget. A changed interconnect address remains reserved while a peer may dial it. Metrics sampling follows the actual observability endpoint before the next launch. The durable catch-up scenario deliberately occupies the stopped follower’s observability port until cleanup of its first startup attempt, so a metrics bind collision cannot silently turn the measured catch-up into an interconnect address and membership change. The planned Kafka handoff scenario installs its subscription and emitter stall, publishes and flushes both branch records, and then starts intake. Both records are therefore available to its first parallel batch. The source host waits for that batch’s attached ACKs before polling the next batch; separate publications after intake starts do not establish one batch. just coverage-harness-liveness <filter> measures the ordinary startup and other harness owner regressions with counters of their own, as the diagnostic lane section describes. A patch that also changes product code passes the retained lcov.info to just coverage-patch as another --report, beside the workspace report.
  • Test defaults. An in-process node’s shutdown timeout defaults to four minutes rather than the product’s 50s, which leaves the bounded shutdown phases scenarios configure by default room to finish, so only a scenario about the deadline reaches it. A server process runs with the product defaults unless its scenario passes an option such as --shutdown-timeout.

Deadline Ownership

Every lifecycle operation of the harness is bounded by exactly one of these deadlines. Each budget is a constant in the module that owns it, under tests/common unless named otherwise; where a second module runs the operation, it is named after the owner.

OperationOwnerDeadlineWhen it passes
One status request: connect, open a session, send, receivestatus_request.rs10 seconds, or what is left of the enclosing phase when that is lessThe request fails naming the operation that was still pending
A status wait: leadership, voters, a consistent leader, interconnect status, applied index, a status fragmentstatus_request.rs, run by cluster.rs40 seconds from the start of the waitThe step fails with the elapsed time, the last status text, and the last typed failure
Status snapshots of every node, for teardown or for a failure messagestatus_request.rs, run by cluster.rs10 seconds for the whole clusterEach node keeps its status, its typed failure, or the operation its request was in
Readiness of one startup attemptnode_startup.rs, run by node_liveness.rs36 seconds, inside the node’s startup budgetThe attempt fails naming the node, attempt, elapsed time, task state, and last readiness outcome
Cleanup after a failed startup attemptnode_startup.rs5 seconds, inside what the startup budget has leftThe node keeps its task, and the startup ends rather than launching over a node that is still stopping
One node’s startup, every attempt includednode_startup.rs84 secondsThe startup fails with the typed record of every attempt
The node startups of one cluster constructionnode_startup.rs, run by cluster.rs84 seconds per node, shared by the whole constructionThe node that ran out ends the construction
A node a scenario stops itselfcluster.rs, recorded by node_stop.rsThe longest of five minutes, the configured shutdown timeout, and the configured drain phasesThe step fails naming how long the node had been stopping and the shutdown phase it was in, and the node keeps its task and everything it holds
Scenario cleanup of a whole clustercluster_teardown.rs60 seconds for every node togetherNodes still stopping are left to end themselves, keep their storage and ports until they have, and are recorded as still stopping
Stopping a scenario’s HTTP and gRPC receivershttp_receiver.rs and grpc_receiver.rs, run by tests/scenarios.rs6 seconds for every receiver together: 5 for its connections, 1 for its accept loopStill-running connections, then the accept loop, are aborted and joined and recorded as forced
An HTTP receiver wait: captured requests or a recorded faulthttp_receiver.rs, run by tests/scenarios.rs60 seconds from the start of the waitThe step fails with the captured count, the fault count, and the latest fault
A gRPC receiver wait: captured callsgrpc_receiver.rs, run by tests/scenarios.rs60 seconds from the start of the waitThe step fails with the captured count, the fault count, and the latest fault
A server process’s readiness, exit, or log lineserver_process.rs120, 120, and 60 secondsThe step fails, quoting the last 80 lines of the process log
Convergence of a started or restarted real-process cluster, or of one restarted memberserver_process_cluster.rs120 seconds, with each status request capped at 10 secondsThe step fails with the last status or typed request failure from every member
A one-shot CLI command or a streaming output assertiontests/scenarios.rs60 seconds for a command, 30 seconds for a subscription line, the duration the step names for a clock line, between 10 and 60 seconds, and 10 seconds for clock-process exit after Ctrl-CThe step fails with the process result or retained output lines
Text the interactive CLI is expected to display, its startup banner includedcli_terminal.rs, run by tests/scenarios.rs60 seconds, pressing Enter every 250 milliseconds so the REPL draws a prompt and prints the events it queued; a row expected from repeated HTTP posts gets 2 seconds after each post within the same 60The step fails quoting the newest 40 lines the terminal displayed, with control sequences removed and repeated lines collapsed
The interactive CLI’s exit after the scenario types exitcli_terminal.rs, run by tests/scenarios.rs60 secondsThe step fails with the exit status or the elapsed wait, quoting the same transcript
One draw from the port poolport_pool.rs65,536 consecutive draws that land on reserved portsThe draw fails with the pool exhausted
The whole scenario runsuite_watchdog.rs41 minutes, injectableEvery active scenario is reported, live nodes get a 60-second cleanup window, and the process exits 124
Stopping the test dependencies after the runsuite_watchdog.rs2 minutesThe containers are left to the runner
Dropping the runtime after the runsuite_watchdog.rs, run by tests/scenarios.rs60 secondsBlocking tasks still running are abandoned

Everything else a scenario’s body does, including its NSPL commands, broker and metric waits, and assertions, keeps whatever bound its step declares. Some of those steps check their bound only between requests, so a request that never returns outlasts it; the suite budget bounds them in every case. It is also the only bound on the time a scenario spends queued for its concurrency permits and on closing the browser during cleanup, neither of which has a budget of its own. Browser cleanup explicitly awaits the Playwright driver’s shutdown before dropping its handle. The driver’s own five-second exit grace is asynchronous, so cleanup keeps the shared scenario runner and its watchdog available rather than waiting in Playwright’s blocking destructor.

Absolute Phase Deadlines

A phase deadline is one instant on the monotonic clock: the moment the phase started plus its budget. Operations inside the phase receive that deadline rather than a fresh timeout, so the phase ends when its budget says it does, however many requests, retries, or polls it made.

  • Nesting. An operation inside a phase that may take at most a budget of its own gets whichever comes first: that budget from now, or the phase’s deadline. A status request inside a status wait, a startup attempt inside a node startup, and a node startup inside a cluster construction are all nested this way.
  • Bounding. Running an operation within a deadline ends it when the deadline passes. An operation that begins after the deadline has passed does not run at all, so a stream of immediately ready work cannot keep a phase alive.
  • Polling. A poll repeats an attempt until one output is accepted or the deadline passes. Every attempt receives the same deadline, the pause between attempts never sleeps past it, and on expiry the poll returns the last output it rejected and the last failure it saw.
  • Owned work. A wait for something its owner must keep, such as the join handle of a node task, borrows it for the length of the wait and never takes it. Whether the wait finishes, runs out of time, or is cancelled from outside, the owner still holds the handle, so no path leaves a node’s task running with nothing able to join it.

The deadline keeps its start and its budget rather than their sum, as the product’s shutdown deadline does, so a budget of any length cannot overflow the clock.

Deriving A Budget

A harness budget is derived from named policy inputs rather than written as an independent literal. Each input is a measured duration, the slowest healthy value observed while the whole suite ran at the CI concurrency factor, or a headroom factor that multiplies one. Every derived budget is checked against the budgets around it by assertions evaluated at compile time, so a change that breaks an ordering fails to build rather than producing a harness that outwaits itself.

BudgetDerivationMeasured input
Status request, 10 secondsThe 40-second status wait divided by the four requests it must outlast when none of them repliesThe slowest healthy reply, 3.7 seconds, times a headroom of 2 fits in one request
Status snapshots, 10 secondsOne request timeout, because every node is asked at onceNone of its own
Startup attempt, 36 secondsA policy inputThe slowest healthy node startup, 24.2 seconds, fits in one attempt; the same 3,465 startups had a 2.0-second median and a 4.3-second 99th percentile
Node startup, 84 secondsTwo full attempts, each 36 seconds of readiness, a 5-second cleanup slice, and a 1-second pauseStays under a 90-second ceiling
Cluster cleanup, 60 secondsThe slowest healthy cluster stop, rounded up to 15 seconds, times a headroom of 412.2 seconds at the slowest over 163 cleanups, with a 0.15-second median and 1.6 seconds at the 90th percentile
Suite, 41 minutesThe 60-minute scenario job limit, less 14 minutes allowed for pre-suite work and a 5-minute reserve after itSplit-job runs took 11m37s and 10m08s before the suite, then 21m49s and 20m26s for the suite; the 22-minute ceiling leaves at least 15 minutes of slack

The assertions keep these orderings, among others:

  • A status request is materially shorter than the waits and startup attempts that repeat it, and one attempt outlasts three readiness requests that never reply.
  • Status snapshots are shorter than the waits whose failures they explain.
  • A startup attempt outlasts the slowest healthy startup, a cleanup slice is short beside the attempt it ends, and the node startup budget stays under its ceiling.
  • The cluster cleanup budget is shorter than the watchdog of a node a scenario stops itself, and the test shutdown timeout lies between the default shutdown phases and that watchdog.
  • The suite watchdog’s cleanup window fits inside the reserve, and so do the dependency stop and the runtime shutdown together.

The 22-minute observed suite ceiling plus 15 minutes of slack fits the 41-minute budget with four minutes to spare. The second split-job run passed in 20m26s with seven retries and 94.3% run-slot utilization. Increasing either measured input eventually fails the compile-time assertion. Measure both again whenever the suite, its concurrency, or the runner changes.

Status Requests And Status Waits

The harness reads cluster state with SHOW CLUSTER STATUS;. A status request opens a new connection to the node’s session endpoint, in plaintext or over TLS trusting the test certificate authority when the node serves its session API over HTTPS. It opens a session authenticated as the default test user, sends the command with a request identity, no domain, and a fresh execution reference, and reads the session’s frames until the reply naming that request identity arrives, keeping the session’s request stream open so the node never sees the session end while it answers.

The request has one deadline: 10 seconds, or what is left of the phase that sent it. Its four operations run in order, each within whatever that deadline has left.

OperationWhat it waits for
Endpoint connectionThe transport connection, including the TLS handshake when the node serves HTTPS
Session establishmentThe node accepting the authenticated session
Command sendHanding the command to the open session
Response receiveThe reply naming the command’s request identity, past any unsolicited server events that arrive first

A node that never completes an operation holds the request in it until the deadline: a listener that never accepts leaves a TLS handshake waiting, a node can accept the connection and never open the session, and a node can take the command and never answer it, or answer only with unrelated events.

A request ends in one typed failure: the deadline passing, naming the operation still pending and the budget; an endpoint or TLS authority that could not be configured; a command frame that could not be encoded; a connection, session, or response-stream error carrying the transport’s status; a session that ended before the result; a frame from the node that does not decode, or a reply to the command that is not a command result; or, when the caller needs the status text, an unsuccessful command result with its disposition, message, and diagnostics. Client diagnostic scenarios inspect the failed disposition and source span from that result, so the assertion uses the delivered diagnostic rather than parsing the displayed message.

A status wait polls one node every 200 milliseconds until its parsed status satisfies a condition, within 40 seconds from the start of the wait. The waits the harness performs are a current leader, a specific leader or a leader other than a given node, a Raft state, the expected voter set, an interconnect status to a peer, an applied index at least the leader’s, and a fragment of the status text. Two waits read every node at once: a leader the running nodes agree on, and a consistent leader, which requires every node to name the same leader, that leader to report itself Leader, and three consecutive polls to agree. Every request of a wait receives the wait’s deadline, so a wait whose last request never replies still ends at its original deadline rather than one request timeout later. A wait that expires fails its step with the elapsed time, the last status text it rejected, and the last typed failure it saw. The meaning of the connected interconnect status these waits read belongs to Cluster Interconnect.

The stopped-voter DROP NODE scenario waits for the public status report to show that the voter is no longer visible in gossip before removing it, then requires the exact remaining voter set. Application health can report an unavailable voter while gossip still sees it live, so an unavailable verdict does not establish membership-removal readiness. Scenarios that exercise application-health failure classification assert that source under their own controlled health fault.

The correlator match-policy scenario observes each left input’s received counter on the correlator owner before posting the next independent input. An HTTP acceptance alone does not order execution across ingestors. Its two-minute correlation window accommodates those bounded readiness waits without expiring the records whose match policy it asserts. The Kafka handoff scenario that pauses after entity-gate engagement uses a test-configured two-minute gate lease so its broker-delivery assertions and pause release fit inside the handoff budget under concurrent suite load.

Collection and route-flush timing assertions measure observed row arrival from the HTTP publication instant. Concurrent branch posts retain one publication origin, and every expected row must match its tenant and sequence and arrive after the declared lower bound, inside the unchanged delivery budget. Starting a new silence window after an HTTP response would reject a valid row whose collection or flush deadline elapsed while the harness awaited that response. The production owner’s timing checks retain the independent branch-deadline assertions.

The MQTT changed-address restart scenario waits for the post-restart marker within its bounded delivery window. Its persistent QoS 1 source may replay the pre-restart marker before that row; broker receipt does not establish that its source acknowledgement reached the broker before the node stopped. The OTEL metric scenario observes one successful export from each concrete emitter owner before checking Collector output. HTTP intake acceptance does not establish sink export, and the per-owner counters retain exact progress assertions for both HTTP and gRPC.

The OTEL Collector content wait keeps its ten-second bound, from the step’s start to the text’s appearance in the followed transcript, counting output printed before the step began. Each look searches only what arrived since the previous look, plus the bytes an occurrence could span across it, and every append wakes the wait, so the text is seen as soon as the follower reads it. No look re-reads the whole output, so a record delivered within the bound cannot be missed by a long read that began before the delivery and ended after the bound. If the bound expires, or the follower stops first, the failure reports whether the text arrived after the wait ended, how far the follower has read each output and how it stopped, the latest 200 Collector lines that name the scenario’s test identifier, and the last command output. A collector timeout alone does not establish that the emitter lost a record or failed to export it; retained diagnostics and a new attempt remain separate evidence.

A scenario step can also read status inside a window of its own, passing that window as the phase deadline. Where the window bounds how long something is watched rather than how long one read may take, every read keeps a full request timeout instead.

The harness session client keeps domain-clock replies and frames in arrival order. When a detach reply ends an attachment, it retires that domain’s unread frames from the pending observation queue while preserving them in the ordered log. Assertions about events after detachment therefore start at the acknowledged boundary, including when ticks arrived while the client awaited the reply.

Node Tasks And Readiness

An in-process node is the server application started as one Tokio task. That task is the node’s root, not the whole node: the application spawns the tasks that run its domains, its consensus, its gossip and its interconnect, and those tasks hold its databases and listeners until the application’s own shutdown stops them. The harness holds the root task through a single owner, which is in one of four states:

  • Not started. No task has been spawned.
  • Running. The task is live, and the owner holds its join handle.
  • Left stopping. The task outlived the wait its owner could give it and was handed to a keeper, which waits for the node to end.
  • Terminal. The task ended, and the owner holds how: a clean application exit, an application error carrying the node’s own report, a panic, or a cancellation, which is how a task ends when the runtime that ran it is dropped.

Running and ended are read from the owner’s state and never inferred from whether an error message exists. Inspecting a task that has finished joins it and keeps its terminal outcome, so a diagnostic never takes the handle a later stop needs. A wait receives its deadline and reports that the task was never started, had already ended, ended within the deadline, was still running when the deadline passed, or had already been left stopping.

The owner never aborts the task. An abort would end only the root future and leave the tasks the node spawned running with its databases, its listeners and its connections to its peers, owned by nothing and joined by nothing: a second launch into the same directory would find the database locked, and the orphaned node would keep working beside every scenario that followed it. A node ends by its own shutdown instead. One that is still running when its owner stops waiting is left stopping: the owner hands the task to a keeper that waits for it to end, and the keeper holds what the node still uses, the cluster’s storage, the node’s seven ports, its consensus fault registration and the cluster’s place in the live-cluster registry, and gives them back only then. A node whose handle is dropped while it runs is asked to stop and left stopping the same way. A keeper prints the end it waited for:

node '<node>' ended <elapsed> after it was left stopping: <terminal outcome>

A node that never ends keeps what it holds until the process drops the runtime.

A readiness probe is one status request, and it reports one of three outcomes:

  • Ready. The node returned a successful command result, or one saying it is not the leader. Either way it answered an authenticated command.
  • Request failed. The status request ended with its typed failure, including the operation a passed deadline interrupted.
  • Non-ready response. The node answered with an unsuccessful result, kept with its kind, message, and diagnostics.

The readiness wait probes every 200 milliseconds within the attempt’s deadline and remembers the most recent outcome. The owner inspects the task before each probe and after it. The wait succeeds when a probe is ready and the task still runs. It fails with node task terminated before readiness when the task has ended, and with startup deadline expired when the deadline passed while the task was running. Either failure names everything needed to tell resource pressure from a deterministic failure:

node '<node>' startup attempt <n> failed after <elapsed>: <failure>; task state: <state>; last readiness outcome: <outcome>

The task state is not started, running, or terminal with the terminal outcome, and the last readiness outcome is the most recent probe or no readiness probe completed.

Node Startup

Starting one node has one advertised worst case, 84 seconds, whatever happens inside it. The budget starts before the first launch, and every readiness probe, every cleanup after a failed attempt, and every pause between attempts receives only what is left of it.

           one startup budget: 84 s, fixed before the first launch
 |<------------------------------------------------------------------->|
 | readiness | cleanup | pause | readiness | cleanup | pause | launch 3  |
 |  <= 36 s  |  <= 5 s |  1 s  |  <= 36 s  |  <= 5 s |  1 s  | remainder |
 |<-------- attempt 1 -------->|<-------- attempt 2 -------->|

The budget pays for two attempts at their full length. A third launch is allowed because a launch that fails at once, such as one whose listen address another process took first, leaves most of the budget unspent; that launch receives whatever the earlier ones left.

After an attempt fails, the harness classifies the failure, cleans up, and records the attempt.

FailureClassification
The harness could not launch the node: an address it could not parse, the test certificate authority, or the node’s database directoryTerminal: the harness’s own configuration, which the next launch repeats
Readiness did not arrive before the attempt’s deadlineTransient, selecting all listeners: the resource pressure the budget exists for
The node’s task ended with an application error binding its gRPC, HTTP, HTTPS, observability, or web console listen address, or starting the interconnect listenerTransient, selecting that listener family: another process took the port first, and its fresh addresses resolve it
The task ended with any other application error, a clean exit, a panic, or a cancellationTerminal: the node decided it from its configuration, its stored state, or its own code

Cleanup requests the node’s stop and waits for its task within a 5-second slice of what the budget has left. A node that never became ready has no drain to finish, so the product’s shutdown deadline plays no part here. A node whose task ended inside the slice gives back its consensus fault registration, and after a transient failure it moves the selected listeners to freshly allocated ports. A bind failure preserves unrelated addresses; a readiness timeout selects all seven ports. The harness pauses one second before launching again.

A node still stopping when the slice passes is never launched again. It still holds its database and its listeners, and it will until its own shutdown has finished, so a fresh launch into the same directory could only fail on the lock its predecessor holds. The node keeps its task, its shutdown coordinator and its fault registration, the startup ends, and whatever stops the cluster afterwards waits for the node or leaves it stopping.

The startup ends in one of five ways, each reported with the node, its budget, the time spent, and the record of every attempt:

  • the last attempt failed for a terminal reason;
  • every launch the attempt limit allows was spent;
  • the budget ran out before another launch could start;
  • the node the last attempt launched had not stopped when its cleanup slice ended;
  • fresh ports for the next launch could not be allocated.

Each attempt’s record names its number out of three, its classification, how much of the budget had been spent once it was cleaned up, its typed failure, and its cleanup: nothing was launched, the task stopped with its terminal outcome, or the node was still stopping at the cleanup deadline. A cleanup that the spent budget left no time for records the node as still stopping too, and that startup ends as the spent budget or the spent attempts rather than as a node that would not stop.

The harness prints every transition to standard error as it happens, so a run heading for exhaustion is legible while it is still going:

node '<node>' startup: ready after <elapsed> on attempt <n>/3; <remaining> of its 84s budget left
node '<node>' startup attempt <n>/3 [<transient or terminal>] failed <elapsed> into the budget: <failure>; cleanup: <cleanup>; <remaining> of its 84s budget left

The budget bounds what the harness waits for, not what the operating system schedules. A runner that stops running the harness’s own tasks for longer than an attempt delays the check that ends it, and the measured startups above include such a runner.

Cluster Construction

A cluster is built one node at a time, bootstrap node first. The construction has one deadline, 84 seconds times the node count, and each node’s startup budget is nested inside it, so no node can outlive the construction. Once a node is ready, the harness asks the other nodes for the current leader and that leader’s applied index within one 40-second lookup, and then waits up to 40 seconds for the new node to apply as far.

Once every node has started, construction waits for a current leader on the bootstrap node. A cluster of more than one node must also converge, and each check is its own 40-second status wait: a current leader on every node, the full voter set, a consistent leader on all nodes, and connected interconnect status from every node to every peer, which is six waits in a three-node cluster. A construction therefore waits up to 84 seconds per node for startups and then up to 40 seconds for each status wait that follows.

The start step logs cluster start requested: with the node count, domain, and test id. When construction fails, the harness stops the nodes it had started the way scenario cleanup does, all of them together under the one 60-second cleanup budget, because a construction that failed has no product shutdown deadline left to observe. It logs cluster start failed: with the error, followed by the cleanup record and every node that was still stopping or panicked when there was one, and fails the step.

The other ways a scenario starts nodes reuse the same budgets. Restarting a whole cluster stops its nodes and builds it again with a fresh construction deadline. Adding a node, or starting one a scenario stopped, spends one 84-second node startup and the catch-up that follows it; adding a node also repeats the convergence checks. A node started while its consensus connectivity is blocked spends the node startup without the catch-up. A scenario whose subject is a node that cannot apply what the leader committed starts it with one attempt’s 36-second readiness and no retry, because a retry would restart a node that had already consumed the failure the scenario armed.

Scenario Phases

A scenario publishes the phase it is entering, so a reader sees the work in flight rather than the last work that finished.

PhasePublished when
queuedThe suite has taken the scenario up, and it waits for the permits that let it run beside the scenarios already running
startedIts steps begin
body completeIts steps have ended and their result is known
teardown startedCleanup begins by releasing the faults the scenario injected
teardown diagnosticsBounded status snapshots and the scenario’s context are recorded
stoppingIts fixtures and cluster nodes are stopped
finishedThe scenario and its cleanup have both ended
queued -> started -> body complete -> teardown started -> teardown diagnostics -> stopping -> finished

finished is published only once cleanup has completed. A scenario holding any other phase has not finished, and a scenario whose log ends at a phase marker is still inside that phase.

Browser assertions that wait for an acknowledged subscription tab to disappear poll for up to 10 seconds. This bounds the server reply and browser update together; on expiry, the step reports the tab text that remains visible.

The active-scenario registry holds every scenario that has started and not yet ended. A scenario registers in its before hook, before it acquires its permits, so a scenario that never gets them is visible while it waits. Its entry holds:

  • its identity: the feature, the scenario name, and the line where it begins, which tells apart the example rows of an outline that share one name;
  • its attempt, counted from one, which is exact because Cucumber takes up a retry only after the run before it has ended;
  • its phase, when it entered that phase, and when it started.

The entry is keyed by a registration the scenario’s world owns rather than by anything the feature file supplies, and it leaves the registry when that world is dropped, whether the scenario passed, failed, or panicked. The suite’s report prints scenarios in order and can keep a finished scenario’s world until earlier scenarios have reported, so a finished entry can still be present when the registry is read.

Entering a phase also appends a marker to the scenario log:

scenario <phase>: feature="<feature>" scenario="<scenario>" line=<line> age=<age> <detail>

body complete carries result= with the body’s result, which is passed, skipped, before hook failed, or step failed with its error. finished carries body= with the same result and teardown= with the cleanup record. Cleanup reports itself separately: a node left stopping and a node that panicked while stopping are recorded beside the scenario’s result and never turn a passing body into a failure.

Scenario Teardown

The after hook runs every scenario’s cleanup through the phases above.

body complete
     |
teardown started      release paused health responses, domain-clock progress pauses, and
     |                command pauses; stop durable catch-up writers and clear consensus
     |                commit delays
teardown diagnostics  every node's status at once within 10 s; the scenario's context
     |
stopping              abort CLI output readers and kill their child processes;
     |                drop HTTP load, held uploads, server processes and process clusters,
     |                and observers;
     |                stop HTTP and gRPC receivers within 6 s; close the browser and the
     |                session;
     |                stop the cluster within 60 s;
     |                release proxies, silent peers, permits, and fixture ports
finished

Teardown Diagnostics

The harness asks every node for its status at once, under one 10-second budget for the whole cluster, so a node that never replies delays cleanup by at most that long and cannot hide another node’s status. Each node’s outcome goes to the scenario log on its own line: its status text, the operation its request was still in when the budget passed, or its typed failure.

scenario teardown <identity>: node=<node> status=<status text>
scenario teardown <identity>: node=<node> status_timeout=<operation> pending after 10s
scenario teardown <identity>: node=<node> status_error=<failure>

A scenario context: line follows with the scenario’s domain and test id, its last command error and output, its last server error, and its last subscription and broker payloads. Diagnostics are best effort: whatever they observe, cleanup continues into stopping. Steps that explain their own failures can collect the same snapshots.

Cluster Teardown

A cluster’s nodes stop within one deadline of 60 seconds for all of them together.

  1. Every node is asked to stop before any node is awaited.
  2. Every node’s task is awaited concurrently under the one deadline. Each wait borrows its task from the node’s owner, so a node still stopping when the deadline passes is still owned.
  3. Once the waits are over, each node whose task ended, whatever ended it, gives back what it holds: its seven port leases and its consensus fault registration.
  4. Each node still stopping is left to end itself. Its task goes to a keeper together with what the node still uses, and the keeper gives that back once the task has ended.

A cluster of three wedged nodes therefore costs one budget rather than three, and no path ends a node or takes anything from under one that still runs. The cleanup record reads stopped <n> of <n> node(s) in <elapsed> of a 60s budget, <n> still stopping. Dropping the cluster afterwards removes the temporary storage its nodes wrote as soon as no node left stopping still shares it. Two more records appear only when they apply:

scenario cleanup unfinished: node "<node>" was still stopping at the cleanup deadline, in shutdown phase <phase>, <the node's own deadline>, and keeps its storage and ports until it ends
scenario live during unfinished cleanup: <identity> attempt=<n> phase=<phase> phase_age=<age> age=<age>
scenario teardown failed: <node record ending in a panic>

A node left stopping is read through its shutdown coordinator before it goes to its keeper, as Scenario-Driven Stops describes, so the record says which of the node’s own shutdown phases the cleanup deadline found it in.

An unfinished cleanup is usually cleanup that ran beside work heavy enough to starve it, so the harness names every scenario live at that moment, and the keeper’s own line later says when the node ended. A node whose task panicked while stopping has no other witness than its record. In the qualification runs that characterized them, cleanups that ran out their budget were nodes of WASM scenarios whose setup had already failed with not-a-leader, and those scenarios passed when retried.

A step that replaces a scenario’s cluster with a fresh one stops the old cluster under the same budget and fails when a node of it was left stopping: that node still runs beside the cluster that would replace it, so the replacement has not happened.

The consensus fault registration keeps an observer of the node’s consensus state, as does every observer a step took from it, so the node’s consensus database stays open after the node’s task has ended until the last of those observers is dropped. That drop closes the database synchronously, on the task that makes it: the close synchronizes the journal and waits until none of the database’s background workers is left. Cleanup and scenario-driven stops give the registration back on the runner task, so every scenario and the suite watchdog wait for that close, while a keeper gives it back on a runtime worker. The close never waits for a worker whose flush or compaction failed; see Terminal Teardown.

What Cleanup Releases

Harness state goes back only after the tasks that used it have ended, so the next scenario never finds a port, a fault, or a proxy taken. What a node left stopping still uses stays with its keeper until that node has ended.

  • CLI output readers, the terminal task of an interactive CLI among them, are aborted and their kill_on_drop child processes are dropped before node teardown, which also removes the REPL’s working directory. Background HTTP load, held uploads, server processes and real-process clusters are also dropped first. Dropping any server child kills it and returns its ports.
  • Broker and syslog observers, HTTP and gRPC receivers, the browser, and the session are closed before the cluster stops.
  • The TCP proxies and silent interconnect peers a scenario placed in front of its nodes are released once those nodes have ended.
  • The ZeroMQ and syslog ports drawn for the scenario return to the pool after their nodes and observers have ended. The scenario publishes finished and then releases its feature permit and run slot, so admission includes all teardown work.

Scenario-Driven Stops

A step that stops a node, stops every node, or restarts the cluster is part of the scenario’s subject, so it runs under the product’s deadlines with a harness watchdog around them rather than under the cleanup budget. The harness requests the stop and waits for the node’s task for the longest of five minutes, the node’s configured shutdown timeout, and its configured drain phases: the application drain when graceful drain is enabled plus the runtime’s branch stop timeout for the configured domain drain. A scenario that configures a longer product deadline raises the watchdog with it, so the harness never cuts a product deadline short. Node-specific stop steps resolve a saved scenario placeholder before selecting the node, including the graceful stop used to exercise ownership handoff during drain.

Once the node’s task has ended, the stop gives back the node’s consensus fault registration, which closes its consensus database as Cluster Teardown describes, and a stop that ended cleanly then checks that the node released its node and consensus database locks. The step fails when the node’s task panicked, when the watchdog passed with the node still stopping, or when a lock outlived the stop. A node still stopping keeps its task, its ports and its fault registration, and the scenario’s cleanup waits for it again or leaves it stopping. Stopping every node requests every stop first and then waits for each node in turn. A scenario can also begin stopping a node without waiting, to observe how the cluster reacts while the node is on its way down; its cleanup waits for it later.

The harness prints every such stop to standard error when its wait ends, as it prints a startup:

node '<node>' stop: ended <elapsed> after its stop request, inside its <watchdog> watchdog: <terminal outcome>; shutdown phases: stop admission <outcome>, drain support <outcome>, terminal teardown <outcome>
node '<node>' stop: still stopping <elapsed> after its stop request, at the end of its <watchdog> watchdog, in shutdown phase <phase>, <the node's own deadline>

The elapsed time runs from the first stop request the harness made of that launch of the node to the moment its task ended. The task records its own ending, so the stops of a whole cluster, which are requested together and awaited in turn, each report how long that node took rather than how long the harness took to reach it. A stop is printed by the wait that observed the ending, and only by that one.

The second line is also the failure of the step. A node that outlived its watchdog is read through its shutdown coordinator, which changes nothing in the node: the phase is the product’s StopRequested, DrainSupport or TerminalTeardown, and the node’s own deadline reads <remaining> before its own shutdown deadline or past its own shutdown deadline. An in-process node has no deadline supervision to end it, so a node read in TerminalTeardown past its own deadline is running the final stops the product lets run to completion, while one read in DrainSupport is still inside a drain its scenario configured. A node that had already been left stopping under a keeper has handed its coordinator over, and its record says so instead.

Ports

Every port the harness binds is drawn from one pool shared by all scenarios in the process. A draw binds port zero on the loopback interface, reads the port the operating system chose, closes it, and reserves it in the pool. The operating system would let a second scenario bind a closed port, so the reservation is the only thing that keeps two scenarios apart. It cannot see sibling worktrees running the same suite, which is why a node startup retries a lost bind on fresh ports.

HolderPortsReturned
In-process node7: gRPC, gRPC over HTTPS, HTTP, HTTPS, observability, web console, and interconnectAfter its task has ended: at cluster cleanup, when a scenario stops every node, and when a failed startup attempt moves to fresh ports
Scenario fixtures4: ZeroMQ ingest and emit, syslog ingest and emitAt the end of cleanup
HTTP receiver1 per receiver, drawn with the scenario fixturesAt the end of cleanup, with the scenario fixtures
gRPC receiver1 per receiver, drawn with the scenario fixturesAt the end of cleanup, with the scenario fixtures
DNS authority1 UDP port per cluster addressed by namesWhen the cluster is dropped at the end of cleanup, after its nodes have stopped
TCP forwarders1 port, shared by every forwarder a scenario stands in front of a dependencyAt the end of cleanup, after the cluster has stopped
Server process6When the process is dropped
A node moved to a new interconnect address1 new interconnect portThe port it gave up stays reserved for the rest of the run

A port returns only once nothing can still dial it. Every scenario names its nodes node-1, node-2, and node-3, so a node of an unrelated scenario that binds a port a peer still dials passes the peer-identity check and is caught only when its introduction fails to verify, which takes long enough to fail a scenario. Keeping every port reserved is not an option either: the ports a whole run draws exceed the range the operating system draws from, because Linux hands a port-zero bind an odd port from the lower half of its ephemeral range, some seven thousand ports.

A draw is synchronous and runs on the runner task. A draw that looped until the operating system offered a port the pool did not hold would stall every scenario and the suite watchdog with it, so a draw that lands on reserved ports 65,536 times in a row fails with the pool exhausted, naming how many ports the process holds, and gives back the ports it had already reserved. A pool with one free port among seven thousand still yields it within that many draws almost every time, and a pool with none ends the draw in about a second.

Server Processes

Scenarios about signals, exit statuses, process startup, command-line values, and open-file limits run nervix-server as a real child process, because an in-process node cannot show whether the process boundary delivers a signal to its shutdown coordinator. The fixture gives each process its own ports, database directory, and interconnect credentials, forms a single-node cluster, and captures its standard output and error in one log. It removes every NERVIX_* variable and RUST_LOG from the child’s environment, so the runner’s configuration cannot silently reconfigure the server. The options and environment variables a scenario gives the process are applied after that removal, written exactly as the scenario states them, so a scenario can hand the node a value it must refuse; such a scenario starts the process without waiting for readiness and asserts how it exits. A claim about a node process dying while its peers keep running, such as what a client producer is told when the node that executes its ingestor or serves its session is killed or frozen, runs the three-process cluster and faults one member, because only a real process death closes, or stops answering on, every connection the node held at once without running any of its shutdown.

Readiness uses the same probe outcomes as an in-process node, probing every 100 milliseconds within 120 seconds, and fails at once with the exit status when the process exits first. Waiting for an exit is bounded by 120 seconds, and waiting for a log line or an HTTP admission by 60 seconds. Those failures quote the last 80 lines of the process log. Dropping the fixture kills the process, so cleanup never waits for a graceful stop and a failed scenario never leaves a server running. What the process does between a signal and its exit is product behavior; see Shutdown And Recovery.

Test Dependencies

The external Chaos directed-rate probe times the sender’s connection and the receiver’s complete byte count through helper-local kernel uptime. A successful observation requires the same kernel boot identity and boot-time offset at both helpers. Namespace identifiers are diagnostic values; they do not identify the clock coordinate. The ten-millisecond clock precision produces a duration upper bound. Its lower bound subtracts two ticks from that upper bound because both endpoint readings can round down. Rate-effect qualification requires the fault’s lower bound to reach twice the baseline’s upper bound. Docker control time remains in separate helper-lifetime evidence and the unchanged ninety-second total probe budget. Missing, malformed or mismatched clocks fail the observation with raw helper logs retained. Direct Docker controls exercise launch delay, actual connection and receiver stalls, byte-count failures, failed helper operations and the real rate effect; they qualify the harness measurement and establish no product recovery outcome.

Brokers, databases, and the other external systems that scenarios use run as containers, started the first time a scenario needs one and shared by every scenario of the run. The run removes them when it ends; NERVIX_TESTCONTAINERS_MODE=reusable, which just test-scenarios-reuse sets, keeps them for the next run instead. Scenarios still provision the topics, queues, tables, and other entities they use explicitly.

The attached- and detached-window Kafka acknowledgement scenarios create their input topic before START and observe the consumer group’s membership and the ingestor’s readiness before injecting the output failure. Their bounded subscription assertions therefore exercise replay and acknowledgement isolation after source initialization, without spending those assertions’ budgets on automatic topic creation and consumer discovery.

The attached-window fixture publishes both rows through one producer and allows a thirty-second partial-batch wait. Collection ends as soon as its two-message ACK batch is full; the window can then produce the output whose failure rejects both records. Separate publication steps can exceed a short collection window, leaving one retained window row waiting for input while the source waits for that partial batch’s ACK. The fixture retains its five-second ACK timeout and exact four-record replay assertion within eight seconds.

The Pulsar broker announces a maxMessageSize of 1 MiB rather than Pulsar’s 5 MiB default, the same limit the MQTT and NATS brokers keep, so one scenario message can exceed each broker’s limit. Its admin API serves the topic-level maxMessageSize policy a scenario sets on its own topic; the step waits up to 60 seconds for the broker to read the policy back, which shows that the policy applies to the next message.

Harness Redis connections use an explicit ten-second budget for connection setup and each command response to tolerate scheduling delay under parallel load. The driver’s one-second connection and 500ms response defaults are too short under concurrent scenario load: the three-node JAQ transformation scenario reached a running ingestor but its publishing client timed out. The budget belongs to the test client used for individual publishes, bursts, and subscriber-count observations. A request failure still fails the step; a publish with an ambiguous result is never retried. The existing bounded subscriber wait only republishes when Redis confirms that the publish reached zero subscribers. Focused harness regressions delay setup and publish replies by two seconds and verify that a silent broker still reaches the connection deadline.

After the run, the suite stops its dependencies within 2 minutes. A stop that does not finish is abandoned and its containers are left to the runner, because waiting without a bound is how a run that already has its result loses it to the job’s own timeout. Dropping the runtime then waits at most 60 seconds for blocking tasks a scenario left parked in a driver.

A container that fails to stop or to be removed does not keep the others from being torn down. Its failure keeps a ContainerTeardownError report with the Docker or Testcontainers cause beneath it, and so does a Ryuk cleanup container the suite fails to remove. The teardown stays typed until the process prints it, on one line: suite dependency teardown failed: and the whole chain of every failure, the failures separated by semicolons. A teardown that failed is not clean, so it fails a run whose scenarios all passed; after a suite timeout it is printed beside the timeout instead.

HTTP Receivers

A scenario about a node sending HTTP requests to an external endpoint starts an in-process HTTP/1.1 receiver in its place, because only a receiver the harness controls can capture exactly what arrived and choose exactly how to answer: a status sequence, a delayed, held, or lost response, a stalled body, or malformed framing. A receiver can serve TLS with a certificate for chosen names and can require the client certificate it issued, whose files a node mounts as a resource. Its response script can generate exact and over-limit header counts and value sizes separately for an interim or final block. It timestamps a request when its complete body arrives, before its scripted response delay begins, so a scenario can assert the measured gap between two requests without racing a window in which nothing should happen.

Three controls keep the remaining HTTP assertions free of timing races too. A Retry-After date is computed when its response head is written, the scripted delay after the current UTC and rounded up to a whole second, so the date always asks for at least that delay however late the request arrives. A response held until released stays unanswered, and so the attempt that sent it unresolved, until a scenario step releases it with a response of its own choice; only the client’s own timeout or the receiver’s stop ends it sooner, so a scenario can observe state that must hold while a request is unresolved without betting on how long a scripted delay lasts. A request for a target the scenario answered by target takes that answer instead of the next scripted response, which gives each request of independent branches or source relays its own outcome whatever order they arrive in. Answering the same target again replaces its answer, so an endpoint that kept failing one request can recover while the scenario watches the retries.

The receiver also records two facts about how its clients treat their answers, each read once the requests it concerns have arrived. It keeps the most requests that were ever awaiting a response at once: a request awaits from its capture until the receiver begins writing its final head, or until its connection ends without one, so a client that sends each request only after reading the previous final head never has two. And it counts the responses its clients abandoned: a held response, a stalled body, or a body still being written whose connection the client closed first. A response body can be generated at a scripted size and is written chunk by chunk, so a body many times the socket buffers of a loopback connection proves that a client which stopped at the final head never read it. A stop of the receiver itself abandons nothing, and an abandoned response is not a fault.

Everything a receiver holds is bounded, and exceeding a bound is recorded as a fault, not captured.

BoundLimit
One request head256 KiB and 512 header fields, room for a request at every HTTP emitter limit at once
One request body16 MiB
Captured requests4,096 per receiver
Kept faults256 per receiver; later faults are counted but not kept
One generated response header value128 KiB, enough to test both sides of the emitter’s 64 KiB header limit
Generated response headers per block512, enough to test both sides of the emitter’s 128-field limit
One generated response body64 MiB, written in 16 KiB chunks and never held in memory
A scripted Retry-After dateOne day ahead, far beyond any scenario’s wait

Every await a receiver connection makes also waits for the receiver’s stop, so a held response or a stalled body ends as soon as cleanup begins. The receivers of a scenario stop together in the stopping phase, before the cluster, under one 6-second budget: 5 seconds for every connection to end on its own, after which the rest are aborted and joined, and 1 second to join the accept loop, after which it is aborted and joined too. The derivation asserts that this budget is shorter than the cluster’s cleanup budget. Each stop is recorded in the scenario log:

HTTP receiver cleanup: <name>: stopped <n> connection(s) in <elapsed> of a 6s budget, <n> forced, <n> panicked; captured <n> request(s), recorded <n> fault(s)
scenario cleanup forced: HTTP receiver <name>: <the same record>

The second line appears only when a connection or the accept loop had to be aborted, or panicked. A receiver’s port is drawn with the scenario’s fixture ports and goes back with them at the end of cleanup, once the nodes that dialed it have ended.

gRPC Receivers

The node trace-export scenarios start one or three real server processes with --otel-enabled, separate service names and the scenario’s DNS configuration. A local gRPC collector captures their OTLP trace requests; the steps decode the current trace schema and require spans from every node and a query for the collector’s fixture name. This uses the process cluster’s existing readiness and failure diagnostics. Processes are dropped before the DNS authority, resolver files and collector, including when a scenario fails. The fixtures use no external collector container.

A scenario about a node calling an external gRPC service, such as an OTLP/gRPC collector, starts an in-process receiver that serves HTTP/2 without TLS in its place. It reads each unary call to the end of its request and captures the method, the header fields, the compressed flag, and the one request message exactly as it arrived. It then answers the call with the next scripted answer: an empty response message with grpc-status: 0, a trailers-only status, no answer at all while the receiver closes the whole connection, or no answer until the client resets the call. Once the script is empty, calls are accepted. The receiver never interprets a message, so a scenario step decodes what it captured.

An OTLP receiver is the protocol-neutral name a scenario uses for either transport: Given OTLP receiver "<name>" is running for "<protocol>" starts a gRPC receiver for grpc and an HTTP receiver for http/protobuf, and one script vocabulary — accept, reject, unavailable, lose response, hold response — maps to the answer each transport gives for it.

A gRPC receiver has the HTTP receiver’s bounds on its message, its captured calls, and its kept faults. A call whose length prefix already declares more than 16 MiB is refused as a fault before its message is read, and a request that is not exactly one gRPC message is a fault rather than a capture. Every await a connection or call makes also waits for the receiver’s stop. The gRPC receivers of a scenario stop together with its HTTP receivers under the same 6-second budget, and each stop is recorded in the scenario log:

gRPC receiver cleanup: <name>: stopped <n> connection(s) in <elapsed> of a 6s budget, <n> forced, <n> panicked; captured <n> call(s), recorded <n> fault(s)
scenario cleanup forced: gRPC receiver <name>: <the same record>

A call that panics takes its connection with it, so the stop counts it among the panicked connections.

Broker Observers

A scenario about a node that publishes to a broker observes the destination through a client of its own: a Kafka consumer, a subscriber of MQTT, RabbitMQ, Redis, Pulsar or SQS, a ZeroMQ socket, or a core NATS subscription. Its steps then assert on what the broker delivered to that client, in the order the broker delivered it.

Those steps run on the runner task, so how an observer hands a message to its step decides how many polls of that task a burst of messages costs. Most observers run one task on the binary’s runtime that reads the broker’s client and hands each message to the step over a bounded channel, and the scenario aborts that task when it drops the observer. A core NATS observer has no task of its own: the NATS client already reads what the server delivers into the subscription’s own buffer on a task of its own, and the step takes each message straight from that buffer. A step that is behind takes as many buffered messages in one poll of the runner task as the poll’s cooperative budget allows.

Under the suite’s concurrency the runner task comes back to a step only after it has polled every other woken scenario. While the NATS observer handed each message on through a one-message channel, every message waited for a poll of its own, and on a loaded runner the NATS emission scenarios drained their 32,768 messages too slowly for their 90-second bound although the observing client had already received every one of them.

The NATS client buffers at most 65,536 messages for one subscription and, as a slow consumer, drops what arrives beyond them, so a scenario keeps the messages its step has not yet taken below that bound.

A step that waits for a sequence of messages and times out names how many messages the observing client had received from its broker, for a client that counts them, so a message that never reached the observer reads differently from one the step had not yet taken.

DNS Authorities

A scenario about how nodes find one another by name selects its cluster’s peer addressing before the cluster starts, with Given cluster peers are addressed by "<addressing>". Every other cluster uses literal IPv4 loopback endpoints, starts no DNS fixture, and lets its nodes load the host’s own resolver configuration, as a production node does.

AddressingEach node listens onEach node advertisesIts name answers with
literal IPv4 endpoints127.0.0.1127.0.0.1No fixture
literal IPv6 endpoints::1::1No fixture
DNS names127.0.2.<n>node-<n>.nervix.testIts listen address
DNS names behind an unreachable address127.0.2.<n>node-<n>.nervix.test127.0.4.<n>, where nothing listens, then its listen address
single-label DNS names127.0.2.<n>node-<n>, completed by the search domainIts listen address
hosts file names127.0.2.<n>node-<n>.nervix.testNothing: the hosts file lists the name and the fixture never answers it

A cluster addressed by names starts one in-process DNS authority on a loopback UDP port drawn from the port pool. The harness writes a resolver configuration with the search domain nervix.test, ndots:1, and one-second queries with one retry, and a hosts file, into the cluster’s directory, and starts every node with that configuration and the authority as its only name server. Every answer carries a one-second TTL, positive or negative, so a zone change reaches a node within seconds of the cached answer expiring. A scenario can move a stopped node to 127.0.3.<n> behind the same name, and answer a node’s name as a name that does not exist, a name with no address, or not at all. A name outside the zone does not exist, with a negative TTL of zero. Every test certificate names localhost, 127.0.0.1, ::1, node-<n>, and node-<n>.nervix.test, so each addressing presents the name its peers expect.

A scenario can also publish a dependency it started under a name in the zone, such as rabbitmq.nervix.test, answered with the dependency’s literal address or with a list of loopback addresses in order, and later answer that name as a name that does not exist, a name with no address, or not at all. A dependency’s published port listens on every local address, so an answer that cannot connect needs TCP forwarders: listeners at chosen loopback addresses such as 127.0.5.<n>, all on one port drawn from the pool, that forward to the dependency and count the connections each accepted. Stopping a forwarder closes its listener and every connection it carried, so a client that still holds a cached answer naming it cannot reconnect there. A scenario can restart that listener on its still-reserved port to let the same client reconnect while its target node stays up; the CLI clock process scenario uses this to observe reattachment after a transport loss without changing the node’s clock installation. An address the scenario names without a forwarder refuses connections on that port. The TLS certificate the harness gives its containerized dependencies names localhost, 127.0.0.1 and *.nervix.test, so a dependency reached through a zone name presents a certificate for the name its client dialled, and a name outside the zone does not match it.

The authority answers from memory and holds nothing else: it counts questions for at most 1,024 names, and a scenario can assert that its nodes asked nothing for their names. It answers until the cluster is dropped at the end of cleanup, after every node has stopped, so no node’s lookup outlives it; dropping it aborts its answer loop, and its port goes back to the pool. How long a node waits for an answer is product behavior, bounded by the budget of the path that asked and described in Bounds, Deadlines And Cancellation; the scenario’s own waits are status waits.

The resolver crate’s focused checks use the same authority, started on an unused port of their own, to observe DNS messages directly: TTL expiry, negative caching, refusals, silence, the lookup budget, the concurrency bound, and runtime teardown. just test-dns runs them. Evidence states what each form of DNS evidence establishes.

Client Probes

The cross-language conformance scenarios in client_conformance.feature run a small client of each runtime against a scenario’s cluster, or against the checked-in conformance corpus. The in-process probe drives the shared Rust binding’s C ABI from a blocking task of the binary’s runtime, because every call of the binding blocks its caller; every other probe is a child process that the fixture starts with its target in NERVIX_PROBE_* variables and its standard input closed. A probe prints one report line per observation on standard output, and the fixture keeps every line it read.

A probe’s waits are bounded twice. The step that starts it waits at most 180 seconds for the line that says its subscription is open or its clock attach completed. A later step can wait, for the duration it names, for one more line the probe prints, so a scenario acts between the probe’s observations: the clock probe prints the first tick of a generation before the scenario stops and starts the domain, and the interruption before the scenario restarts the TCP forwarder the probe entered through. Every line read on the way is kept for the report. The step that reads the report waits the duration the step names for the probe to end, 180 seconds against a cluster and 60 against the corpus. Each probe also ends itself: a binding probe gives up on its rows, or on each stage of the clock it follows, after 120 seconds, and the Go and TypeScript probes give up on their whole run after 170. A failure quotes every report line read so far, the exit status, and the probe’s standard error. Dropping the fixture kills a child process, so a failed scenario never leaves a probe running; the in-process probe ends when its session fails against the stopped cluster, or at its own deadline.

Every example of a runtime other than the in-process probe is tagged @client_conformance_toolchain and one @client_probe_<runtime> tag, and the suite excludes the first tag unless a run selects its own tags, because those examples need toolchains the suite’s job does not install. just test-client-conformance builds every probe artifact and runs them; its first argument is the tag expression that selects runtimes. The client-conformance CI job, which runs only for a pull request labeled client-conformance, runs it on its own runner with a 60-minute limit and a 15-minute suite budget, which leaves the builds before the scenarios up to 40 minutes of the limit and keeps the same 5-minute reserve.

The Suite Watchdog

The scenario run has one budget, 41 minutes from the moment it starts, which --suite-budget or NERVIX_TEST_SUITE_BUDGET replaces with a duration such as 4m, read by the same guarded duration parser as a node’s options. The budget is a clock rather than a count of failures. Cucumber’s fail-fast stops scheduling scenarios and leaves those already running where they are, so it cannot end a run whose step, diagnostic, or node stop never returns; the clock ends such a run at the same instant as one whose work returned at once. Until the budget expires, the suite keeps running its intended failure and retry coverage.

The watchdog reads two registries: the active-scenario registry, and the live-cluster registry. A cluster registers when a scenario builds it. Each node registers when its task is spawned, through a registration the task itself owns, so the node leaves the registry when its task ends, whether it returned, failed, or panicked, and the watchdog can tell a cluster that stopped from one still running without owning either. A cluster stays registered for as long as its scenario holds it or one of its nodes was left stopping, so a node that outlived its scenario’s cleanup is still named. The registry holds each node’s stop, its shutdown coordinator, rather than the node, so the watchdog never reaches into a scenario that is still using it.

When the budget expires:

  1. The watchdog reads both registries before changing anything, so the diagnostic is the state the run expired in, and prints it to standard error at once. If the job’s own limit kills the process during what follows, it has still said what it was doing. The scenario log receives the same diagnostic, with the cleanup outcome, once the cleanup window has ended.
  2. It asks every live node to stop, then waits one 60-second window for all of them together, checking every 50 milliseconds.
  3. It drops the run. That ends every scenario future still held, which kills the server processes the scenarios started and leaves the nodes they still hold stopping.
  4. The suite stops its dependencies within 2 minutes and drops the runtime within 60 seconds, which ends every node task that is still running.
  5. The process prints the dependency teardown and the cleanup outcome, flushes its output, and exits with status 124.
suite timeout: the <budget> suite budget expired with <n> scenario(s) active
  suite timeout active: <identity> attempt=<n> phase=<phase> phase_age=<age> age=<age> nodes=[<nodes>]
  suite timeout unclaimed cluster: <identity> nodes=[<nodes>]; its scenario had already left the registry
suite timeout cleanup: asked <n> node(s) to stop and waited <elapsed> of a 60s window; every node ended itself

When a node is still running at the end of the window, the cleanup line names its cluster instead of reporting that every node ended itself, and the scenario log adds suite timeout cleanup forced: the run was dropped with nodes still running. A live cluster whose scenario has already left the registry is a leak, and the unclaimed line is its only record.

The watchdog does not run after hooks. Faults a scenario injected stay in place while its nodes are asked to stop, so a node held by one can still be running at the end of the window, and it ends when the process drops the runtime. The induced timeout during qualification had one such node, held by a consensus commit delay that only its scenario’s cleanup releases.

The CI Reserve

The suite runs inside just test-scenarios-coverage in the CI scenarios job, after that job’s instrumented server, CLI and paced simulation driver builds. The tests job runs at the same time on another runner. The scenarios job’s timeout-minutes is 60, and the suite budget is derived from it so that the job ends on its own.

For focused local coverage, just coverage-scenarios <lcov-path> <scenario-options> starts a fresh measurement. just coverage-scenarios-append <lcov-path> <scenario-options> retains the current profiles and reuses unchanged instrumented artifacts for another scenario selection. When collecting a different source revision, first run just coverage-clean-workspace so instrumented binaries and line mappings from earlier sources cannot enter the new report. After collecting unit and scenario profiles, just coverage-report-workspace exports all workspace packages, including executed test files, as lcov-workspace.info with the checkout path beside it, and just coverage-merge maps that report onto the repository.

Both CI collectors export lcov-workspace.info from every collected profile, with no package selection, and with --no-default-ignore-filename-regex, and write the absolute checkout path they ran in to coverage-source-root.txt beside it. Test lines stay in these reports, so merging native extra reports cannot make executed library properties appear uncovered because their files were filtered. Executing a crate’s tests does not include that crate in a report whose package selection names only the root package. Filtering happens in the merge, not in the exports: the coverage job runs just coverage-merge over the coverage-tests, coverage-scenarios and coverage-native-extras artifacts, which maps every record onto the files tracked at the tested revision, drops dependency, toolchain and generated records by class, leaves out the native collections of other modes, and fails rather than publish a report that misses an input or a workspace member. It passes that one report to just coverage-patch, so library properties contribute to patch coverage alongside server and client tests. Patch coverage, CRAP scores and comment publication are advisory and never change the build verdict; the CRAP step fails only when its analysis cannot describe the merged report. The complexity verdict is the just ratchet count of functions above cyclomatic complexity 30, which the checks job enforces and which reads no coverage. The PR comment and workflow summary identify the exact comparison and the coverage-merged artifact retains the report, its merge record target/coverage-merge/record.json, target/patch-coverage.md and the CRAP reports target/crap-report/crap.json and target/crap-report/crap.md. The scenario collector also starts coverage in each published Python paced driver using the shared scripts/paced_simulation_coverage startup hook and configuration. It combines those completed process reports into target/paced-simulation/python.lcov. CI merges this scenario report with the deterministic Python open-policy report from the extra checks, alongside ordinary Rust coverage. The STOP/START generation-following outline also carries @paced_simulation_reopen, so both full Deloxide selections reach its Rust and Python drivers on one-node and three-node clusters. The merge reads each producer’s checkout path explicitly when it maps LCOV sources from Blacksmith onto files on its GitHub-hosted runner.

Part of the jobBudgetBasis
Work before the scenario binary starts14 minutesThe first cold kache 0.28.1 split-job run took 11m37s from job start to the binary, and the next took 10m08s; the ceiling adds 2m23s beyond the slower measurement
The scenario run41 minutesWhat the limit leaves
After the budget expires5-minute reserveAt most 60 seconds of cleanup window, 2 minutes of dependency stop, and 60 seconds of runtime shutdown, four minutes in all, and then the log upload, measured at 2 to 3 seconds with 8 seconds of steps after it

A healthy suite finishes inside its budget and exits 0 or reports its failures. A wedged one exits 124 with its diagnostic and leaves the upload its reserve. Either way, a step that runs whatever the job’s result uploads the whole tests/logs directory as the scenario-logs artifact.

A suite that passes ends before its budget, so the reserve then holds the coverage export, the uploads and cache publication, which waits at most 120 seconds. Saving the manifest took 5 to 14 seconds in the scenario jobs measured on 2026-10-14, and the daemon had already published every entry those jobs built. A suite that fails or runs out of its budget leaves publication not-run, so the reserve stays with the cleanup.

The first split-job PR run completed its suite in 21m49s with 88.3% run-slot utilization and four retries. One Raft snapshot scenario failed all three attempts because its setup accepted a purge that preceded the backlog under test. The corrected scenario waits for a purge beyond the measured backlog peak. The next run passed in 20m26s with 94.3% utilization and seven retries, so the 22-minute ceiling and 15-minute slack fit both measured runs.

The job’s limit remains the emergency guard outside the budget rather than the mechanism that ends a wedged run. A job the limit cancels is killed wherever its scenarios are: the logs it uploads end mid-scenario, with no summary and no record of what each scenario was doing. The limit and the harness’s copy of it change together.

A same-commit rerun of the scenarios job replaces that job’s four report artifacts: scenario logs, runner load, kache report, and scenario coverage. This lets a rerun publish its own diagnostics under GitHub Actions’ immutable artifact names. When the coverage job is rerun, its advisory reporter updates its marked PR comment for the same PR head instead of adding a comment.

Runner Stalls

The watchdog is polled on the runner task, so it cannot see, let alone end, a step that blocks that task. The run is therefore polled beside a tick on the same task, due every 250 milliseconds. A step that blocks inside its poll delays the tick until it returns, so how late the tick is polled is how long the task was held. A stall of one second or more goes to the scenario log with every scenario live during it:

runner task stalled for <duration>
scenario live during runner stall: <identity> attempt=<n> phase=<phase> phase_age=<age> age=<age>

The log line is a diagnostic, not a failure: a stall delays every wait and bound the other scenarios measure, and the line says when and for how long. Backup steps hand their archive reads, rewrites, digests and fixture generation, and the inspection of stopped nodes’ databases, to blocking tasks; the native metadata restore scenario, whose archive holds lifecycle and offset records above 32 MiB each, held the runner task for about 30 seconds while it inspected three restored nodes before they did.

Diagnostic Lane

just test-deloxide, just test-deloxide-order, just test-deloxide-stress and just test-deloxide-stress-restore run the Deloxide diagnostic lane: every workload tests/deloxide-inventory.toml registers, in the build of the diagnostic feature its selection compiles, supervised by scripts/deloxide_lane.py. Data-Plane Concurrency owns what the lane proves, its failure classes and its applicability records; this section owns how it runs and how long it may take.

The lane starts after its prerequisites, just tests-deps, and builds each invocation with Cargo before it runs it. The prerequisites provision what the workloads load, as they do for the ordinary suite: the WASM guests, the generated ONNX models and the ONNX runtime library, whose path the lane passes to every invocation. The probes, the tracked locks’ conformance checks and the owner tests are libtest executables, run directly so that a signal reaches the lane as a signal rather than as Cargo’s status. The scenario binary runs the lane’s tagged scenarios without retries. The active-only and order selections run them in two inventory invocations: the restore, client, diagnostic, remote ACK, Roto function, TLS rebinding and inferencer scenarios, then the paced-driver scenarios with the diagnostic Rust driver; the stress selections run the same scenarios in three invocations of their own, described below. Active mode, with or without stress, runs each invocation as one process. Order mode starts a fresh process for each tagged feature in the first invocation; the materialized restore feature runs each scenario separately and its six large examples each have their own process, as do the four complete-generation restore examples, selected by the inventory’s order_tags. The lane checks every chunk’s Cucumber summary against its registered examples, retains its logs and evidence separately, and stops on the first failed chunk. Each process of the active-only and order selections runs the inventory’s fixed number of scenarios at once, four, rather than one per CPU: a diagnostic build pays for its tracked acquisitions on every lock, and a fixed count puts the same load on its nodes locally and on CI’s 16-vCPU runner.

The lane resolves every feature pattern against its repository root before passing it to Cucumber. A relative pattern would make Cucumber traverse the whole working directory, including generated build artifacts. An exact feature uses an absolute pattern with two identical literal alternatives, so the walker starts at that feature’s parent directory and still selects only that file. This bounds discovery without changing the registered examples or their deadlines.

Under the native coverage collector the lane instruments only workspace crates, the scope the ordinary coverage build has. When every crate was instrumented, wasmtime’s compiler included, CI’s WASM restore scenarios ran five times as long as an uninstrumented local run, 338 seconds against 64 for one of them, and its restored runtime revision missed the product’s readiness deadline: the test cluster’s 10-second node unavailability timeout plus the 30-second propagation bound. It missed it at four scenarios at once as at eight, because the time went to preparing the restored WASM processor, not to waiting for a run slot. The product deadline stays as it is; the lane removes the instrumentation the product’s own work never needed. Each invocation records its evidence in its own directory of the attempt, and the binary’s tests/logs are copied into the attempt after it ends, because the next invocation truncates them.

Each scenario invocation passes the binary NERVIX_TEST_SUITE_BUDGET, its bound less the inventory’s teardown reserve, so a suite that runs out of time ends itself with the suite timeout diagnostic and its own cleanup before the lane would stop it. The reserve is six minutes: the four the suite’s own cleanup can take after its budget, with two to spare. A bound that expires anyway ends the binary with SIGTERM and, after the stop grace period, SIGKILL for its whole group; Ryuk removes the containers the binary started when its connection closes. A stall of the runner task itself ends only this way, because the suite watchdog runs on that task, and the binary then prints no Cucumber summary. Unless a tracked lock takes part in the stall, the lane records it as timed-out with no finding: a wait inside a dependency, such as the storage engine’s own worker channel while a database closes, is visible only in a stack of the binary’s threads taken before the bound expires.

In CI the deloxide job, which runs only for a pull request labeled deloxide, runs each selection on its own 16-vCPU runner through just coverage-native-extras test-deloxide, test-deloxide-order, test-deloxide-stress or test-deloxide-stress-restore, then just test-deloxide-qualification for the same selection, and uploads every attempt whatever the verdict.

Part of the jobBoundBasis
Setup before the laneNo bound of its ownToolchain, LLVM, Go, TinyGo and kache installation, as in the other native jobs: 46 seconds for deloxide and 40 for deloxide-order in the measured run
Prerequisites, diagnostic builds and workloads, and the coverage export95 minutes, timeout in the stepThe inventory’s 80-minute budget bounds the diagnostic builds and workloads, and the prerequisites and the export share the rest. The measured run took 25m02s for deloxide and 23m24s for deloxide-order. The prerequisites took 3m05s and 2m46s, the diagnostic builds and workloads 21m28s and 20m11s, and the export 2 seconds in each. Inside the budget, the builds took about 11 and 9.5 minutes, the 55 scenario runs 7m41s and 7m45s at four at once, the 32 paced runs 2m35s in each, and the seven conformance checks less than a second together after a build of 8 and 7 seconds
Supervision qualification10 minutes, timeout in the stepA small probe build and up to seven bounded cases; only the untracked wait runs to its bound, 15 seconds, and the others end in milliseconds. It took 40 and 41 seconds in the measured run
Uploads and the kache reportThe rest of the 120-minute job limitAttempt directories, completion records and LCOV reports, uploaded after either step’s verdict, then cache publication, which waits at most 120 seconds. In the measured run the uploads took 4 and 3 seconds

The table’s measurements come from the CI run of 2026-10-07 in which both selections passed with every owner of tracked locks reached: 110 workloads in deloxide and 118 in deloxide-order, with the lane instrumenting workspace crates only and running 55 scenario examples at four at once. The first run in which both selections passed, on 2026-10-06 with 97 and 105 workloads and 49 scenario runs, took 30m27s and 23m18s in the lane’s step. The six scenario runs and seven conformance checks added between those runs fit inside their variation. A deloxide-order run that included the one-node restore of a deduplicator keyspace and a window above the bulk budget, together with its three-node example, took 21m47s in the lane’s step with 107 workloads and 51 scenario runs before those six scenario runs and seven conformance checks were added. The table predates the four remote ACK examples and the order-mode per-example process split, so its durations are historical measurements rather than a timing claim for the current combined inventory; the inventory and job bounds remain enforced.

The two stress selections compile one build, the active-only build with the bounded disturbance of StressConfiguration::LANE, and between them run every scenario of the active-only selection but the report tool’s triage, one at a time: a stress build enters Deloxide’s process-wide detector twice on every mutex and write acquisition, and when the selections were introduced, with four scenarios in one process, their nodes contended on it and missed startup and request deadlines that a plain diagnostic build of the same scenarios met. Two Stressed Scenarios In One Process records what made them miss. deloxide-stress runs the active-only selection’s probes, conformance checks and owner tests with the disturbance, the probe that proves it, the stress-scenarios invocation of 35 lifecycle scenario runs tagged @deloxide_stress, bounded at 30 minutes, and the stress-paced-simulation invocation of the 36 paced-driver runs with the stressed Rust driver, bounded at 40. deloxide-stress-restore runs the build’s probes and the stress-restore-scenarios invocation of 43 capture, restore and checkpoint-transfer runs tagged @deloxide_stress_restore, bounded at 70 minutes. Each selection has the inventory’s 80-minute budget and its own CI runner.

The split follows from what the disturbance costs. It barely lengthens a scenario, because the lifecycle scenarios wait on product cadences, timers and deadlines rather than on the CPU: in the CI run of 2026-10-09 the 33 runs the stress selection then held took 854 seconds one at a time, against 885 seconds of run-slot time for the same runs at four at once in the active-only lane. One at a time, a stressed lane therefore costs the sum of its scenarios’ durations, and the 44 scenario runs and 36 paced-driver runs not yet stressed took 1,587 and 658 seconds in the same run. One lane holding all of them one at a time would have needed about 52 minutes of scenarios after 11 minutes of diagnostic builds, about 63 of its 80 budgeted minutes, before the variation between CI runs, before the large-state WASM handoff added since, and before any scenario added later. Chunking that lane into a fresh process per feature keeps the sum and adds a process start-up per chunk. Running more stressed scenarios at once in one process, or fewer nodes in each of several concurrent scenarios, is the configuration whose nodes missed product deadlines then. The restore scenarios are the longest family, so they became their own selection: a budget and a runner of their own, and the same build, disturbance and probes. In the CI run of 2026-10-10 that introduced the split, deloxide-stress took 28m41s in the lane’s step and 27 minutes inside its budget: about 9.5 minutes of diagnostic builds, 6m19s for the 35 stressed lifecycle runs and 10m51s for the 36 paced-driver runs, with 30 qualifying evidence files and no finding. deloxide-stress-restore took 39m40s in its step and 37 minutes inside its budget: 5.5 minutes of builds and 31m30s for the 43 restore runs, with no finding. One lane holding both would have needed 58.5 of its 80 minutes, before any variation between runs. The disturbance delays one in twenty nested mutex and write acquisitions by at most 200µs, and the product’s physical and logical deadlines are not widened for it. A stressed workload that misses a product deadline is a finding to reproduce and fix in its owner, not a bound to raise.

The budget is measured on CI’s runner, whose diagnostic builds kache serves warm, and a local run pays its cold builds out of the same budget. On 2026-10-10 a shared 40-CPU host already at a load of 36 to 50 spent 33 minutes building the stressed scenario binary and ran the heaviest restore scenarios two to three times slower than CI, so deloxide-stress-restore expired its budget after 17 of its 43 runs, every one of which passed. Run the stress lanes where the CPUs are not already saturated, or again once their builds are warm; an expired budget ends the lane with status 124, never as a clean result.

Two Stressed Scenarios In One Process

At two stressed scenarios in one process, the restore scenarios failed in two ways the one-at-a-time selections never did: the cluster is restarted found a restored node still stopping at the end of its 300-second watchdog, and node startups spent their whole 84-second budget. Neither was a deadlock among tracked locks. Every evidence file of those runs qualified with no finding, and both failures are a node that could not get through terminal teardown, or through startup, beside the other nodes of its process.

The retained run of tests/features/cluster/backup_materialized.feature at 6c0d23de holds the signature. Its 28 stops by the cluster is restarted took 130.5 seconds at the median and 297.7 at the slowest, 2.3 seconds inside the watchdog, where the plain diagnostic build of the same revision took 0.9 and 39.6. Of its 129 node stops, 69 never reported the end of terminal teardown. Three things made a stop that slow, and CPU contention is what turned them into a failure: the run shared a 40-CPU host whose one-minute load was 47 at the median and between 28 and 75.

  • Every node swept its whole acknowledgement registry every second, and a sweep that overran the second could not be cancelled. The remote acknowledgement silence sweep took a guard for each of a node’s 16,384 correlation positions whether or not the node had forwarded anything, and a stress build enters Deloxide’s one detector twice for each of them. On that host one sweep took longer than the second between sweeps. Its task then found the next tick already due and went from one sweep into the next without waiting, and the cancellation that runtime teardown joins the task with is observed only when the task waits. The run’s first cleanups show it: four nodes that had served for under ten seconds logged nothing through 59 seconds of terminal teardown while the process kept three CPUs busy. The sweep now visits only the positions a node’s deliveries and admissions have claimed, which keeps it far inside its second, and its task returns to its executor after every sweep, so teardown’s cancellation ends it after the sweep in progress even when that sweep overruns; see Terminal Teardown.
  • Nodes the harness gave up on kept running. A cleanup that reached its deadline aborted the node’s root task, which ended that one future and left every task the node had spawned, its sweep among them, running for the rest of the process. A node aborted this way never reported the end of its teardown. The harness never aborts a node now, as Node Tasks And Readiness describes.
  • A stopping domain waited for its generators one at a time. When every node of a cluster stops at once, a generator that is reading its source relay from another node, or publishing to one, waits on a peer that no longer answers, and runtime teardown gave each such generator its own two-second grace in turn. This one is independent of the detector: it is in the plain build’s 39.6 seconds too. The generators of a domain now share one grace; see Terminal Teardown.

The measurements below repeat that run’s configuration on a 32-CPU workstation shared with other builds: the whole feature in the deloxide-stress build, two scenarios in one process, sixteen runtime workers on sixteen CPUs, no retries, and the row, startup, stop and cleanup deadlines unchanged.

BuildOutcomethe cluster is restarted stops, median and slowestSlowest node startup
6c0d23de, retained from the 40-CPU hostEnded by its 3000-second budget with 17 of 18 complete and one failed130.5 s and 297.7 s29.5 s
c39c5388, after the sweep and the harness changed18 of 18 passed in 422 s0.49 s and 36.9 s, the slowest all generator graces2.1 s
With the generators’ shared grace18 of 18 passed in 376 s0.36 s and 10.9 s2.6 s

Without competing load, 6c0d23de itself is as fast here as the current revision: in the feature’s first four examples its restarted clusters stopped in 0.06 seconds at the median and 0.48 at the slowest. The sweep’s cost shows only once the process has to compete for its CPUs. A build of c39c5388 with nothing changed but the full sweep restored was run beside an unchanged one, the process confined to four CPUs that twelve busy loops competed for. The unchanged build passed 18 of 18 in 443 seconds. The build with the full sweep took 1,301 seconds and 1.7 times the CPU time, failed both three-node examples with forty relays when deliveries to a peer timed out during their input rounds, and stopped its restarted clusters in 5.3 seconds at the median against 0.36. 6c0d23de itself, run the same way, matched that build and not the unchanged one: 1,345 seconds, the same two examples failed, one of them with the missing rows of the retained run, and its restarted clusters stopped in 4.5 seconds at the median.

What a three-node stop still costs after this is bounded by the deadlines of the calls its nodes had in flight when the stop began, and each wait is named in the node’s log. A relay owner that is delivering to a peer which has already stopped waits out the five seconds that delivery’s admission is given and the five its cancellation is given. A checkpoint announcement to a replica that has stopped is waited for until its five-second dispatch deadline has passed. A replica’s placement task that is fetching or acknowledging a checkpoint when its owner stops does not observe the stop, and such tasks are aborted one after another, two seconds each. The node’s generators share their two seconds. In Every node stopping at once ends its generators together, the slowest node of the three-node cluster took between 2.0 and 12.0 seconds to stop in ten runs of the ordinary build. Before the generators shared their grace it took between 22.0 and 30.1 seconds in five of six runs, and 8.1 in the sixth, where two of the nodes had no generator waiting on a peer.

The stress selections keep running one scenario at a time: their budgets were measured that way, and two per process remains the comparison below rather than a lane.

For a focused comparison, just test-diagnostic-scenarios <selection> <feature> --name <scenario> --concurrency 2 --retry 0 builds and runs the public scenario in deloxide, deloxide-order or deloxide-stress, with the lane’s prerequisites and ordinary CLI. Its builds share the diagnostic target directory. The recipe resolves its feature file to an absolute path: Cucumber’s relative input glob walks the entire working directory, including build artifacts, before starting any scenario. The absolute input glob matches the exact file in its containing directory. Argument boundaries, including a scenario name with spaces, are preserved. Each attempt retains its output under target/deloxide/focused/<selection>/run.* and rejects an empty scenario selection. It records detector evidence there unless NERVIX_DEADLOCK_EVIDENCE names another directory. Use a fresh evidence directory for each run and retain the standard output, scenario log and suite summary together. The complete lane remains the entry point for inventory accounting and diagnostic evidence qualification.

In this comparison, --concurrency counts scenarios in one process. Their in-process nodes share one runtime and one detector; the runtime still has one worker per available CPU. Record the available CPU count, affinity and host load, and keep them the same for the plain and stressed selections. A failure’s phase matters: missing generator rows immediately after restore and missing rows after a subsequent restart exercise different installation boundaries. Preserve the first failure, the missing relay and tenant identities, and any stop or startup deadline outcome before running a control. A passing control or replay describes that run only.

The oversized materialized-generation assertion preserves its missing relay and tenant pairs, expected round and value length, domain, and subscription node when its row deadline expires. Before teardown it queries SHOW RELAY ... MATERIALIZED STATE on at most three nodes for the first four missing relays. The observations share a ten-second budget, with at most two seconds per query. They keep the placement, branch, round, value byte length and watermarks, without printing the fixture’s large values. Query failure or timeout stays a diagnostic observation; the original exact-row assertion still fails. This observation budget starts after the row deadline and grants no additional time for a missing row to satisfy the assertion.

just test-diagnostic-harness <selection> <filter> runs focused harness regressions in the chosen diagnostic build. just coverage-harness-liveness <filter> measures ordinary harness regressions in a separate target directory and retains its log and lcov.info, including the harness source, in a fresh target/harness-coverage-reports/run.* directory. Both commands reject a selection that completed no tests or left selected tests ignored. These counters measure the harness itself and do not join product coverage or qualify a diagnostic lane. After replaying an instrumented ordinary scenario binary from that target directory without a source edit, just coverage-harness-liveness-report <output> exports the retained profiles without another test invocation.

Public materialized-state inspection can read installed or checkpoint state and can forward an empty non-owner observation to the primary. Record the addressed node and returned placement; rows returned by a replica’s endpoint alone do not prove that replica installed the generation. Correlate the observations with the restore execution report, runtime log and lifecycle snapshot before deciding which publication or delivery owner needs a regression.

A debugger attached to this process suspends every in-process node while it collects thread stacks. Record that suspension separately and retain the attempt as a diagnostic run; its elapsed time and deadline outcomes cannot qualify an uninterrupted throughput comparison. Preserve an unprofiled failure before collecting stacks on a diagnostic replay. Thread stacks can identify workers blocked in a tracked lock or the detector itself, but a suspended async task is not a worker’s call stack. Retain the node’s lifecycle and placement observations alongside the dump when deciding whether the failure belongs to drain, state publication or scheduling contention.

When a step’s timeout expires it sends SIGTERM to the step’s process group. The collector records its attempt as interrupted and forwards the signal to the lane, which ends the process it is running with that process’s whole group, records interrupted in lane.json and exits; the upload steps still run. A failed or interrupted attempt is kept as it is: a later run, or a warm rerun, creates a new attempt and never qualifies the one that failed.

How Failure Reaches CI Output

The job log carries the scenario binary’s standard output and standard error, and the scenario-logs artifact carries tests/logs.

OutputWhereWhat it holds
Cucumber reportStandard outputEvery scenario and step result in order, the world of a failed step, the summary, and every failed scenario repeated at the end
Harness transitionsStandard errorNode startup transitions and outcomes, the stops scenarios waited for, and a suite timeout’s diagnostic and cleanup
Scenario logtests/logs/cucumber.logThe run’s parallelism and suite budget, cluster start requests and failures, the NSPL commands scenarios run, every phase marker, teardown diagnostics and context, unfinished cleanups, and the suite timeout diagnostic
Suite summarytests/logs/suite-summary.md and the CI job summaryLength, budget and remaining margin, run-slot utilization and scenario work, wait time by reason, retries, and the ten longest attempts; captured at watchdog expiry before cleanup on a timed-out run
Runner loadrunner-load-tests and runner-load-scenarios artifactsFive-second CPU utilization and steal-time samples and their mean and peak
Node tracestests/logs/scenarios.logThe trace output of every in-process node of the run

Server processes write their logs into their own temporary directories, and a failure quotes the tail it needs.

FailureHow it surfaces
A step assertionThe step fails with its message; Cucumber retries the scenario and repeats the failure at the end of the report
A status wait that expiredThe step fails with the elapsed time, the last status text, and the last typed failure
A node that never became readyThe step fails with every attempt’s record, and a failed construction logs cluster start failed:
A teardown diagnostic that failed or timed outThe node’s status_error= or status_timeout= line, and cleanup continues
A node a scenario stopped that outlived its watchdogThe step fails with the node’s stop record: how long it had been stopping, the shutdown phase it was in, and whether its own deadline had passed
A cleanup that left nodes stopping, or a node that panicked while stoppingscenario cleanup unfinished: lines naming each node’s shutdown phase, or scenario teardown failed: lines, beside an unchanged scenario result, and the keeper’s ended ... after it was left stopping line on standard error
A scenario that never endsThe suite timeout diagnostic names it with its attempt, phase, phase age, and nodes
An exhausted port poolThe step that needed a port fails with the pool’s report

Exit Status

EndingStatus
Every scenario passed and the dependencies stopped cleanly0
The run finished with failed steps, parsing errors, or hook errors, reported as a panic naming each count101
The run finished but its dependencies failed to stop or were abandoned101
The suite budget expired124
A deloxide build could not start its deadlock diagnostics4
A deloxide build reported an active deadlock and recorded it3
A deloxide build failed output/recording, exceeded a diagnostic budget, or lost findings/context4
A deloxide-order build retained a potential cycleWorkload continues; unreviewed evidence cannot qualify, and the diagnostic lane fails

124 is the status timeout(1) reports. It differs from 0, from 1, and from the 101 a panic ends with, so a wedged suite is told apart from a passing or a failing one by its exit status alone.

Adding A Harness Operation

The append-setup regression observes each live source’s test-only setup counter before issuing a burst of public domain creations with one voter stopped. It checks that both survivors apply the burst, that attempts to the departed voter fit OpenRaft’s existing 200 ms retry series plus two boundary attempts per source, and that the returning voter catches up. Its count bound uses the whole observation’s elapsed time, so parallel-suite delay does not tighten the assertion. A zero attempt observation fails rather than claiming that pacing was exercised. The counter shares the probe’s existing map with successful opens and is absent from the ordinary production recorder.

A new operation that starts, observes, or stops something on the harness’s behalf follows the same contract.

  1. Find its phase. An operation inside a phase, such as a startup attempt, a status wait, or a cleanup, takes that phase’s deadline as a parameter and nests its own budget in it. Only a new phase starts a deadline of its own, and never inside a loop that retries within a phase: a retry that restarts its budget turns a bounded wait into an unbounded one.
  2. Derive its budget. Name its policy inputs, usually the slowest healthy duration measured with the whole suite at the CI concurrency factor and the headroom that multiplies it. Derive the budget from them with constant arithmetic, and assert its order against the budgets around it: shorter than the phase that repeats it, and inside the cleanup budget or the reserve when it runs during cleanup. Record the measurement beside the input.
  3. Bound it by what it owns. An operation that owns nothing runs within the deadline, or is polled until it, keeping its last output and last failure. A wait for something its owner must keep borrows it and leaves it with that owner when the deadline passes. A task that spawned nothing of its own may be aborted and then joined; a node is never aborted, because the abort would end only its root task. A loop yields on every iteration and pauses only until the deadline.
  4. Keep its failure typed. Return a report of a semantic error enum the module owns, name the operation a passed deadline interrupted, and read state from typed variants. Never reduce an outcome to a boolean, infer a state from whether a message exists, or report an unknown reason. Failures carry no credentials and no payload values.
  5. Never block the runner task. Every scenario and the suite watchdog share it. A synchronous loop needs a bound of its own, as the port draw has, and blocking work belongs on a blocking or spawned task: a step hands its file, encoding, digest or database work to off_the_runner in tests/common/runner_offload.rs and awaits it, and the runner-lag observer logs a step that still holds the task. A duration a scenario injects for a product deadline to race is timed on a task of its own, as injected holds are, because another scenario can still stall the runner task.
  6. Make cancellation release it. Whatever a scenario starts belongs to the scenario’s world or its cluster, so dropping the run releases it: spawned work behind handles that abort on drop, child processes killed on drop, and node tasks through the single task owner, which asks a node that still runs to stop and leaves it stopping under a keeper. A new kind of node publishes its stop to the live-cluster registry through a registration its task owns, and its cleanup belongs to the stopping phase. Ports, faults, proxies, and storage go back only after the tasks that used them have ended.
  7. Report it. Transitions a reader needs while a run is still going go to standard error or to the scenario log with the scenario’s identity. A failure fails its step with the typed error’s message, and diagnostics gathered on a failure path stay within the diagnostic budget.
  8. Test it. Add a focused regression to tests/harness_liveness.rs that drives a stand-in endpoint or task, on a paused clock where the budget is long, so the bound is proven without production-scale waits, and run just test-harness-liveness. Run the affected features with just test-scenarios --input <feature> at the CI concurrency factor, NERVIX_TEST_CONCURRENCY_FACTOR=2.
  9. Keep this chapter current in the same change.

Guarantees And Limits

The harness guarantees:

  • Every status request, status wait, readiness wait, node startup, teardown diagnostic, cluster cleanup, and post-run teardown has one absolute deadline, and no retry or poll restarts it.
  • No diagnostic, timeout, or cleanup path aborts a node task or leaves one unowned. A node that outlasts a wait stays with its owner or with the keeper it was handed to, and keeps its storage, its ports and its registrations until its task has ended.
  • No node is launched over one that is still stopping, so a launch never meets a database lock its own predecessor holds.
  • A cluster’s cleanup costs one budget whatever its size, and releases what a node held only after that node’s task has ended.
  • finished is published only after cleanup has completed.
  • A gate a step holds is released its injected duration after the hold observed it, however long the runner task is stalled.
  • The run ends within its budget, the cleanup window, and the bounded teardown, with a status that tells a timeout from a failure.

Its limits:

  • Budgets bound what the harness waits for, not what the operating system schedules. A starved runner delays the checks that end a wait.
  • The watchdog is polled on the runner task, so it cannot end a synchronous stall of that task. The job’s limit is the guard that remains.
  • Provisioning a Kafka topic’s partitions and waiting for a consumer group’s members query the broker synchronously on the runner task, for up to 5 seconds per query, and every scenario of the run waits while one of those queries runs.
  • Cleanup and stops give back a stopped node’s consensus fault registration on the runner task, so when no step still holds an observer of the node’s consensus state, the node’s consensus database closes there, and every scenario of the run waits until the close has synchronized the journal and the database’s last background worker has left.
  • Kafka committed-offset assertions query on a blocking worker with a one-second request timeout. A query error retries within the assertion’s original deadline; persistent errors fail rather than count as an offset observation. An offset that crosses a forbidden threshold fails immediately, and a below-threshold assertion completes only after a successful observation at the end of its window.
  • The watchdog runs no after hooks, so a node held by a fault its scenario injected can outlast the cleanup window and ends with the runtime.
  • Nothing in the process can end a node that does not end itself: in-process nodes share one runtime with the harness. A node left stopping that never finishes its shutdown keeps running, with its storage and ports, until the process drops the runtime.
  • The time a scenario spends queued for permits and closing the browser have no budget of their own, and some ordinary step waits check their bound only between requests. The suite budget bounds all of them.
  • A construction’s convergence checks are separate 40-second waits after its startup deadline.
  • The suite budget’s derivation holds with no margin.

Qualification Evidence

just test-harness-liveness runs the focused regressions that hold this contract. They drive stand-in session services and a stand-in NATS server on real loopback sockets, and stand-in node tasks, most of them on a paused clock. The tests job runs them beside the separate scenario job.

ContractRegressions
A status request ends at its deadline in the operation that stalled, and every probe outcome keeps its causestatus_request_ends_at_its_deadline_while_the_connection_is_pending, status_request_ends_at_its_deadline_while_session_establishment_is_pending, status_request_ends_at_its_deadline_while_the_response_is_pending, status_request_ends_at_its_deadline_while_unrelated_responses_remain_ready, readiness_and_status_outcomes_retain_their_typed_cause
A wait ends at its original deadlinestatus_wait_ends_at_its_original_deadline_when_the_final_request_never_replies, nested_deadline_never_outlives_its_phase, startup_readiness_failure_reports_the_timed_out_status_operation
Node task outcomes are distinct, the task is joined once, and its owner never aborts itrunning_task_reports_the_last_failed_probe_at_the_deadline, clean_application_exit_before_readiness_is_terminal, application_error_before_readiness_is_retained, panic_and_cancellation_have_distinct_terminal_outcomes, stop_and_keeper_paths_use_the_task_inspected_for_diagnostics, a_wait_past_its_deadline_leaves_the_task_running_with_its_owner
One startup budget with classified retriesrepeated_readiness_failure_spends_one_budget_across_every_attempt, a_node_that_does_not_stop_ends_the_startup_instead_of_being_launched_over, exhaustion_reports_every_attempt_with_its_typed_cause, ports_that_cannot_be_reallocated_end_the_startup, a_bound_address_is_retried_until_a_launch_becomes_ready, a_last_attempt_still_becomes_ready_with_what_the_budget_left, an_application_error_ends_the_startup_without_another_launch, a_panicking_node_ends_the_startup_without_another_launch, a_launch_failure_ends_the_startup_before_anything_is_cleaned_up, sequential_cluster_construction_stays_inside_its_derived_budget
Diagnostics are concurrent and never keep cleanup from startingstatus_snapshots_keep_a_healthy_node_while_another_node_stalls, failed_and_stalled_diagnostics_end_by_their_deadline_so_cleanup_starts, a_stalled_diagnostic_still_reaches_every_node_stop_in_a_cluster_of_one_and_of_three
One cleanup budget per cluster, and truthful phasesstuck_nodes_spend_one_cleanup_budget_in_a_cluster_of_one_and_of_three, a_node_left_stopping_keeps_what_it_holds_until_its_own_task_ends, a_single_node_cleanup_keeps_how_its_task_ended, a_panicking_node_is_the_only_cleanup_failure_a_three_node_cluster_reports, the_finished_phase_is_published_only_once_cleanup_has_completed, an_active_scenario_publishes_its_phase_and_the_age_of_that_phase
A stop a scenario waits for is timed by the node’s own ending, and a node that outlives a wait is named with its shutdown phasea_stop_is_timed_from_its_first_request_to_the_ending_of_the_node_task, a_node_that_had_ended_before_it_was_asked_to_stop_took_no_time_to_stop, a_stopping_node_is_read_with_its_shutdown_phase_and_its_own_deadline, a_stop_record_reads_as_the_transition_the_harness_prints, a_node_left_stopping_is_recorded_with_where_its_own_shutdown_stood
Feature waits do not occupy run slots, and limited chains start before and progress beside bulk worka_queued_web_console_scenario_does_not_hold_a_run_slot, limited_features_are_taken_up_before_the_bulk, the_next_limited_scenario_gets_a_slot_beside_bulk_work, releasing_a_limited_scenario_hands_its_slot_to_the_next_in_its_chain, each_web_console_feature_starts_before_one_feature_consumes_the_group
The port pool is bounded and gives ports backa_draw_that_keeps_landing_on_reserved_ports_ends_at_the_draw_limit, an_exhausted_draw_gives_back_the_ports_it_had_reserved, a_draw_the_operating_system_refuses_is_reported_as_its_own_failure, ports_drawn_from_the_operating_system_are_distinct_and_reserved, a_released_port_can_be_drawn_again
An HTTP receiver answers as scripted, records what it cannot capture, and stops within its budgetthe_receiver_captures_requests_and_answers_its_script_in_order, a_lost_response_is_captured_and_the_connection_closes_without_an_answer, chunked_bodies_interim_responses_and_raw_bytes_are_served_as_scripted, held_responses_and_stalled_bodies_end_within_the_stop_budget, requests_awaiting_a_response_are_counted_until_their_final_head_begins, a_client_that_leaves_an_unfinished_response_abandons_it, a_wait_for_a_request_line_ends_once_that_request_is_captured, requests_beyond_the_receiver_bounds_are_faults_not_captures, a_tls_receiver_accepts_the_client_certificate_it_issued_and_refuses_others, a_tls_receiver_is_refused_by_a_client_that_dials_a_name_its_certificate_lacks, every_documented_script_form_parses_and_unknown_forms_are_refused
A gRPC receiver answers as scripted, records what it cannot capture, and stops within its budgetthe_grpc_receiver_captures_calls_and_answers_its_script_in_order, a_lost_grpc_answer_is_captured_and_its_connection_closes_without_one, held_grpc_calls_end_when_the_client_resets_them_or_the_receiver_stops, a_request_that_is_not_one_bounded_message_is_a_fault_not_a_capture
Synchronous step work leaves the runner task polling, and a stall of that task is measureda_step_job_leaves_the_runner_task_polling_other_scenarios, a_step_job_returns_its_result_to_the_step, a_step_job_fails_its_step_with_its_own_panic, a_step_that_blocks_the_runner_task_is_reported_with_its_length
A step takes the messages a NATS observer’s client already holds without a poll of the runner task for each one, in the order the server delivered thema_step_takes_buffered_deliveries_without_a_poll_per_message
The suite watchdog names what was running, publishes timing before cleanup, and ends the runa_run_that_finishes_inside_its_budget_keeps_what_it_produced, a_suite_timeout_reports_before_it_drops_the_run, a_stalled_scenario_body_is_named_with_its_attempt_phase_and_nodes, a_stalled_teardown_diagnostic_is_named_by_the_phase_it_is_in, a_node_that_never_stops_is_named_at_the_end_of_the_cleanup_window, a_cluster_that_outlives_its_scenario_is_named_as_unclaimed, a_retried_scenario_publishes_which_attempt_is_running, the_suite_budget_is_injectable_and_defaults_to_the_suite_policy, a_timed_out_suite_is_reported_apart_from_a_passing_and_a_failing_one, a_failing_suite_ends_the_process_by_unwinding, a_dependency_stop_that_never_returns_is_abandoned_at_its_budget, a_dependency_stop_that_finishes_keeps_what_it_reported

The high-parallelism qualification was recorded on 23 September 2026 for the change that landed as cec5f764, at the CI concurrency factor of two scenarios per CPU with Cucumber’s two retries unless a run says otherwise. The local runs used a 24-CPU workstation shared with other builds, whose load average stayed between 7 and 40, so 48 scenarios ran at once; CI used its 16-CPU runner, so 32 did.

RunResult
just test-harness-liveness, 20 repetitionsEvery repetition passed, 48 regressions in 4.0 seconds each
WASM processor restores guest state after cluster restart, all three example rows, three runs with --retry 0Every run passed. Every node became ready on its first attempt within 2.1 seconds, and the slowest cleanup took 8.7 of its 60 seconds, so none reached its budget
tests/features/cluster/*.feature206 scenarios passed in 500 seconds with 16 retries. 553 node startups: median 2.04, 99th percentile 22.0, and slowest 41.3 seconds; two became ready on their second attempt, after a 36-second readiness deadline whose last probe was a typed Unauthenticated reply. 200 cleanups, the slowest 45.1 seconds, so none reached its budget
tests/features/runtime/wasm_*.feature95 scenarios passed in 583 seconds with 18 retries. 288 startups, the slowest 17.4 seconds. Two cleanups reached the 60-second budget with a node still running, each recorded with that node and the scenarios live beside it, and both scenarios passed when retried
The whole suite with --suite-budget 4mExit 124 after 313 seconds: the 240-second budget, the 60-second cleanup window, and 13 seconds of dependency and runtime teardown. The diagnostic named all 49 registered scenarios with their attempt, phase, phase age, and nodes, and the cleanup named the one node still running at the end of its window
The whole suite with a deliberately failing scenario addedExit 101 reporting 3 step(s) failed after 1,676 seconds. The failing scenario failed all three attempts while the rest of the suite ran beside it, and each attempt reached finished within 0.32 seconds of its body ending. 3,926 startups, 99th percentile 4.14 and slowest 21.4 seconds; eight transient bind failures were relaunched on fresh ports and became ready. Six cleanups reached their budget with a node still running and were recorded
The whole suiteExit 101 after 1,490 seconds, with 1,749 of 1,752 scenarios passing. The three that did not are throughput and deadline assertions that failed only under the shared machine’s load and pass in isolation and in CI. 3,937 startups, the slowest 20.7 seconds; four cleanups reached their budget with a node still running and were recorded
CI tests job with NERVIX_TEST_SUITE_BUDGET=4mThe job ended itself in 16m30s. The suite started 11m54s into the job, and its budget expired with 32 scenarios active, 29 in their body and 3 stopping, each named. All 53 live nodes stopped within 9.96 seconds of the cleanup window, the process exited 124, and the test-logs artifact was uploaded 2 seconds later
CI tests job of the merged revisionPassed in 34m15s. The suite started 12m01s into the job and ran 1,754 scenarios and 19,384 steps in 19m26s with 2 retries. 3,832 node startups, all on their first attempt: median 1.77, 99th percentile 4.91, and slowest 6.37 seconds. No cleanup reached its budget

After every run, no nervix-server process, scenario binary, or dependency container remained. The harness liveness qualification ledger records each run in full, with its artifacts, the executable evidence for every item of the qualification matrix, and the comparison with the run the job limit cancelled.

Deterministic Interconnect Simulation

The interconnect has a seeded network simulation built on Turmoil. Each Nervix transport runs on a simulated host with its own Tokio runtime, virtual clock, TCP stack, and DNS name. The hosts talk over simulated links that the test can delay, partition, hold, and repair. The code above the socket is the production code: TLS 1.3 mutual authentication, HTTP/2 pools, typed rkyv envelopes, Arrow IPC payloads, relay admission and reconciliation, connection lifecycle, and quota accounting all run unchanged. The same seed and inputs make the same decisions in the same simulated order, so a network failure that once exposed a defect can be run again exactly.

This chapter owns that simulation: where its code lives, how it builds, what a simulated host controls, how a run is supervised, the fault model, how runs are compared and replayed, the scenarios and what each one proves, the commands, the CI budget, the qualification evidence, and the limits of every claim. Three rules hold throughout.

  • The simulation tests interconnect contracts. It never claims that a whole Nervix node, a consensus group, gossip, the domain clock, a store, or an external system was simulated.
  • A replay compares decisions, not bytes. A trace records identities, admission decisions, outcomes, and the simulated time at which they happened. It never records key material, certificate bytes, ciphertext, or payload values.
  • A simulated host crash is not a process crash. It tears a runtime down with ordinary destructors, so durability and SIGKILL behavior keep their real-process evidence.

The harness is test code outside the layer order: it may name any layer, and product code never names it. Product contracts stay with the chapters that own them: node-to-node transport with Cluster Interconnect, in-process ordering under Shuttle with Data-Plane Concurrency, node stops and crash recovery with Shutdown And Recovery, domain time with Domain Clock, and the Cucumber cluster harness with Integration Test Lifecycle.

What Each Suite Establishes

Four suites exercise the interconnect. Each establishes something the others cannot, and none of them substitutes for another.

SuiteCommandScheduler and timeWhat it establishesWhat it cannot establish
Ordinary unit testsjust test-interconnect, just test-executionReal Tokio runtime, paused or real timeLocal logic of one owner: framing, codecs, quotas, identitiesBehavior across a lossy network or between real processes
Shuttle checksjust test-shuttleShuttle’s scheduler over wrapped primitives; no elapsed timeThat in-process protocols hold their invariant under every explored interleavingSockets, elapsed deadlines, or anything across hosts. See Deterministic Concurrency Verification
Turmoil simulationjust test-turmoilOne Turmoil scheduler owns every host; each host has a paused Tokio clock that advances with simulated timeThat the production transport keeps its deadlines, reconciliation, fencing, and resource bounds when links are delayed, partitioned, held, repaired, or a host restarts, and that the same seed replays the same decisionsInstruction-level races inside one job, kernel TCP behavior, durability, and whole-node behavior
Cucumber scenariosjust test-scenarios --input <feature>Real nodes, real sockets, real timePublic behavior through NSPL, HTTP, the CLI, and cluster state, including real-process SIGKILL recoveryExhaustive fault timing; failures are observed, not scheduled

Code Ownership

PathKindOwns
crates/primitives/src/net.rs, time.rs and task.rsProduct seamThe primitive boundary’s selection for the Turmoil build: Turmoil’s sockets and the simulated lookup_host in net, Tokio’s timers and instants in time, which follow each host’s clock, and task::spawn_cpu, which runs an admitted CPU job as a scheduler task. Production takes Tokio’s sockets and runs the job on the blocking pool
crates/interconnect/src/peer_resolver.rsProduct seamSelects the PeerResolver: the node’s Hickory resolver in production, the simulated host’s DNS table through nervix_primitives::net::lookup_host in the Turmoil build
crates/interconnect/src/authentication.rsProduct seamTransportClock, the one UTC clock each TLS bundle judges certificates by. TransportClock::system in production, TransportClock::from_provider for a controlled environment
crates/interconnect/src/entropy.rsProduct seamTransportEntropy, the source of the process epoch and relay grant identifiers. TransportEntropy::operating_system in production, TransportEntropy::from_source for a controlled environment
crates/execution/src/workers.rsProduct ownerAdmission, charges and cancellation of every job; it submits CPU jobs through task::spawn_cpu, the only caller the boundary check allows, and storage jobs through task::spawn_blocking
crates/primitives/src/lib.rsProduct seamThe compile error when two execution modes are selected together
crates/interconnect/tests/simulation/runner.rsHarnessRun configuration, the scheduler thread and its real-time bound, host supervision, first-panic capture, the simulated UTC clock, seeded entropy, and the semantic trace. The library’s own simulation tests include it as simulation_runner
crates/interconnect/tests/simulation/scenario.rsHarnessScenario identity, committed seeds, the seed sweep, fresh-process attempts, failure records, replay, and failure injection
crates/interconnect/tests/simulation.rsHarnessThe simulation test target: runner checks, the DNS and CPU scenarios, and the fresh-process record and replay checks
crates/interconnect/tests/simulation/transport.rsHarnessThe certificate authority fixture, typed Arrow requests, host synchronization, and the exchange and link-fault scenarios
crates/interconnect/tests/simulation/relay.rsHarnessRelay reply loss, cancellation, receiver restart, and sender replacement with a retained intake
crates/interconnect/tests/simulation/backup.rsHarnessAuthenticated backup capture and section fetch across a partition, repair, and owner restart during transfer
crates/interconnect/tests/simulation/isolation.rsHarnessThe three-host stalled-peer scenario and the capacity bounds it checks
crates/interconnect/tests/simulation/materialized.rsHarnessTyped branch descriptions and 3 MiB Arrow payloads in 64 KiB bulk chunks across a partition, repair and serving transport replacement
crates/interconnect/src/authentication/simulation_tests.rsHarnessCertificate validity, expiry drain, handshake deadlines, and trace replay under the simulated clock
crates/interconnect/src/wire.rs, module simulation_checksHarnessSeeded rkyv round trips through the execution owner
crates/execution/src/workers.rs, module simulation_checksHarnessQueue slots, admission order, and charge ownership of the Turmoil CPU strategy
crates/primitives/src/tests/simulated_host.rsHarnessThe boundary’s own check that sockets, name lookup, timers, and admitted CPU jobs belong to the simulated host whose task uses them
justfile and the turmoil job in .github/workflows/check.yamlToolingThe recipes, budgets, validation, and CI artifact

Every product seam has exactly one production behavior, and the production value is the default a node uses. A seam exists so that a controlled environment can supply a value; it is not a switch between two production behaviors. The harness never reaches into product state: it constructs a transport through its public API, supplies a clock and an entropy source, and observes the result through requests, snapshots, and outcomes.

Build Modes

Execution Primitives owns backend and target selection, permission contracts and the difference between compile-time probes and actual execution evidence. This chapter owns what the Turmoil hosts simulate, the faults and the completed scenario evidence.

The simulation is its own build. Its scheduler replaces the real network, so it can never share a compilation with the production or Shuttle builds.

BuildSelected bySockets and DNSCPU jobsSynchronization primitivesTokio configuration
NormalDefault featuresTokio’s operating-system sockets, which nervix_primitives::net selects, and the node’s Hickory resolverTokio’s blocking pool, through task::spawn_cpuThe ordinary primitives nervix-primitives re-exports: Tokio, Tokio Util, parking_lot, DashMap and the standard library’sStable
ShuttleThe shuttle feature of each owning package, forwarded to nervix-primitivesTokio’s, outside every model and never driven by the checks: a socket created inside a check fails itspawn_blocking of Shuttle’s modeled TokioThe modeled primitives nervix-primitives selects: Shuttle’s Tokio, timers, Tokio Util, parking_lot, DashMap and atomics, and its own scheduler-visible Notify and watchStable
TurmoilThe turmoil feature of nervix-interconnect, which enables nervix-execution/turmoil and nervix-primitives/turmoilTurmoil’s simulated sockets and DNS table, which nervix_primitives::net selectsA task on the simulated host’s scheduler, through task::spawn_cpu; storage jobs stay on the blocking poolThe ordinary primitives, on the simulated host that runs the caller, with timers that follow its clock--cfg tokio_unstable
Turmoil with Shuttle, Loom or DeloxideBoth modes in one dependency graph, from one package or twoFails to compile in nervix-primitives with a diagnostic naming both modes, so the deadlock detector never runs inside a simulation
Turmoil for the browser’s targetThe turmoil feature with --target wasm32-unknown-unknownFails to compile with the boundary’s diagnostic that execution modes run on native targets only, before any simulator dependency is built

Only the Turmoil recipes pass --cfg tokio_unstable, and just validate-primitive-boundary rejects the flag in any other recipe, in Cargo configuration, in a workflow and in a build script. It rejects --cfg turmoil everywhere, and a bare turmoil in a cfg predicate: the mode is the turmoil feature, never a cfg every crate of the build would read. With it, Turmoil seeds each host runtime’s scheduling from the simulation seed and configures an unhandled task panic to shut that host’s runtime down, which fails the run. Without it, scheduling would not follow the seed and a panicking task would only print, so the runner refuses to start. The workspace declares the cfg in its check-cfg list so ordinary builds accept the attribute without enabling it. Turmoil 0.7.2, locked in Cargo.lock, is used without its unstable filesystem feature.

Validation keeps the modes apart:

  • just validate-execution-mode-dependencies fails when the normal dependency graph of the workspace, or of any package built on its own, with or without default features, contains Turmoil, Loom, Shuttle, a Shuttle wrapper or Deloxide, or enables an execution mode on the primitive boundary.
  • just validate-primitive-boundary rejects every path to Tokio’s or Turmoil’s sockets, Tokio’s timers, or the standard library’s monotonic clock and sockets outside nervix-primitives, and a turmoil dependency outside the boundary unless it is optional behind the package’s own turmoil feature, as the interconnect’s harness takes it. The simulated path therefore cannot name an operating-system socket or clock that would escape its host.
  • just validate-execution-mode-conflicts builds nervix-primitives with every pair of the loom, shuttle, turmoil and deloxide modes and with all four, and nervix-interconnect with Shuttle while its execution dependency selects Turmoil, and requires the diagnostic naming the modes in each. It builds each mode for the browser’s target and requires the boundary’s own diagnostic first, and builds the NSPL formatter, a product binary, with each mode and requires the diagnostic that a modeled build is a test artifact.
  • just lint runs Clippy over every target of both packages with the turmoil feature, beside the production and Shuttle lints.
  • just test runs the workspace with all features while excluding every package that offers a scheduler mode, runs those packages with default features, and then runs just test-turmoil.

The Simulated Host

A simulated host is a named Turmoil host running one production transport. Everything the transport owns is constructed inside the host: its credential bundle, executor, peer resolver, transport, and peer targets. Live sockets and transports are never shared between hosts. Hosts share only bounded readiness and completion signals with each other and with the observing client.

Sockets And DNS

In the Turmoil build the primitive boundary’s nervix_primitives::net selects Turmoil’s TcpListener, TcpStream and UdpSocket, and every transport is built with PeerResolver::simulated, which answers from the simulated host’s DNS table through nervix_primitives::net::lookup_host, a lookup the boundary offers in that mode alone. The listener, every outbound dial, and peer resolution therefore use simulated TCP and DNS. A peer endpoint such as server:7443 resolves to the address Turmoil assigned to the host named server, in IPv4 or IPv6 by configuration, and a name the table does not hold fails as a name that does not exist. The scenarios register peers at their advertised endpoints, so, as in production, every connection attempt resolves the peer’s name again inside its setup deadline and dials the answer. Nothing in the simulated path opens an operating-system socket, constructs the production Hickory resolver, reads a resolver configuration or hosts file, or sends a DNS packet, so a fault scenario cannot escape to the real network and no resolver state is shared between hosts. The layers above the socket, tokio-rustls, h2, the envelope codec, and Arrow IPC, are the production code. These scenarios exercise the transport over simulated names; Hickory’s DNS protocol, cache, and failure handling are checked by the resolver crate’s own tests against local DNS authorities outside the simulation, as Name Resolution describes.

Bounded CPU Execution

The transport encodes and decodes through nervix-execution, which submits every admitted CPU job through the boundary’s task::spawn_cpu. In the Turmoil build that runs the job as a Tokio task on the host’s scheduler instead of on the blocking pool, so the job body is one scheduling step of the simulation. The executor is the mechanism’s only caller: the boundary check rejects it anywhere else, so no caller runs CPU work on the scheduler without admission. Queue slots, per-class worker reservations, memory charges, and cooperative cancellation follow the same policy as production. A queued caller that leaves releases its slot and charge; a running job keeps its worker and charge until it exits, even when its caller leaves.

Turmoil can vary the order of tasks around a job, but not instructions inside it. Races inside a job need Shuttle or real threads. Storage jobs still run on a real blocking thread and are outside the simulated path. Only bounded executor probes and interconnect codec jobs are approved for the simulation; storage jobs, external drivers, and unbounded CPU work are not.

Time

A simulated host has three distinct notions of time, and the simulation controls each one differently.

TimeSource on a simulated hostRead by
Scheduler timeThe timers and instants of nervix_primitives::time, which are Tokio’s and follow the host runtime’s paused clock, advanced by Turmoil one tick at a timeEvery transport deadline: connection setup, request and progress timeouts, liveness, reconnect backoff, relay grant lifetimes, the drain deadline derived from certificate expiry
Certificate UTCThe bundle’s TransportClock, given a provider that reads the configured epoch plus simulated elapsed time, with an optional per-host skewRustls verification on both sides of a handshake, the transport’s checks of its local and peer certificates, and the mapping of certificate expiry onto a monotonic drain deadline
Domain timeNot simulatedDomain-clock authority and execution time, owned by Domain Clock

Outside a simulated host the simulated UTC clock has no reading, and a certificate check fails instead of falling back to the host wall clock. The certificate fixtures’ epoch, 2027-01-15T00:00:00Z, lies after the wall clock of any machine the suite runs on today, so a check that consulted the host clock by mistake would find every fixture certificate not yet valid. The transport clock is physical infrastructure time only: it never defines domain time and never stamps connector arrivals.

Identities And Entropy

Node names are fixed per host. Each transport’s process epoch and relay grant identifiers come from a seeded source, SimulatedEntropy, whose stream is named after the host. One seed therefore allocates the same identities on every host in every process, while two hosts sharing the seed draw different values. Fixtures choose their own delivery identities and acknowledgement registrations, such as the channel incarnation and ACK identifier of each relay case and the run of the node that registers it, so a trace can name them.

Certificates are generated for every run with fixed validity windows: 2027-01-01 to 2028-01-01 for the transport fixtures, and windows placed relative to the epoch for the certificate-clock checks. Validity decisions therefore replay while key bytes differ between runs. Rustls and AWS-LC keep their real cryptographic randomness; it changes ciphertext and never a decision the trace records.

The transport’s concurrent maps use per-process hash seeds. Every walk over one of them that causes effects runs in semantic key order, as Cluster Interconnect lists, so map order cannot reach a decision.

The Runner

SimulationConfig::run builds one Turmoil simulation from one configuration, lets a setup closure add hosts and clients, and steps it on a dedicated scheduler thread until every client has finished or a bound expires.

Configuration

Every run is built from exactly these inputs, and a failure record stores all of them.

InputMeaning
seedTurmoil’s random seed: link latency draws, the random host order within each step, and each host runtime’s scheduling seed. Fixtures derive their entropy streams from it
epochThe UTC instant simulated time starts at. It must not precede the Unix epoch
topologyIPv4 or IPv6 host addresses
network.min_message_latency, network.max_message_latencyThe range each simulated message’s delay is drawn from with Turmoil’s exponential curve. Every scenario uses 0 to 100 ms
network.fail_rate, network.repair_rateTurmoil’s chance per step that a link starts or stops dropping messages. Every scenario uses 0 and 1, so links fail only through the scenario’s written fault plan
network.tcp_capacitySegments one simulated connection buffers before its sender waits. Every scenario uses 64
bounds.simulated_durationSimulated time after which the run fails
bounds.tickSimulated time one step advances. Every scenario uses 1 ms
bounds.max_stepsSteps after which the run fails
bounds.wall_durationReal time for the run and its cleanup together

The runner validates the configuration before it starts a thread. A zero duration, an epoch before the Unix epoch, a build without tokio_unstable, or a real-time bound too large for the machine’s clock each fail with their own error, naming the scenario and seed.

Host Lifecycle And Supervision

A host is registered with its software, a closure that Turmoil calls when the host starts and again each time the host is bounced. Each call builds the host’s transport afresh. A client is a future that runs once; the run completes when every client has returned. Scenarios therefore add an observer client that waits until each host has reported that it finished, so hosts, which never complete a run themselves, still bound it.

  • HostSupervisor::run spawns a host’s body as a task on the host’s runtime and returns the body’s error or panic as the host’s result. A failed host fails the next step.
  • A panic in any other task on a host shuts that host’s runtime down, which fails the run. The runner reports the first panic raised on the scheduler thread, with its message and location, rather than Tokio’s generic shutdown message. A panic that something caught, such as a supervisor or Tokio while dropping a task, is reported the same way.
  • A host shuts down every transport it bound before its body returns, and checks the transport’s connection counts after shutdown.
  • SimulationConfig::run_with_control, used through ScenarioRun::simulate_with_control, calls a control closure after every step that did not end the run. A fixture uses it to crash, release, repair, and bounce hosts at a protocol milestone a host published, never at a guessed step count.
  • When the steps complete, the simulation and every host runtime are dropped in a cleanup phase of their own, inside the same real-time deadline. A panic while dropping fails a run whose steps all succeeded.

How A Run Ends

EndingHow the run fails
A host or client returns an errorWith that error
A panic on the scheduler threadWith the first panic’s message and source location. This covers a panic in a task on a simulated host, which shuts that host’s runtime down, and a panic Tokio catches while it drops a task, which would otherwise only be printed
The simulated duration or the step limit runs outWith the bound and the simulated time reached
The real-time bound expiresWith the phase the scheduler was in, the steps it completed, and the simulated time they reached, which locates a step that blocked the scheduler thread
Cleanup panics after the run completedAs a cleanup failure, even though every step succeeded

The real-time deadline is fixed before the scheduler thread starts and is measured outside it, so it expires even when a host blocks that thread and simulated time cannot advance. The blocked thread is then abandoned and ends with the process that runs the attempt. A cleanup failure that follows a failed run is written to standard error beside the run’s own failure. The real thread and wall clock decide only when a test fails, never how a simulated request proceeds.

Fault Model

A fault plan is written with Turmoil’s link and host controls. Each control is an abstraction of a network or host event, and a scenario’s claim is limited to the abstraction it used.

ControlEffect in Turmoil 0.7.2Used by
partition(a, b)Drops every message between two hosts in both directionsPartition before connect; partitioned authenticated exchange
partition_oneway(from, to)Drops messages in one direction onlyOne-way partition before connect; relay reply loss; receiver restart after body receipt and after admission
repair(a, b), repair_oneway(from, to)Restores delivery. Dropped messages are not replayedEvery partition above
hold(a, b)Queues messages between two hosts instead of delivering themHeld authenticated exchange; receiver restart with a delayed reply; stalled-peer teardown
release(a, b)Delivers every held message at onceThe same scenarios
crash(host)Drops the host’s runtime and every task on it, running ordinary destructorsReceiver restart
bounce(host)Restarts a host by calling its software againReceiver restart

The model has consequences that every scenario respects:

  • Simulated TCP does not retransmit. A message dropped by a partition is lost for good, so after a repair an established HTTP/2 session can be unusable. The established-exchange scenarios require both peers’ protocol pings to retire unusable sessions and allow reconnection without restarting either host or manually replacing a pool. No scenario infers kernel retransmission timing or packet-loss recovery.
  • Holding and one-way partitions cannot be combined on one link, so scenarios use them separately.
  • Turmoil 0.7.2 has no disconnect control. TCP closure is exercised by shutting a listener down or crashing a host.
  • A request whose deadline expires while its messages are held stays expired when they are released.
  • Turmoil’s own socket count can keep failed connection attempts after a partition. Scenarios measure session cleanup with the transport’s connection counts instead.
  • Random link failures are disabled. A scenario’s faults are its written plan, applied at named protocol points.

Semantic Traces And Replay Rules

A run records a semantic trace: an ordered list of events, each with the simulated time it happened at, the host that recorded it, and what was decided. Fixtures record events at decisions: a connection became ready, a setup failed, a deadline expired, a relay was admitted or fenced, an epoch changed, a resource was released. The same scenario, seed, and inputs must record the same trace.

  • An event names identities, admission decisions, and outcomes. It never contains key material, certificate bytes, ciphertext, or payload values, which differ between runs without changing a decision or do not belong in a record. Payload assertions compare values without printing them.
  • An event never contains a value that describes the process rather than the simulation, such as a process identifier, a real time, a memory address, or an iteration over a hashed collection. process_dependent_trace is a deliberately divergent scenario that records its process identifier, and a regression test requires it to be recorded as diverged.
  • Every committed seed runs twice, each time in a fresh process, and the two traces and outcomes must be identical. The first differing event, or the first event only one run has, is a divergence and fails the seed. A divergence that can affect behavior is a defect to fix, never a seed to drop.
  • A replay compares its outcome and its trace with the record, event by event.

Two nondeterminism sources remain outside the trace by design. Rustls and AWS-LC randomness changes ciphertext and key bytes but no decision. Tokio numbers tasks across the whole process, and a runtime tears its tasks down in an order derived from those numbers, so when a simulated host crashes, the order in which its connections close depends on every task the process created before. Each attempt and each replay therefore runs in a fresh process, where that count starts from the same state. Tokio’s per-runtime scheduling seed is not a residual source: Turmoil derives it from the simulation seed because the build sets tokio_unstable.

Scenarios, Seeds, And Attempts

A scenario is one case of a test: a name unique within the test, a written fault plan, and its committed regression seeds. Scenario::check takes a function that builds a seed’s configuration and a function that exercises it, and runs them in the mode the environment selects.

VariableMode
NoneRun every committed seed of every case twice, each in a fresh process
NERVIX_TURMOIL_SWEEP=<first>..<end>Replace every case’s committed seeds with that range, end exclusive. Everything else is unchanged
NERVIX_TURMOIL_INJECT_FAILURE=<duration>Add a client named injected-failure that fails at that simulated time, such as 5s. The injection is a recorded input
NERVIX_TURMOIL_REPLAY=<record>Run only the recorded case, once, with exactly the recorded inputs. It refuses to run beside a sweep or an injection
NERVIX_TURMOIL_FAILURES=<directory>Where records are written. Unset, they go under Cargo’s CARGO_TARGET_TMPDIR; the recipes set target/turmoil-failures

The driver sets a fifth variable, NERVIX_TURMOIL_ATTEMPT, on the process it starts for one attempt, and clears the other four there.

Seed Selection

Committed seeds are small and deliberate: one or a few per case, distinct across cases, and listed beside the fault plan they exercise. A case’s committed set grows only by a seed that exposed a defect, committed with its fix, so the regression set records what the simulation has found. The sweep explores beyond them: just test-turmoil-sweep runs sixty-four consecutive seeds from 1000 through every case by default. A seed that fails in a sweep leaves an ordinary record, and once the defect it found is fixed, it joins the committed seeds of its case. Seed 1036 of the receiver restart with a delayed relay response is the first such seed.

Fresh-Process Attempts

For each seed, the driver writes a record marked running, then starts the test binary again with the exact test name, --exact --include-ignored --test-threads=1 --nocapture, and the attempt’s request. That process runs the case once and writes back its outcome and trace. The driver waits for the run’s real-time bound plus 30 seconds, then kills the process. A process that ends without writing its result yields an unreported outcome holding its ending and the last 40 lines of its output. A process that passes without running the requested case is a driver error, because the test and case names no longer match. After both attempts pass and agree, the record is removed.

Failure Records

A failed, panicked, diverged, or unreported seed leaves a JSON record at <failures>/<test>/<case>-seed-<seed>.json, where the test and case are reduced to lowercase letters and digits joined by dashes. The test then fails with a summary of the trace and the command that replays the record.

FieldContents
scenarioThe Cargo package, the test target, the libtest test name, the case within it, and its fault_plan
buildsource, the checked-out commit and whether tracked files were modified; lockfile_sha256 of Cargo.lock; toolchain, the output of rustc -vV; and whether tokio_unstable and debug_assertions were compiled in
inputsThe complete configuration, from seed to real-time bound, and any injected failure
runfirst, or repeat when the second attempt failed after the first passed
outcomeIts kind and detail, as below
eventsThe semantic trace of that run, as far as it got
Outcome kindMeaning
runningThe seed had started when its process ended from outside, such as at the suite’s real-time budget. The record still holds its inputs
failedThe runner returned an error: a failed host or client, an exhausted bound, or a scheduler panic
panickedThe scenario panicked on the test thread around the simulation, such as in a check after the run
divergedBoth runs passed with different traces; the record names the first differing event of each run
unreportedThe attempt’s process aborted, or outlived its bound and was killed, without reporting

A record holds identities, inputs, outcomes, and semantic events only. It never holds certificates, key material, ciphertext, or payload values, so it is safe to attach to an issue or a CI artifact.

Commands

Every recipe builds with the repository’s kache compiler wrapper and passes --cfg tokio_unstable itself.

CommandWhat it runsBound and exit
just test-turmoil [budget_seconds]The primitive boundary’s simulated-host checks, the nervix-execution library under the turmoil feature, the interconnect library’s wire::simulation_checks and authentication::simulation_tests, and every case of the simulation target over its committed seedsBuilds first, then runs inside a real-time budget of 480 seconds by default; exits 124 naming the in-progress records when it expires. Reports how many tests each invocation and the whole suite discovered, selected, executed and completed, keeps each invocation’s output under target/turmoil-suite, and fails when an invocation executed none, when a test tests/turmoil-inventory.toml registers did not run or ran ignored, and when a simulation test ran unregistered
just test-turmoil-simulation [args]The simulation target alone, with libtest arguments, such as a test name and --exactThe per-run bounds only; fails when the selection executes no test
just test-turmoil-replay <record>Exactly the recorded package, target, test, and case, once, with the recorded inputs, in a fresh processExits nonzero when the replay fails, including when it reproduces the record, and when no case in the recorded test matches
just test-turmoil-replay-checkInjects a failure at five simulated seconds into the partition-before-connect case, requires exactly one record, and requires its replay to report turmoil replay: reproduced the recorded outcome and traceFails when any of those does not hold
just test-turmoil-sweep [first] [count] [budget_seconds]Every case over count seeds from first, 64 from 1000 by default, each seed twice in fresh processesA real-time budget of 1,500 seconds by default; exits 124 when it expires, and fails when the sweep executes no test
just coverage-turmoil <output>The canonical just test-turmoil suite through the native coverage collector, copying a complete product-source LCOV report to output and retaining its collection evidenceThe same per-run bounds, inventory validation and 480-second post-build suite budget as just test-turmoil; failed or interrupted collection cannot produce a complete report

The replay prints a verdict for every run:

LineMeaning
build differs: …The recorded source, lockfile, toolchain, or build flags differ from this build. Any of them can legitimately change a run
fault plan differs: …The case’s written plan changed since the record was made
trace: all <n> recorded events replayed identicallyEvery recorded event recurred in order
trace diverges at event <i>: …The first event that differs, from each side
turmoil replay: reproduced the recorded outcome and traceThe same outcome and trace; the failure reproduces
turmoil replay: the recorded failure did not reproduceThe replay passed, which is how a fix is confirmed
turmoil replay: the replay failed differently from the recordThe replay failed with another outcome or trace

The record and replay path is checked itself. injected_failure_is_recorded_and_replayed_in_a_fresh_process injects a failure into a scenario in a fresh process, requires exactly one record, replays it in a second fresh process, and requires the recorded outcome and trace. diverged_and_panicked_runs_leave_records starts two deliberately failing scenarios, one whose trace names the process that ran it and one whose check after the simulation fails, and requires a diverged and a panicked record. just test-turmoil-replay-check does the same through the documented commands. The suite still contains no failing test: the injection exists only in the environment of the processes those checks start, and the failing scenarios are ignored unless a check starts them.

An Example Investigation

The failure below was produced on purpose with the recorded injection, which exercises the same path a real failure takes. The output is quoted from that run, with paths shortened to the repository root.

NERVIX_TURMOIL_INJECT_FAILURE=5s just test-turmoil-simulation \
    transport::network_disruption_respects_deadlines_and_repairs_authenticated_service --exact
  1. Read the failure. The test fails with the case, seed, run ordinal, outcome, trace, and the replay command:

    simulation transport::network_disruption_respects_deadlines_and_repairs_authenticated_service / "partition before connect" seed 61 (first run) failed: simulation partition before connect seed 61: host or client failed: injected harness failure at 5s simulated time
      0.001s client PartitionBeforeConnect installed; peer registered
    failure record: target/turmoil-failures/transport-network-disruption-respects-deadlines-and-repairs-authenticated-service/partition-before-connect-seed-61.json
    replay it in a fresh process: just test-turmoil-replay target/turmoil-failures/transport-network-disruption-respects-deadlines-and-repairs-authenticated-service/partition-before-connect-seed-61.json
    

    In CI the same record is in the job’s turmoil-failures artifact. Its run field says whether the first attempt failed or only the repeat did. A repeat failure or a diverged outcome points at nondeterminism before it points at the product.

  2. Compare the build. The record’s build names the commit, lockfile hash, and toolchain. Check out that commit when the current branch has moved; the replay reports every remaining difference.

  3. Replay it.

    just test-turmoil-replay target/turmoil-failures/transport-network-disruption-respects-deadlines-and-repairs-authenticated-service/partition-before-connect-seed-61.json
    
    recorded outcome (first run): failed: simulation partition before connect seed 61: host or client failed: injected harness failure at 5s simulated time
    replayed outcome: failed: simulation partition before connect seed 61: host or client failed: injected harness failure at 5s simulated time
    trace: all 1 recorded events replayed identically
      0.001s client PartitionBeforeConnect installed; peer registered
    turmoil replay: reproduced the recorded outcome and trace
    

    The command exits with a failure status because the failure reproduced. The trace ends at the last decision before the failure: the client registered its peer 1 ms into the run, and the injected client failed at 5 seconds, before the twelve-second partition ended.

  4. Narrow it. Read the case’s fault plan and the fixture’s events around the last recorded one. To see what happened between two events, add a temporary tracing subscriber that stamps lines with turmoil::sim_elapsed() and prints the lengths of byte arrays rather than their contents, then rerun the replay. Compare two runs of a divergence line by line from the first differing event.

  5. Fix it at its owner. A product defect gets its fix and regression at the owning boundary, with a Cucumber scenario when the behavior is public. A harness defect gets its fix in the runner or driver. Replay the record again until it prints turmoil replay: the recorded failure did not reproduce, commit the seed to its case’s committed seeds, and run just test-turmoil.

Seed 1036 is the recorded history of this procedure. The first sweep found that its two runs of the receiver restart with a delayed relay response diverged after the simulated crash while they shared one process. Tracing both runs with simulated timestamps showed that the crashed receiver’s connections closed in a different order, and that the order followed Tokio’s process-wide task numbering. The driver moved every attempt and replay into a fresh process, and seed 1036 became a committed seed of that case.

CI And Budgets

CI runs the simulation as a job of its own, turmoil, only for a pull request labeled turmoil, beside the default tests and the shuttle and loom jobs. Its build enables the turmoil feature and tokio_unstable, so it shares no compilation with them, and a failed case’s record is its own artifact. The job runs just coverage-native-extras test-turmoil, which executes the canonical gating suite once under LLVM source instrumentation, then runs just test-turmoil-replay-check independently. Replay qualification contributes no counters. The instrumented build lives under target/native-coverage-build-turmoil; its failure records and suite logs, together with the ordinary replay’s target/turmoil-failures, are uploaded as the turmoil-failures artifact when the job fails.

The coverage-turmoil artifact retains the product-source report, completion.json, executions.jsonl and export.log from the collection’s own attempt. Only a report beside a complete record establishes successful coverage; a failed or interrupted attempt retains its failure evidence. Each fresh simulation process inherits LLVM_PROFILE_FILE with %p-%m, so its counters are merged with the parent and the other attempts rather than overwritten. The exporter matches every raw profile to a retained executable by build ID and includes Turmoil-selected product paths in primitives, execution and interconnect. It excludes dependencies, generated files and harness files below tests, examples or benches directories.

After the suite and replay qualification pass, the job publishes an advisory Turmoil patch coverage comment through just coverage-patch --scope turmoil. Its marker is independent of the ordinary coverage comment, and publication checks that the PR is open at the tested head. A fast simulation run cannot replace the ordinary report, and a failed collection never supplies a successful report. Coverage percentages and comment failures remain advisory; the simulation, replay and collection verdicts remain mandatory. The ordinary coverage/CRAP job uses ordinary reports only. just coverage-turmoil <output> uses this same collector for local inspection.

The job uses the shared native CI linker, so adding --cfg tokio_unstable to RUSTFLAGS preserves Wild linking. It installs flatc, because the interconnect’s test build takes its generated vocabulary values from nervix-arbitrary, whose WASM protocol bindings are generated when that crate is built.

Every bound nests inside the next, so the innermost expired bound names the stuck run, and the job still has time to upload what it found. This follows the convention of The Suite Watchdog.

BoundValueExpiry
Simulated duration per run1, 15, 30, 60, 85, 90, or 120 seconds by caseThe run fails with the bound and the simulated time reached
Scheduler steps per run200, 20,000, 50,000, 100,000, or 120,000 by caseThe run fails with the bound and the simulated time reached
Real time per run3, 60, 90, or 120 seconds by caseThe run fails naming its phase, steps, and simulated time
Real time per attempt processThe run’s real-time bound plus 30 secondsThe driver kills the process and records an unreported outcome
just test-turmoil tests480 seconds after the buildExit 124; in-progress records name the unfinished runs
just test-turmoil-sweep1,500 secondsExit 124
CI turmoil job30 minutesThe emergency guard around the build, the tests, and the upload

The sweep’s 25 minutes is three times the 8m11s its default 64 seeds took after the build, one attempt at a time, on the 32-thread workstation where it was introduced.

Measured wall times for the qualified revision:

RunWall time
just test-turmoil locally, including compilation1m31s
just test-turmoil-sweep 1000 64 locally11m02s, with a largest process of 131,060 KiB
CI turmoil job, including build and replay check3m21s and 3m18s on the last two qualifying changes
CI Shuttle stage of extra-tests9m03s of a job that took 27m33s inside its 45 minutes
CI tests job53m03s inside its 60 minutes

Scenario Matrix

Every case below is a Scenario in the simulation target. An ordinary run executes its committed seeds twice each, in fresh processes; the sweep replaces the seeds without weakening any assertion.

CaseTestCommitted seedsSimulated / steps / realMain assertion
peer resolutionpeer_resolution_uses_simulated_dns411 s / 200 / 3 sA peer endpoint resolves to its simulated address through Turmoil DNS
host isolationsimulated_hosts_keep_their_own_names_sockets_and_clocks4315 s / 20,000 / 60 sTwo hosts listening on one port are each reached at their own address, a name no host holds fails as not existing without time passing, and a ten-second wait completes only on the simulated clock
bounded CPU workerbounded_cpu_job_runs_on_the_simulated_scheduler1–121 s / 200 / 3 sEvery CPU and memory class runs its job on the scheduler thread, and every queue and reservation returns to zero
typed Arrow exchangetransport::production_transport_exchanges_typed_arrow_batch_and_state_frames_over_simulated_tcp4930 s / 50,000 / 90 sAfter a listener rebind, production TLS, HTTP/2, typed envelopes, and Arrow IPC carry a typed request, a relay payload and generation-qualified state frames
materialized state owner replacementtransport::materialized::materialized_branch_state_transfers_reconnect_to_the_replacement_owner51, 53180 s / 200,000 / 120 sTwo branch placements describe and stream a 3 MiB Arrow payload at revision 1, then a partition after the first received frame interrupts another fetch. After repair and serving transport replacement, both branches transfer at revision 2. The client verifies exact length and digest at both revisions. This exercises the transport and typed checkpoint envelope; server installation, disk durability and whole-cluster recovery are covered separately.
multiple authenticated peers preserve remote failure classestransport::multiple_peers_keep_remote_failure_classes_and_reject_invalid_authentication5730 s / 50,000 / 90 sOne client resolves and exchanges typed Arrow responses with two peers, each peer’s rejected, unavailable, not-ready, and failed outcomes arrive as their own remote failure classes naming their subject, and a dial under a DNS identity the certificate does not name is rejected
partition before connecttransport::network_disruption_respects_deadlines_and_repairs_authenticated_service6185 s / 100,000 / 90 sSetup fails and liveness times out through twelve seconds of partition; repair restores authenticated service
one-way partition before connectThe same6285 s / 100,000 / 90 sThe same, with only server-to-client messages dropped
held authenticated exchangeThe same6385 s / 100,000 / 90 sA one-second liveness probe and a two-second request expire while held; release restores service
partitioned authenticated exchangeThe same6485 s / 100,000 / 90 sBoth deadlines expire; after repair, protocol pings replace the stalled sessions without a restart or pool reset
one-way loss after authenticated exchangeThe same6585 s / 100,000 / 90 sClient-to-server packets are lost after authentication; repair restores service without a restart or pool reset
relay response lost after admissiontransport::relay::relay_reconciliation_and_cancellation_survive_lost_replies7160 s / 50,000 / 90 sA retry to the same receiver process returns the admitted outcome and ACK without a second enqueue, and a late cancellation returns admitted
relay cancellation before grantThe same7260 s / 50,000 / 90 sA cancellation that reaches the receiver first fences the delivery
relay cancellation while reply is lostThe same7360 s / 50,000 / 90 sCancellation wins the admission fence while the body reply is lost
relay cancellation during reconnectThe same7460 s / 50,000 / 90 sCancellation wins the admission fence while the sender reconnects
receiver restart after relay body receipttransport::relay::relay_restart_fences_unresolved_delivery_and_accepts_fresh_work8190 s / 50,000 / 90 sA new process epoch leaves the unresolved delivery indeterminate and admits fresh work
receiver restart after runtime admissionThe same8390 s / 50,000 / 90 sThe same after runtime admission
receiver restart with delayed relay responseThe same85, 103690 s / 50,000 / 90 sA released pre-crash reply confirms only historical receipt; admission stays indeterminate against the new epoch
sender replacement with a retained admitted intaketransport::relay::sender_retirement_reclaims_capacity_while_an_admitted_intake_is_retained9530 s / 50,000 / 90 sRemoving the sender releases a one-item receiver’s capacity while its admitted intake remains borrowed; a recreated sender transport delivers three Arrow rows with a distinct full process identity
backup section partition and repairtransport::backup::backup_section_fetch_is_fenced_and_recovers_after_partition101, 1000320 s / 400,000 / 90 sA forged process identity cannot capture or consume a section; a stalled fetch reaches its deadline; a partitioned fetch fails and succeeds after repair; six 6 MiB materialized column sections, 36 MiB in all, stream in 64 KiB chunks and install as one checkpoint
window section partition and repairtransport::backup::window_sections_larger_than_the_bulk_budget_stream_across_a_partition103320 s / 400,000 / 90 sThe same fences and faults for three window groups of input rows and argument columns, each section classified by its own kind; 36 MiB, beyond the 32 MiB bulk budget, streams in 64 KiB chunks and installs as one branched window checkpoint
backup owner restart during transfertransport::backup::restart_during_backup_transfer_discards_the_process_stage102320 s / 400,000 / 90 sAn incomplete section cannot finish after owner restart, and the replacement process has no prior capture stage
stalled peer isolationtransport::isolation::stalled_peer_cannot_consume_unrelated_capacity_or_leak_reservations91–93120 s / 120,000 / 120 sA stalled peer holds only its own connection’s slots, unrelated work progresses, and every reservation is released

The simulation target also holds 26 runner and driver checks, such as the supervision, bound, clock, entropy, and trace checks and the fresh-process record and replay checks, and two ignored scenarios that only those checks start. The library-level checks use fixed seeds outside the driver and are rerun by name: the execution strategy’s queue, admission-order, and charge checks; the seeded rkyv round trips of seeds 1–12, alternating IPv4 and IPv6; and twelve certificate-clock checks, one of which compares the traces of two fresh processes.

Authenticated Exchange

The exchange cases prove that the simulated path is the production path. A server binds, shuts down, and rebinds its listener in the same host, so listener cleanup is observable. The client then sends a typed request carrying an Arrow batch and the same batch as a relay payload, and each side decodes it with the production codec and checks the values without printing them. The three-host case adds a second peer and a rejected dial whose DNS name the certificate does not carry, so authentication failure is proven over the same connection path that succeeds. The typed Arrow reply archives its native row count with the vocabulary’s fixed-width 64-bit adapter, matching the product’s count representation. The rejection assertions inspect the TransportError at the report’s current context; relay timeout, cancellation, and indeterminate-delivery assertions do the same. This keeps the simulated failure class independent of the report’s diagnostic text while production transport and wire causes remain available underneath it.

The certificate-clock checks, beside the transport, prove that time-dependent authentication follows the simulated clock on both peers: not-yet-valid and expired boundaries of the local and the peer certificate, rejection by the client’s and by the server’s Rustls verifier under skew, the drain of an accepted session at the earlier expiry on both sides, and the setup deadline of a stalled handshake.

The two setup partitions stay in place for twelve seconds of simulated time, so bounded reconnect behavior is observable: setup failures are counted, liveness times out, and no connection appears. The established-link faults first complete an authenticated exchange, then expire a one-second liveness probe and a two-second typed request, and require at least three seconds of simulated time to have passed. Transport readiness and authenticated liveness are checked before each disruption; after each repair, the client reconnects, passes liveness, and completes another exchange. The symmetric and one-way partitions keep both processes and their listeners running; protocol ping deadlines close the unusable sessions on both ends so inbound slot limits do not prevent recovery. Snapshots bound open connections, pending requests, reconnect failures, and simulated sockets throughout, and the transport’s connection counts are checked after shutdown.

Relay Reconciliation And Cancellation

The relay cases drop the receiver-to-sender reply once an Arrow batch has entered the receiver’s queue, then reconnect to the same receiver process. The retained attempt reconciles to its admitted outcome and semantic ACK, and the application sees one enqueue. Cancellation competes with admission through the attempt’s one fence: before the grant, while the reply is lost, and while reconnecting, cancellation wins; after admission, it returns the admitted outcome. The checks hold within the receiver’s retention contract in Ordering, Retry, And Reconciliation. They say nothing about a receiver restart, which the next group covers.

Receiver Restart

The restart cases lose the reply at one of two milestones, after the Arrow body entered the receiver’s queue and after runtime admission, or hold an admitted attempt’s reply. At the milestone the receiver host publishes, the control closure crashes the receiver, repairs the one-way partition or releases the held reply, and bounces the host. The bounced host binds a new transport under the same node name with a distinct process epoch from its seeded entropy. The sender’s retained delivery resolves as indeterminate against that epoch and never enters the restarted receiver’s queue; a released pre-crash reply can confirm only that the old process received the body. A new delivery identity then crosses the rebound listener and is admitted. The fixture checks the Arrow batch and both process identities at the protocol boundary, and that the receiver crashed and restarted exactly once.

Backup Section Transfer

The backup cases send the production typed capture, inventory, and bulk fetch messages. A request that claims another process epoch is rejected before the capture or stream handler runs. The partition case bounds a stalled fetch by its request deadline, leaves the staged section available when the link is partitioned, repairs the link, and reads the verified section. The restart case ends an owner process after its first stream chunk; the incomplete stream fails, and the rebound owner has no stage from that process. These are transport and in-memory staging boundary checks; the Cucumber cluster scenarios verify the server’s archive and restore behavior.

Stalled-Peer Isolation

A hub and two peers run on three hosts. The stalled peer accepts shared management operations and never answers, leaves resource streams unread, and sends a relay batch the hub holds without admitting. The healthy peer keeps exchanging shared management work, liveness, typed Arrow commands, whole resource streams, and admitted relay batches with the hub throughout. Every observation of a host checks its pool connections per peer, leased streams per connection, admissions per direction and subquota, worker queues, and memory budgets against their configured bounds.

Key transitions assert exact values. The stalled peer holds all 32 shared management streams of its connection while liveness, cancellation, and discovery still use the reserved streams, and one more shared operation waits until its own deadline. A burst fills the one bulk worker and its eight-job queue, the two submissions beyond them are refused without a charge, and control work is admitted in the same pass. Unread streams stop at one HTTP/2 window each and release their admission and memory at the five-second progress deadline. The held relay reserves its exact body, largest decoded batch, and scratch, plus its retained grant metadata, until cancellation and release of the borrowed intake.

The fixture then holds the stalled link. The hub’s own liveness deadline ends its probe to the stalled peer while the healthy peer keeps exchanging every traffic class. Removing the stalled peer from membership ends the hub’s operations to it and closes the hub’s pools to it at once, without that peer’s cooperation. Tearing the stalled host down while the link is still held closes the connections the hub opened to it at once, together with the handlers still waiting to answer, and releasing the link closes the hub’s inbound connections from the torn-down peer. The Traffic And Resource Isolation contract is what these values check.

Simulated Restart And Real Process Crash

Sim::crash cancels the crashed host’s Tokio tasks and drops its runtime. Destructors run, and the host’s connections close in the order its tasks are torn down, which Tokio’s task numbering sets. Sim::bounce calls the host’s software again, which rebinds the listener and allocates a new process epoch. The simulated hosts have no filesystem and no background thread of their own.

A simulated restart therefore establishes the transport’s volatile contract: a new process epoch fences in-memory delivery state, an unresolved delivery becomes indeterminate, and the transport never replays it into the new process. It establishes nothing about fsync, power loss, SIGKILL, partial writes, consensus or WASM checkpoint durability, or full-node recovery.

Those claims keep their real-process evidence. tests/features/cluster/process_crash_recovery.feature starts a server process and kills it with SIGKILL before checking recovery, and the Shutdown And Recovery, Consensus Storage And Replication, and WASM State And Recovery chapters own the durable contracts it checks. A Turmoil host bounce is never cited as durability evidence.

Adding A Bounded Scenario

A new case follows the same contract as the existing ones.

  1. Name the claim. Write the interconnect contract the case checks and the fault that challenges it. A publicly observable outcome also needs its Cucumber scenario; the simulation adds fault timing, not public coverage.
  2. Place it with its owner. Add it to the fixture module whose contract it checks, or to a new module declared from transport.rs with its own ownership header.
  3. Declare the scenario. Give it a name unique within its test, a fault plan that says which controls it applies and when, and committed seeds that no other case uses.
  4. Bound it. Start from the fixture’s configuration and set the smallest simulated duration, step limit, and real-time bound that complete the plan with room to spare. Keep the network parameters lossless, so the written plan is the only fault.
  5. Build each host inside its host. Bind a transport per host with SimulatedEntropy named after the host and a SimulatedUtc clock. Share only readiness and completion signals between hosts, run each host body under HostSupervisor::run, and have the observer client wait for every host to finish.
  6. Stay inside the simulated boundary. Use nervix_primitives::time for every wait and instant and nervix_primitives::net for every socket, and call nervix_primitives::task::consume_budget().await at the top of every loop. Never sleep a real thread, read the wall clock, open an operating-system socket, touch the filesystem, or submit storage or unbounded CPU work.
  7. Inject faults at protocol points. Apply link controls from inside a host when the host reaches a decision, or apply host controls from the control closure when a host publishes a milestone.
  8. Record decisions. Record an event at every decision the case asserts, with identities and outcomes only, never payloads, key material, process identifiers, or map-ordered listings.
  9. Prove release. Shut every transport down before its host exits, assert its connection counts afterwards, and check every pool, queue, and reservation the plan touches against its bound.
  10. Register it. Add the test to its invocation in tests/turmoil-inventory.toml, which just test-turmoil holds the suite to: a registered test that does not run, or runs ignored, fails the suite, and so does a simulation test that runs without being registered.
  11. Run it. Iterate with just test-turmoil-simulation <test> --exact, run just test-turmoil-sweep to look for divergence across seeds, then just test-turmoil and just validate.
  12. Keep this chapter current in the same change: the scenario matrix, its bounds, and any new limit.

Findings And Retained Regressions

The simulation changed product code only to remove nondeterminism sources that reached a decision. Its other findings are recorded here and in their owning chapters as current behavior. The qualification run found no further product defect and no behavior-affecting trace difference.

FindingResolutionRetained regression
Each certificate check read the process wall clock on its own: both Rustls verifiers, the transport’s local and peer checks, and the expiry drain deadlineEach TLS bundle carries one TransportClock, which every check readsThe certificate-clock checks in authentication::simulation_tests
The process epoch and relay grant identifiers came straight from operating-system randomnessTransportEntropy supplies them, from the operating system in production and from a seeded source in the simulationThe fresh-process trace comparison in the certificate-clock checks, and every committed seed
Walks over concurrent maps that cause effects followed per-process hash orderThose walks run in semantic key orderEvery committed seed
Transport shutdown closes the connections peers opened, with the handlers serving them, at once rather than draining themThe behavior is stated in Cluster Interconnect and Shutdown And RecoveryThe stalled-peer teardown
A CPU queue refusal reaches a typed-request caller as RequestError::Encode when the first encode is refused, but as RequestError::Transport when an admitted request is refused at a later stageThe stalled-peer case asserts both shapes and counts every failure as exactly one queue refusalThe stalled-peer case
Two runs of one seed in one process diverged after a simulated crash, because teardown order follows Tokio’s process-wide task numberingEvery attempt and every replay runs in a fresh processSeed 1036 of the receiver restart with a delayed relay response
A panic in a host task surfaced only as Tokio’s generic runtime-shutdown messageThe runner reports the first panic of the scheduler thread, and supervises cleanup as its own phasesimulation_host_task_panic_reports_its_own_message, simulation_cleanup_panic_fails_a_completed_run

Guarantees And Limits

The simulation guarantees, for the audited path:

  • The production transport runs over Turmoil TCP and DNS only, and no scenario reaches the host network.
  • Transport deadlines follow the simulated clock, certificate validity follows the supplied UTC clock, and neither falls back to the host clock.
  • One scenario, seed, and set of inputs makes the same recorded decisions, at the same simulated times, in every fresh process of the same build on the same host configuration.
  • Every failure leaves a record that replays the same inputs in a fresh process and reports whether the failure reproduced.
  • Every run, attempt, suite, and sweep ends inside a real-time bound, including one whose host blocks the scheduler thread.

The audit behind those guarantees covered the boundary’s selection of sockets, timers, and CPU jobs and the simulated resolver, the transport identities and certificate clocks, request and relay deadlines, the map walks with side effects, the bounded CPU executor, and the runner. Its limits:

  • The runner’s real thread and wall clock decide only when a test fails, never how a request proceeds.
  • Execution storage jobs use a real blocking thread and are outside every case.
  • Default worker counts follow the host’s CPU count, so a replay is established for the tested host configuration, not independently of host capacity.
  • Rustls and AWS-LC keep their randomness, so traces compare authenticated decisions, never ciphertext.
  • The TCP model neither retransmits nor models kernel timing, and Turmoil 0.7.2 has no disconnect control.
  • A simulated host crash is not evidence of durability, as described above.

Prerequisites For Whole-Cluster Simulation

Running whole Nervix nodes under Turmoil needs more than substituting TCP. Each of these is an open prerequisite, not part of this simulation’s claim:

  • Raft and gossip entropy. OpenRaft election timing and Chitchat gossip draw their own randomness. Both would need a seeded owner without changing production entropy.
  • Storage and crash semantics. Fjall runs background threads and writes real files. A simulated store would need defined filesystem, fsync, and crash behavior, kept apart from the real-process durability evidence.
  • Host attribution. Work a node spawns, including blocking tasks, timers, DNS queries, and sockets, must run on the host that owns it; code that escapes to another runtime or thread escapes the simulation.
  • Domain time. Domain-clock authority, generations, and deadlines would need controlled time that preserves the Domain Clock contracts.
  • Connectors. Every connector’s I/O, acknowledgement, and commit boundary would need to be classified as a simulated fixture or a real-process test.
  • Host-independent capacities. Execution worker counts would need fixed values, so admission traces stop depending on the host’s CPU count.

Combining Shuttle’s scheduler with Turmoil’s network is a separate question. Shuttle owns the interleaving of in-process tasks and Turmoil owns a runtime per host; the two scheduler modes are mutually exclusive by build. Driving Shuttle over a simulated network would need a network substrate integrated with Shuttle’s own scheduler, such as turmoil-net, and is not part of either suite.

Qualification Evidence

The simulation was qualified on 25 September 2026 at revision 7032bca2, which also contains the final Shuttle qualification. The results below are from that run, except the CI row, which is from the change that recorded it.

RunResult
just test-turmoilPassed in 1m31s including compilation: 14 execution tests, 12 interconnect library checks, and 34 simulation tests, with the ignored fresh-process probes started by their own checks
just test-turmoil-sweep 1000 64Passed in 11m02s, inside its 25-minute budget: all 16 cases, each of 64 seeds twice in fresh processes, 2,048 attempts with identical outcomes and traces. The largest process used 131,060 KiB
just test-turmoil-replay-checkThe injected failure left one record for the partition-before-connect case, seed 61, and a fresh process reproduced its outcome and trace
just test-shuttleAll 70 checks passed in ordinary mode and under the uncontrolled-nondeterminism detector, 140 invocations
just test-interconnect, just test-execution44 and 11 tests passed in the normal build
just validate, just ratchetPassed, including the dependency and scheduler-conflict checks, with unchanged debt counts
Real authentication and transport, one and three nodesinternal_tls, interconnect_health, interconnect_lifetime, and interconnect_observability: 19 scenarios and 159 steps passed
Real-process crash recoveryprocess_crash_recovery: 2 scenarios and 57 steps passed with SIGKILL
CIThe turmoil job passed in 3m18s, the Shuttle stage in 9m03s, and the tests job in 53m03s

No product defect and no behavior-affecting trace difference was found in the qualification. Seed 1036 remains a committed regression, and no failing seed was dropped.

The commands in this chapter were run again on d2cec5f2, the revision it documents, on a 32-thread workstation. just test-turmoil passed in 1m01s with a warm build cache, just test-turmoil-replay-check passed in 2 seconds, and just test-turmoil-sweep passed all 16 cases over its 64 seeds in 7m05s, leaving no failure record, with a largest process of 130,764 KiB. The example investigation above was recorded and replayed on the same revision.

Routing the simulation’s sockets, timers and CPU jobs through the primitive boundary was requalified on 29 September 2026 on a 24-thread workstation that other builds shared. just test-turmoil passed, including the host isolation case and the boundary’s own simulated-host checks, and just test-turmoil-replay-check reproduced its injected record. Every case of just test-turmoil-sweep passed its default 64 seeds twice in fresh processes with identical traces, leaving no failure record. Under that shared load the sweep ran past its 25-minute budget during its last test, the receiver restart group, which then passed its 64 seeds alone in 2m30s.

Property Testing And Fuzzing

Bolero is Nervix’s default property-test engine for representation correctness. Each property is an ordinary Rust test beside its production owner and a registered custom libFuzzer target. The generator, production path and assertion are the same in both modes. tests/bolero-targets.toml is the single inventory. Its IDs remain stable while a generator-domain version advances when the current representable shape changes.

Property Contract

A lossless encode/decode or representation conversion asserts equality with the complete original value over bounded, valid current inputs. Equality includes every significant field and order, branch and domain identity, nullability, type and sensitivity. Where PartialEq is not the contract, the property uses an explicit complete oracle. Float bit patterns and Arrow schema, logical values and validity are examples; physical buffer layout need not match when it is not public semantics. Canonicalizing, lossy and one-way transformations state and test their actual contract. Malformed input has separate rejection and boundedness targets, so a round-trip generator cannot pass by producing mostly invalid values. Reference models, operation sequences, scalar/SIMD differential checks, encoded-size bounds and segmentation invariance are other useful Bolero properties.

Generators stay in test code, construct bounded valid values, cover supported variants and deliberate boundaries, and never reintroduce historical shapes. They may not add test-only product APIs, row carriers, persistence for volatile state, or inner-layer parser dependencies. A Bolero property is independent of live services. Public behavior, concurrency and recovery retain their own Cucumber, Shuttle, Loom, Turmoil and external Chaos evidence.

Target And Representation Register

IDPackage and invariantDomainOrdinary casesInput limit
client-emitter-wirenervix-client-wire native emitter frame round-trip equalityall current request, reply, refusal and settlement variants with bounded exact schema, window, identity, branch and batch fields, v1256128 bytes
client-requestsnervix-client-wire complete client request equalityevery current request family, identities, positions, schemas and opaque I/O payloads, v11282048 bytes
client-repliesnervix-client-wire complete reply equalityevery reply family, dispositions, ordered diagnostics and structured command metadata, v11284096 bytes
client-eventsnervix-client-wire complete event equality and retained graph ownershipevery unsolicited event, discovery, clocks and opaque graph text, v11282048 bytes
client-streamsnervix-client-wire complete stream equality and shared chunk ownershipall upload, download and restore roots and outcomes, v11282048 bytes
client-upload-archivesnervix-client-core complete directory tree and deterministic archive bytes against an independent tar decoderempty directories, nested and GNU long paths, arbitrary file bytes and block boundaries, v11284096 bytes
client-reply-transfernervix-client-wire exact segmentation bytes, reply equality and rejected append isolationsingle frames and ordered transfers, incorrect identities and duplicates, v1128256 bytes
client-row-viewsnervix-client-wire complete retained Row viewsall scalar cells, nested vectors, fixed arrays, nulls, redaction, branches and float bits, v1128512 bytes
client-arbitrary-framesnervix-client-wire typed verification and decoding within budgetsarbitrary bytes at all eight frame roots, v125616385 bytes
client-frame-corruptionnervix-client-wire typed verification and decoding within budgetscurrent frame truncation, bit, offset, length and size mutations, v125664 bytes
client-discriminatorsnervix-client-wire validate discriminants before accessing union membersall byte-valued request tags, v125664 bytes
client-arrow-rowsnervix-server Arrow logical values and validity against production Row viewsscalar columns, nullable and nested lists, arrays, selected rows, concrete branches with every supported key field type, frame splitting and explicit redaction, v2128512 bytes
client-arrow-selectionnervix-server whole-input typed rejection without partial outputinvalid row bounds, order, duplicates and branch alignment, v2128512 bytes
client-binding-rowsnervix-client-ffi complete column values and retained frame ownershipnative scalar bits, states, string/bytes offsets, branch keys and nested cells, v1128512 bytes
client-ffi-field-descriptorsnervix-client-ffi field descriptor ABI equality both ways: a host’s level-by-level description becomes the complete original field, and the field and level accessors read back its canonical description as the header’s nx_type valuesevery scalar inside 0 to 32 LIST and FIXED_LIST levels of 1 to INT32_MAX elements, either nullability and sensitivity, names from the whole name rule spelled in any letter case, v12562048 bytes
client-ffi-field-descriptors-malformednervix-client-ffi invalid-argument refusal that leaves the field list as the accepted calls built itunknown types, lengths on scalar and LIST levels, empty and oversized FIXED_LIST levels, a 33rd list, refused names, elements without an open field, fields before the open one’s element, an open last field, and reads past a field’s last level or past the last field, v12564096 bytes
client-processor-choice-requestnervix-client-wire processor choice request round-trip equalitycurrent targets with relay context, search, page and identity, v112832 bytes
nspl-expressionnervix-nspl expression render and reparse equality through a statement and the standalone expression, expression-list and route-construction readersNSPL expressions of every form, v5256512 bytes
nspl-modelnervix-nspl canonical CREATE render and client and server reparse equalityNSPL Models of every family, client emitters included, v72562048 bytes
nspl-archive-modelnervix-nspl complete ordered canonical Model equality through export, restore reads, reparse and re-exportcluster/domain archives with multiple ordered documents and every Model family, v81284096 bytes
nspl-statementnervix-nspl canonical statement render and client and server reparse equalityNSPL statements of every form, session-only forms included, v52562048 bytes
nspl-statement-textnervix-nspl edited statement text is rejected with located diagnostics or reads as canonical statementsedited canonical text, v52562048 bytes
nspl-expression-textnervix-nspl edited expression text reads as the same expression, or as none, through a statement and the standalone expression, expression-list and route-construction readersedited canonical expressions, v3256512 bytes
nspl-format-documentnervix-nspl-format keeps statements and comments and is idempotentdocuments with gaps, comments and either line ending, v52564096 bytes
nspl-format-textnervix-nspl-format refuses unparseable text or formats it keeping its statementsedited documents, v52564096 bytes
models-namesnervix-models name text, conversion, JSON, archive and Model-name widening equalityevery name type, v1256256 bytes
models-name-validationnervix-models name parsing matches the name rule; decoders accept only canonical textarbitrary text, v1256256 bytes
models-timestampsnervix-models integer, RFC 3339, JSON, archive and chrono equalityevery signed Unix nanosecond, v125664 bytes
models-timestamp-textnervix-models RFC 3339 text reads as its reference instant or a typed errorgenerated RFC 3339 text, v125664 bytes
models-domain-clocknervix-models period, skew and rate value and bit equalityevery period, skew and positive finite rate, v125664 bytes
models-domain-clock-validationnervix-models clock text and numbers read in range or fail typed; decoders refuse invalid valuesarbitrary text and numbers, v125664 bytes
models-durationsnervix-models a duration reads back from the text humantime writes for it; only one within one of each unit of the longest duration may be refused as too longevery duration, v125616 bytes
models-duration-textnervix-models duration text reads as humantime reads it or fails typed, and never panicsarbitrary duration text, v125664 bytes
models-json-pathsnervix-models path text, JSON and archive equalitypaths up to the step limit, v12561024 bytes
models-json-path-validationnervix-models path text reaches a fixed point or fails typed; decoders admit the constructor’s step countsarbitrary path text, v1256512 bytes
models-batch-limitsnervix-models message and size limit text, JSON and archive equalityevery limit and unit, v125664 bytes
models-batch-limit-validationnervix-models limit input reads in range or fails typed; decoders refuse out-of-range limitsarbitrary limits, v125664 bytes
models-identitiesnervix-models execution reference, upload identity, endpoint and pool-bound equalityvalid identities, v1256512 bytes
models-identity-validationnervix-models identity parsers and decoders accept exactly their rulearbitrary identity text and bounds, v1256512 bytes
models-archived-modelsnervix-models Model and statement archive equality, resource-version widening and pinningvocabulary Models and statements of every family and form, full-width counts included, v52564096 bytes
models-archived-countsnervix-models count-bearing Model, statement, transaction identity and WASM inspection archive equalityzero where permitted, u32::MAX + 1, u64::MAX and generated native counts, v125616 bytes
consensus-archived-countsnervix-consensus complete record equality through the production bounded storage codectransaction commands, limits, positions, progress, failures, outcomes, plan and report headers with boundary and generated counts, v125616 bytes
registry-archived-modelsnervix-server complete Model equality through the registry’s sealed storage codecevery vocabulary Model family with pinned resource versions and full-width counts, v32564096 bytes
runtime-window-archived-countsnervix-server histogram delayed-removal archive equalitycurrent removals with arbitrary expiry and boundary and generated bucket indices, v125632 bytes
backup-record-manifestnervix-backup complete record and manifest encode/decode equalityall eleven current record kinds, lifecycle/catalog variants, every cut, full-width counters and ordered metadata, v42564096 bytes
backup-runtime-state-recordsnervix-backup complete runtime state record equalityguest/materialized descriptors and identities, empty/multi-partition offsets and ordered branch lifecycle with bit-exact nested fields, v32564096 bytes
backup-materialized-identitiesnervix-backup complete materialized descriptors and ordered scalar identity group equalityempty and one-to-four-group generations, unbranched and branched identities, nested typed fields, float bits and per-row watermarks, v22562048 bytes
backup-materialized-columnsnervix-server exact-schema Arrow/native generation fidelity and stored checkpoint re-exportnullable, sensitive, nested and byte columns; complete headers, ordered identities and original stored Arrow bytes, v21284096 bytes
backup-branch-state-descriptorsnervix-backup complete deduplicator and window descriptor equalityempty, unbranched and branched keyspaces and windows, model digests, row sequences and watermarks, group counts and delayed histogram removals, v12564096 bytes
backup-deduplicator-keysnervix-server typed Arrow key group and restored keyspace fidelity, and the streamed checkpoint byte-identical to the runtime’s encoding of the same keysevery DEDUPLICATE ON key type with nulls, narrow integers, arbitrary float bit patterns, text, bytes, datetimes and list keys, repeated keys and bounded groups, v11282048 bytes
backup-window-groupsnervix-server window input and argument group and native checkpoint fidelity with equal aggregatesup to 192 admissions and interleaved steps over nullable integer, string and boolean arguments, concrete multi-type keys with bit-exact finite floats and fixed-offset datetimes, every exact aggregate family and a delayed histogram percentile, bounded groups sealed whole or split under generated identity limits, v3641024 bytes
backup-complete-archivesnervix-backup complete export/extraction, restore-read and deterministic re-export fidelitycluster/domain scopes, resources included/omitted, every cut and supported state kind, empty/unbranched/multi-group materialized generations with valid Arrow columns, deduplicator key groups and window row and argument groups, synthetic raw payloads at tar block boundaries and long paths, v32564096 bytes
backup-malformed-recordsnervix-backup safe typed validation of current record headers and valuesall eleven kinds, truncated/altered headers, invalid current fields and bounded arbitrary bytes, v32564096 bytes
backup-corrupt-archivesnervix-backup rejection before verified restore contents are returnedchanged lengths/digests/kinds, reordered/missing/extra sections, invalid records, materialized, deduplicator and window group metadata/membership, damaged Arrow sections, truncation and bounded arbitrary tar inputs, v32564096 bytes
console-pending-backupsnervix-web-console pending backup entry and tab storage round-trip equality, with forgetting only by its own referenceany execution reference and query text, no domain or one in any spelling the name rule admits, recorded over an earlier record, v12561024 bytes
console-pending-backups-malformednervix-web-console exact read-back or refusal of a tab’s stored record, which a refusal forgets while other entries staymissing, canonical, edited, upper-cased and drawn reference, query and domain entries beside an unrelated entry, v12561024 bytes
console-restore-draftsnervix-web-console restore draft lowering equality and existing-user option textevery RESTORE scope, source, mode, runtime state and lifecycle, domains in any spelling the name rule admits, every policy, v1256512 bytes
backup-capture-wirenervix-interconnect complete capture/drain/inventory request and response equality and fetch request equality through each request’s declared class limitevery drain action and counter, every success/refusal, empty and ordered inventories of all twelve kinds, zero/full-width lengths and counters, arbitrary digests and bounded Unicode paths, v12561024 bytes
backup-capture-wire-malformednervix-interconnect typed decode refusal or complete stable re-encoding, with management/bulk reservations releasedtruncated, bit-flipped, overwritten and arbitrary capture/drain/inventory request and response bytes and fetch request bytes, v12561024 bytes
backup-section-mappingnervix-server total and injective capture-kind/archive-content mapping and distinct validated archive pathsall twelve section kinds over generated identities, group ordinals and branched/unbranched fingerprints, v1256128 bytes
restore-installation-wirenervix-interconnect complete installation request and success/refusal wire equality through the bulk class limitall actions, authority, all eight runtime state kinds branched and unbranched, zero/full-width lengths, arbitrary digests, payload and inventory, v2256128 bytes
restore-installation-storagenervix-server complete staged checkpoint and publication record equalityauthority, placement, revision, payload and inventory, v1256128 bytes
restore-staging-kept-measurementnervix-server the measurement of unpublished restore staging a store kept equals one read afresh from every staged entry, after every stephistories of up to ten steps over two domains, two entities and three generations: staging of up to two whole chunks and a partial one that completes, fails its digest or is cut short, retried staging, sweeps that retain or reclaim, publication and reopening, v19696 bytes
consensus-state-recordsnervix-consensus complete recovered state equality through the state writer, a real keyspace and recovery, for a first revision and a second stored as changesevery record family with replaced and removed records, every transaction, outcome and command execution state, names across the whole name rule and boundary counts and times, v212865536 bytes
consensus-state-corruptionnervix-consensus typed InvalidState recovery failure or a canonical state its readers can rely onflipped, truncated, extended, rekeyed, copied, unknown and missing records of a stored revision, drawn from bytes of their own, v225665600 bytes
consensus-raft-recordsnervix-consensus complete Raft record equality through the storage codec, and keys that name their position or sectionvotes, log positions, entries of every command, state metadata, snapshot manifests and sections, v22564096 bytes
consensus-raft-records-malformednervix-consensus typed storage failure or a value that stores back to the same recordarbitrary bytes as every Raft record, log key and section key, v22564096 bytes
registry-stored-modelsnervix-server complete Model equality through listing and keyed reads of a reopened registry, from its journal and from flushed tablesModels of every family in domains and with names across the whole name rule, v2644096 bytes
registry-stored-models-corruptionnervix-server typed registry storage failure or Models whose own keys are exactly the stored keysflipped, truncated, extended, recased, extended-key, copied and unknown records, v21284096 bytes
runtime-state-keysnervix-server exact decoded placement projection, and prefixes, index and chunk keys that reach no other placementevery state kind with schema, generation, Model kind and name and branch key, names across the whole name rule, v12562048 bytes
runtime-state-keys-malformednervix-server typed storage failure or a placement whose own key is the stored keydamaged and arbitrary keys, v12562048 bytes
runtime-state-chunk-keys-malformednervix-server typed chunk, storage-format or decoding failure, or a segment set whose placement key decodes canonically and whose chunk prefix and offset respell the keydamaged and arbitrary chunk keys, v12562048 bytes
runtime-state-staging-namespacesnervix-server the domain each key names after its namespace, and a maintenance walk that visits every namespace once in key order without passing another’s keyplacement, index and chunk keys of up to three domains in the initial namespace and restored generations of any value, v1644096 bytes
runtime-state-staging-namespaces-malformednervix-server typed storage-format failure at the first key whose namespace does not decode, or the namespace and domain the key’s leading bytes spell exactlydamaged and arbitrary keys, v11282048 bytes
runtime-state-identity-recordsnervix-server complete handoff and forced-recovery record equality with branch values by archived bits, and key identitytransitions with checkpoints of every placement kind, v12564096 bytes
runtime-state-identity-records-malformednervix-server typed decode failure or a record that stores back unchangedarbitrary bytes as every identity record, v12564096 bytes
runtime-state-snapshotsnervix-server every owner’s complete restored snapshot through its codec and the inline checkpoint envelopeKafka offsets, branch lifecycles, deduplicator keyspaces with float bits and branch-aggregated metrics, v12564096 bytes
runtime-state-snapshots-malformednervix-server each kind’s typed decode failure or a value that stores back to the same payloadarbitrary bytes as every snapshot payload, v12564096 bytes
runtime-state-store-checkpointsnervix-server byte equality at the stored revision through both readers and every reader of a backup cut’s listing, which lists exactly the domain’s checkpoints in key order and by kind with their stored sizes, before and after the database reopensup to three placements of one domain stored inline, as chunked streams or in a published restore generation at, beside and between the chunk boundaries, beside an unpublished generation and another domain, v2644096 bytes
runtime-kafka-offset-capturesnervix-server every offset and topic schedule the replica’s cancellable reader restores from a capture’s native checkpoint, a written length within the capture’s reserved bound, and a check before every native entryup to three topics of three partitions at any offset, and three topic schedules, v12562048 bytes
runtime-kafka-offset-transfer-headersnervix-server exact revision, digest and payload from a checkpoint stream cut at every position and into generated chunksadvancing revisions beside the held and described ones, payloads of up to 128 bytes, v12561024 bytes
runtime-kafka-offset-transfer-headers-malformednervix-server typed length or revision refusal, or the revision and digest the header spells, with exactly the bytes behind the header forwarded at every splitarbitrary streams, half of them shorter than the header, with revisions beside the one they spell, v12561024 bytes
runtime-window-snapshotsnervix-server complete sealed window equality, and nothing for another branch lifetimeempty or up to 192 rows, concrete multi-type or absent branch keys, boundary sequences, nullable string arguments of zero or 2049 bytes and up to 96 delayed removals; generated 4–16 KiB column limits include IPC framing and split columns independently of the 1–32 KiB identity/typed limits, v21282048 bytes
runtime-window-snapshots-malformednervix-server typed window snapshot failure or a window that seals back to the same bytessealed windows damaged once and arbitrary bytes behind the magic, v12564096 bytes
runtime-window-sealed-checkpointsnervix-server a window sealed in quota-owned pieces byte-identical to its resident encoding and restored completelyempty or up to 192 rows with bit-exact concrete branch keys or unbranched execution, boundary sequences, nullable string arguments of zero or 2049 bytes and up to 96 delayed removals under generated 4–16 KiB column and 1–32 KiB identity/typed limits, v2642048 bytes
runtime-arrow-bodiesnervix-server relay body and sealed snapshot section Arrow IPC equality: exact schema, values, nulls and float bits; several sections refused by the one-section decoders and concatenated by the schema-free oneevery field type with nulls, nested lists, masked list elements, empty and sliced views and boundary values, v21282048 bytes
runtime-arrow-bodies-malformednervix-server typed refusal of a misframed or undecodable Arrow body, or a batch of the expected schema that encodes and decodes back to itself, without allocating from a declared lengthrelay bodies and sealed sections truncated, flipped, overwritten, shifted or trailed, and arbitrary bytes, v22562048 bytes
resource-store-layoutnervix-server complete manifest and archive read-back for every installed versionnames across the whole name rule and those spelled like the store’s own directories, staging cleanup and removal of another version, v164512 bytes
client-producer-batchesnervix-server producer batch equality from the client library’s stream to the node’s batch, with exact row and byte limitsevery field type with nulls, nested lists and sliced views, v11282048 bytes
client-producer-batches-malformednervix-server refusal with a batch defect or a batch within the ingestor’s schema and row limitdamaged producer streams and arbitrary bytes, v12562048 bytes
codec-schemaful-rowsnervix-server WIRE JSON, WIRE CBOR and WIRE AVRO row round-trip equality through one group builderregistry-accepted codecs over each format’s value domain, generic and exact wire types and shuffled wire field order, v12564096 bytes
codec-schemaful-payload-groupsnervix-server each payload decodes in a group exactly as alone; refusals are typedcodec payloads interleaved with damaged bytes, swapped values and changed keys, v12564096 bytes
codec-json-non-finite-floatsnervix-server the JSON projection of non-finite floats: optional top-level fields read back as nulls, required fields and list elements refuse the payloadWIRE JSON and WIRE CBOR codecs over every float bit pattern and field type, v12564096 bytes
codec-transforming-numbersnervix-server number equality through the JAQ-native JSON, YAML, TOML and CBOR codecs and a protobuf codec with unchanged programs: F64 and F32 bits, alone and in lists, and I64 values; a protobuf singular zero reads back positivegenerated batches of one schema of numbers over every finite float bit pattern, v12561024 bytes
jaq-native-numbersnervix-jaq number equality through the identity program and each numeric native format’s writer and reader: float bits, integer value and signednessfinite floats of any bit pattern, boundary floats and 64-bit integers, TOML within its signed range, v1256256 bytes
remote-relay-rowsnervix-server routed relay batch equality from the sender’s payload to the receiver’s rows, branch key bits and watermarksevery field type, concrete and absent branches and per-row registrations, v11282048 bytes
remote-relay-rows-malformednervix-server the first payload defect in the receiver’s check orderforeign bodies, miscounted watermarks and registrations and invalid branch keys, v11282048 bytes
relay-wire-messagesnervix-interconnect relay grant, reply, admission, acknowledgement and connection binding equality through the bounded codecevery message variant, branch key values with any float bits and per-row metadata, v12562048 bytes
relay-wire-messages-malformednervix-interconnect typed decode failure or a message that encodes and decodes back to itself, with every allocation of the decoder freeddamaged encodings of every message and arbitrary bytes, v12562048 bytes
client-producer-link-framesnervix-server complete equality of every client producer link frame through the bounded duplex codec of its stream, with nothing left chargedthe opening message, every serving-node frame and every owning-node answer, with schemas of any field type, limits and counts at their ends, opaque batch bytes, every refusal, outcome, admission state and end reason, v12562048 bytes
client-producer-link-frames-malformednervix-server typed decode failure, never an admission or execution refusal, or a frame that encodes and decodes back to itselfdamaged encodings of every link frame, frames read as another direction’s type and arbitrary bytes, v12562048 bytes
client-consumer-stream-framesnervix-server complete equality of every client consumer stream frame through the bounded duplex codec of its stream, with nothing left chargedthe opening message, every serving-node frame and every owning-node answer, with schemas of any field type, policy durations from zero to the longest, every answer, refusal and settlement outcome, delivery headers across their ranges with and without a branch fingerprint, and chunks from empty to the largest one sent, v12562048 bytes
client-consumer-stream-frames-malformednervix-server typed decode failure, never an admission or execution refusal, or a frame that encodes and decodes back to itselfdamaged encodings of every stream frame, frames read as another direction’s type and arbitrary bytes, v12562048 bytes
client-emitter-deliveriesnervix-server client emitter delivery equality from the sink’s Arrow IPC stream to the client library’s batch: the Arrow schema of exactly the declared fields, every delivered row in order, values, nulls and float bits; refusal under another row count or other fieldsevery field type with nulls, nested lists and sliced views, deliveries of adjacent and scattered rows, v11282048 bytes
wasm-generated-poolsnervix-server generated column pool equality from a WASM guest’s Arrow IPC stream to the node’s batch: unnamed fields, types, nullability, values, nulls and float bitsevery field type with nulls, nested lists and sliced views, streams ended by the marker or closed, v11282048 bytes
wasm-generated-pools-malformednervix-server a typed generated-IPC or batch-count defect, or one batch of unnamed columns that writes and decodes back to itselftruncated, flipped, overwritten, shifted, trailing and arbitrary pools, v12562048 bytes
simd-checked-lanesnervix-simd-kernels checked integer arithmetic equals scalar overflowing arithmetic at every SIMD levelall integer widths, runs and shared operands, v1256128 bytes
simd-constant-divisionnervix-simd-kernels constant quotients and remainders equal checked operations at every SIMD levelall integer widths, divisors, scalar fallback and tails, with full-range signed overflow, v1256128 bytes
deadlock-evidencenervix-deadlock complete evidence encode/decode equalityevery finding and review variant, complete shared-reader cycles, cycles up to the thread bound with omitted threads, texts cut inside multi-byte characters, absent context and extreme identities and times, v42564096 bytes
deadlock-evidence-malformednervix-deadlock arbitrary bytes, bare or behind a valid evidence header, fail typed or decode within every bound to evidence that encodes back to itselfarbitrary bytes, v12561024 bytes
deadlock-evidence-boundsnervix-deadlock a valid encoding that breaks one bound is refused as out of boundsone broken bound of findings, cycle threads, omitted threads, identities, lost findings or kept texts, v12561024 bytes
deadlock-evidence-summarynervix-deadlock the summary a qualification prints counts each finding once by kind, keeps repeated deliveries apart from lost findings, agrees with qualification, and names every count onceevery finding variant in every scope, reviewed and unreviewed cycles, extreme repetition and loss counts, v32564096 bytes
branch-membershipnervix-branch-instances owner steps against the specified visible-set contract: each claim, admission, eviction, expiry and release publishes exactly the current owner lifetime’s membership, and a step that changes none publishes nothingbounded claim, admit, expire and release sequences over six branch keys, v1256256 bytes
task-status-transitionsnervix-server task status publishes the complete healthy or failed status, preserving retry on error-only changes64 transition bytes over current status and retry values, v125664 bytes
entity-freeze-transitionsnervix-server freeze publications contain exactly the coordination owners whose holds remain active64 insert/remove bytes over eight coordination identities, v125664 bytes
replica-progressnervix-checkpoint-replication replica reports, offers and announcer steps against the monotonic quorum contract: each replica’s progress is the highest revision it reported, a count of replicas holding a revision never falls, and an offered revision keeps exactly one announcer until every assigned replica holds it or the replicated state is gonebounded report, offer, step, cancel and retire sequences over four replicas and sixteen revisions, v1256256 bytes
replica-catch-upnervix-server an owner’s catalog of branch checkpoints and a replica’s catch-up rounds against convergence: whatever catalog reads and branch fetches fail and however announcements are lost, repeated or reordered, the replica holds every branch its lifecycle names at the owner’s revision once reads and fetches succeed, never records holding what its storage does not, and every listing reaches it through its wire form and encoding unchangedbounded start, publish, stop, catalog replacement, naming, eviction, lifecycle, announcement and round sequences over four branches, unbranched work included, v1256256 bytes
typed-reportnervix-lint-report complete compiler-report serialization equalityempty and multi-item findings, arguments and audit candidates; all current audit patterns, escaped Unicode text, full-width spans and counts, expansions and completion metadata, v325632 bytes
typed-source-contractnervix-lint-report complete source-contract parsing equalitybounded current execution-contract annotations, v125632 bytes
typed-site-unionnervix-lint-report authored-site deduplication preserves every configurationrepeated ordinary/modeled findings over bounded current sites, with complete current reports, v325632 bytes
typed-source-syntaxnervix-lint-report exact cast/field counts and complete source/site representation equalitybounded inactive Rust casts, testing-gated tuple fields, raw identifiers and Unicode comments with exact locations, v125632 bytes
expiry-map-operationsnervix-expiry-map operation sequences against an IndexMap reference: iteration order, lengths, oldest entry, lookups, shared key allocations and one drop per valueup to 480 weighted insert, shared insert, keyed and oldest removal, lookup, shared-key copy and clear steps over 96 keys, v12561024 bytes
vm-decimal-rounding-floatsnervix-vm round(value, digits) over F64 and F32 columns against the exact decimal expansion, the identity on values holding no more digits, symmetry about zero and lane null and failure classificationany bit pattern, values rounded within their own digits, decimal-looking values and exact binary ties, counts near, within and beyond the bound, v12561024 bytes
vm-decimal-rounding-integersnervix-vm integer rounding against a 128-bit reference, overflow failures, lane nulls and digit columns of every integer type clamped to the boundevery integer width, values anywhere and beside both limits, counts near, within and beyond the bound, v12561024 bytes
approx-into-roundingnervix-approx-into integer-to-float and F64-to-F32 rounding against a bit-level IEEE 754 reference, and float-to-integer truncation against an exact reference128-bit patterns read at every width, integers and floats beside powers of two, ties and the integer limits, any F64 and F32, v125664 bytes
approx-into-exactnervix-approx-into lossless round trips on the exact domainintegers whose significant bits fit each float’s significand, every non-NaN F32 through F64, v125632 bytes
simd-json-escapesnervix-simd-kernels JSON escape block masks and row bits at every level against the scalar class, and the first offset defectsparse and dense buffers up to 1,100 bytes, ascending offsets not starting at zero, empty, negative, out-of-buffer and descending offsets, v12564096 bytes
simd-paced-admissionnervix-simd-kernels packed admission bits at every level against the scalar definitionreached centers anywhere and at the timeline’s ends, every period reduction, skews to beyond half a period, timestamps beside edge and interior centers, v12562048 bytes
simd-elapsed-histogramsnervix-simd-kernels layout refusals against an integer reference, and latest instant, totals and buckets at every level against HdrHistogramunits from a nanosecond to any 32-bit count, highest units to 2^31, up to six figures, instants before, beside half units and after the reference, v12564096 bytes
simd-flag-packingnervix-simd-kernels packed flag words at every level against scalar shifts, appended to held wordsruns of up to 600 flag bytes of any density, v12562048 bytes
simd-window-runsnervix-simd-kernels validity, boolean, sum, extreme, non-finite and reverse folds against scalar folds at every level; compensated sums exact for integers and within their bound of an exact sum for floatsup to 300 values read at every width and as floats, unaligned validity and boolean bitmaps, v12564096 bytes
simd-window-momentsnervix-simd-kernels means, M2 and paired cross moments against exact addition/product references, and exact histogram placementup to 128 finite paired values, independent sliced null masks, constant overflow-scale runs, edges and clamps, v12564096 bytes
columnar-json-rowsnervix-columnar-json written rows against an independent rendering of their logical values and the first refused nullevery supported scalar, list, fixed-size and nested list column, sliced, every null policy and number and bytes encoding, v12568192 bytes
bounded-write-limitsnervix-bounded-write whole writes within the limit, capacity within it, and the outcome classification against a reference of the rulewrites, encoder failures and writing on after a refusal under limits of 1 to 1,021 bytes, v12561024 bytes
connector-row-request-divisionnervix-connector row request division against the documented halving rule, both limits and the measurement bounddeclared and native row and byte limits, up to 160 rows, measures that grow and shrink with a candidate, v12561024 bytes
connector-confirmation-windownervix-connector confirmation window outcomes, end instant and in-flight bound against a reference model of the documented window rule, so no record confirmed before the publish ends is left unresolvedMAX one to six, ACK TIMEOUT one to 40 ms, up to 24 records refused at hand-over or answered confirmed, refused or failed within, at, past or never by their deadlines, pauses between hand-overs and a driver failure, on a paused clock, v1256512 bytes
emitter-batch-subdivisionnervix-server batch candidates against the documented rule, message limit, compatibility and encoding boundup to 96 members in three compatibility classes, failing containers, encodings that grow and shrink, v12561024 bytes
emitter-batch-packingnervix-server packed payloads and rejected rows against the documented packing of exact member encodings and container framinggenerated schemaful codecs and rows in carriers across two relays, three branches, three envelopes and seals, MAX SIZE from below one member’s container to above the whole run’s, v12564096 bytes
codec-bounded-encodingsnervix-server bounded row, member and container encodings against the unbounded bytes, and container framinggenerated schemaful codecs and batches under limits short of, at, past and around each encoding, and containers of 24, 64 and 256 repeated members where a count’s encoding grows, v12564096 bytes
emitter-row-preparationsnervix-server row request preparations kept unchanged or refused with the first violationpreparations of up to 12 selected rows spoiled by repeated, swapped, dropped, foreign and refused members and empty requests, v1256256 bytes
emitter-retained-payloadsnervix-server retained payloads written unchanged in packing order and answered member by memberpacking and write steps with delivered, rejected and unanswered records and failed writes over three buffered batches, v1256512 bytes
sqs-batch-requestsnervix-connector-sqs measured record sizes, refusals above 256 KiB, and batch requests within every limit, ended only by a limitsmall and near-limit bodies of one- to four-byte characters, attributes and FIFO groups, v12561024 bytes
mqtt-publish-packetsnervix-connector-mqtt measured PUBLISH sizes against the packet the client writes, the protocol’s largest packet and the broker maximumtopics up to 128 bytes, every quality of service, payloads beside every Remaining Length width and the protocol limit, v1256512 bytes
postgres-insert-measuresnervix-connector-postgres measured inserts against the statement and the arrays the driver encodes, and the Bind message boundup to four text columns of up to eight rows with NULLs, quotes and multi-byte characters, every run of rows, v12561024 bytes
websocket-signaling-protobuf-valuesnervix-connector-websockets complete signaling JSON through the production protobuf conversions, with double and float bit equalityexplicit scalar presence, absent defaults, full-width integers, booleans, UTF-8 strings and up to eight repeated doubles and floats, v1256256 bytes
websocket-signaling-protobuf-malformednervix-connector-websockets typed refusal through the production protobuf decoderinvalid tags, truncated fixed-width fields and strings, oversized varints and incomplete packed floats, v1256256 bytes
otel-hex-idsnervix-connector-otel complete identifier bytes and acceptance against a per-digit scalar reference, with exact refusal key and reasonnonzero trace, span and parent-span identifiers in all letter cases, arbitrary UTF-8 text, odd lengths, trailing spaces and all-zero identifiers, v1256128 bytes

Each target declares its Cargo manifest. Product targets use the root workspace; the synchronization properties use tools/nervix-lint/Cargo.toml. The shared runner discovers declared tooling workspaces from root metadata and uses the selected manifest in ordinary, sanitizer, replay and qualification commands. Compiler executions are fixture checks, not work done in each fuzz iteration.

A target names the test executable that holds its property: its package’s library unit tests, lib, the unit tests of one of its binaries, bin:<name>, or one integration test, test:<name>. The web console’s backup dialog belongs to the console’s browser binary, whose unit tests compile natively, so its properties are bin:nervix-web-console targets; Bolero and the generators stay development dependencies outside the browser’s graph. Before instrumenting a target, the runner reads Cargo metadata to resolve its package, target kind, name and source root. It selects the emitted executable by that identity, including integration tests in folders, a binary’s unit tests beside a library of the same name, and explicitly configured library or test paths. A missing or ambiguous Cargo target or executable fails the run before the campaign starts.

The CI sanitizer job budgets cold ordinary and instrumented builds separately from each target’s bounded campaign. Its overall limit is three hours; per-build, per-case and per-campaign deadlines remain enforced by the shared runner. Validation uses an 8-CPU runner and a two-hour limit to complete Clippy and the declared compiler synchronization matrix from a cold cache. Bolero inventory and compiled discovery run through the dedicated Bolero commands and workflow. The native extra-checks job retains its 8-CPU allocation and the same two-hour limit for ordinary instrumentation and per-mode primitive conformance; the Shuttle and Loom suites run in jobs of their own.

The inventory also records exact full test names, required features, corpus paths, case timeouts and each invariant. Its corpus path is Bolero’s source-adjacent __fuzz__/<test-name>/corpus directory. Ordinary tests replay those files before randomized cases; checked-in corpus inputs are regression cases. Each target starts with current-domain boundary seeds. Generated fuzz corpus and crash files live in retained artifacts under target/bolero/runs. Promote a verified minimized failure to the checked-in corpus when it remains a meaningful regression for the current domain. Breaking shape changes replace obsolete seeds.

The storage representation coverage map ties the consensus, registry, runtime-state and resource store targets to the stored shapes they cover, their complete oracles, the projections their keys make and their corruption boundaries. A filter selects every target whose ID or test name contains it, so just test-bolero runtime-state runs the runtime-state store targets and just test-bolero storage:: the consensus and registry storage targets.

The client representation coverage map ties the client targets to current schema families, complete oracles, ownership and malformed-input boundaries. Request, reply and event coverage checks compare generated families with the schema’s union declarations, so adding a family requires extending its generator.

The Arrow, codec and interconnect representation coverage map ties the relay body, producer batch, client emitter delivery, WASM generated pool, schemaful codec, JAQ-native and protobuf codec number, routed relay payload, relay wire message, client producer link frame and client consumer stream frame targets to their oracles, each codec’s value domain and the contract boundaries they record. Its server targets share one generator of schemas and batches over every current field type and one logical oracle that compares batches cell by cell with floats by their bits. just test-bolero codec- runs the four codec targets, just test-bolero jaq-native the numbers a JAQ program and its native formats carry, and just test-bolero client-producer-link and just test-bolero client-consumer-stream the frames a forwarded client producer and consumer travel in between nodes.

Generated Domains

The WASM representation coverage map registers protocol messages, native host conversions, SDK Arrow and snapshot conversions, archive state descriptors and stored checkpoints. just test-bolero wasm selects their seventeen targets; just test-wasm also builds and exercises the supported Rust and Go reference guests. The malformed targets separately cover header lengths, offsets, vector counts, tags and invalid descriptor fields. Shared guest-message generators live only in the nervix-arbitrary test harness; guests and product libraries acquire no generator dependency. These properties preserve values and retain the existing owners of callback, ACK, checkpoint and recovery semantics.

nervix-arbitrary supplies bounded vocabulary values and entropy; properties compose these into their owning frame or Arrow representations in test code. It reads a property’s bytes as a sequence of bounded choices, so the same bytes always build the same value and a saved failure replays exactly. It is a harness outside the layer order: it depends only on the vocabulary, and states what NSPL can spell as a rule over values instead of calling the parser, so the vocabulary’s own properties use it without a language dependency. No product code names it.

For native client producers and consumers it draws the window a client asks for and is granted, with counts at one, at the bound the vocabulary declares and at the largest count their type holds, a producer’s whole description, every outcome of a submitted batch with each cause it names, and physical durations that land on zero, one nanosecond and the longest duration as often as anywhere else. It also lists one field of every declared type, each scalar, a list of it and a fixed-length array of such lists, across the four combinations of optional and sensitive, for a property that must carry every type on every run.

A generator draws from one of two domains:

  • NSPL holds the values canonical NSPL spells and reads back as themselves. The language properties draw from it.
  • Vocabulary holds every value the vocabulary types hold, including states NSPL has no spelling for: negative, signed-zero and non-finite numeric literals, empty arrays, a CASE without a WHEN, casts to collection types, and the statement and Model states listed under Uncovered Boundaries. Stored and archived forms carry these states, so the archive properties draw from it.

Both domains reach every Model family, every statement form, and every expression form: integer, float, boolean, string and null literals with their range ends, arrays, field references in every scope, every unary and binary operator nested to a bounded depth, casts and TRY_CAST, IF, CASE with and without an operand, IN and BETWEEN with their negations, the JSON value and existence forms, and calls to built-in functions and UDFs. Every form is written in every region a statement embeds an expression, including a call to a built-in named like a keyword that begins the next clause, such as max, right or replace, and a field in the output or right scope. Routes are generated in every shape a node family allows: transforming and set-only construction, INHERIT, ordered SET, FLUSH EACH and FLUSH IMMEDIATE, and branches declared per route or node-wide. A name of any kind may spell any NSPL keyword, statement and expression keywords alike, and a field, UDF or function name inside an expression or a route construction may also hold a -, ~ or ., or begin with a digit, which canonical NSPL writes between backticks. Names reach both length limits, and counts reach the largest value their field holds. The language properties also sweep sixteen deterministic byte sequences through every Model family, every emitter sink and every statement form on every ordinary run, so none is left to the random cases.

The NSPL Postgres generator keeps DO UPDATE targets nonempty and leaves at least one VALUES column outside the conflict target, as the grammar requires. The vocabulary domain also generates updates whose target includes every mapped column, so archive equality still covers those values.

Relay capacities in both domains reach u64::MAX on native 64-bit targets. Archived native counts use the vocabulary’s fixed-width count representation. Dedicated count properties assert complete current-record equality at zero where permitted, u32::MAX + 1, u64::MAX, and generated values. The public scenario Relay capacities keep every bit through replication and a full cluster restart checks creation and alteration at both positive boundaries in one-node and three-node clusters, including a follower read and full restart.

Rejection targets start from valid canonical text and edit up to three characters, inserting, deleting or replacing delimiters, quotes, comment markers, line endings, digits and non-ASCII characters. The result is mostly no longer NSPL and sometimes still is, so both the rejection and the acceptance paths are exercised.

The expression-text target edits a canonical expression up to four times, deleting a character or inserting a piece or putting one in a character’s place. The pieces are whitespace and line comments, a . with and without spaces beside it, exponent letters, digits and a minus, statement punctuation an expression never reads, and keywords only an expression reserves beside ones it reads as names. It asserts that every entry point agrees on the result: a subscription’s WHERE, the standalone expression reader, a one-element expression list and a route construction’s WHERE read it as the same expression, or none of them reads it as one.

Uncovered Boundaries

The generated domains exclude states whose representation is not lossless. Each exclusion is a recorded boundary, not a claim:

  • Canonical NSPL writes a negative literal, -0.0 included, as - applied to its magnitude, and reads it back that way. NaN and the infinities have no spelling, and rendering them fails with a typed error. The NSPL domain therefore holds only non-negative finite literals.
  • Outside an expression or a route construction, a generated name is one lower-case ASCII identifier, because canonical NSPL writes a name holding a -, ~ or ., or beginning with a digit, between backticks only inside one. The name properties cover the whole name rule on each name type directly. In the NSPL domain a relay is never named message or branch, and a placement member is never named like an ALTER operation keyword, because NSPL reads those words there as its own.
  • DROP has no form for branches, generators, hash maps, signaling protocols, WASM processors or window processors, an HTTP emitter has no BATCH clause, a correlator’s filter has no spelling, and a Postgres ON CONFLICT DO UPDATE whose target names every mapped column is rejected, because it leaves no column to update. Only the vocabulary domain generates those states, for the archive properties.
  • A time rate’s text, JSON and archived forms keep every bit. The workspace builds serde_json with float_roundtrip, which reads a decimal as the float nearest to it; its default reader is best-effort and lands one unit in the last place away for a tenth to a quarter of the decimals that computed floats print as.

Behavioral Properties

Registered properties also hold behavioral contracts against independent references:

  • Operation sequences. expiry-map-operations drives the expiry map with weighted operation sequences against an IndexMap model and a ledger that counts every stored value’s drops. Miri runs the same property through just test-expiry-map-miri, which lifts Miri’s isolation so Bolero reads its checked-in corpus, with four random cases of at most 512 input bytes; every step still runs and checks its own answer, and the complete state is compared every sixteenth step and at the end. Mutation testing runs the property in every mutant through just test-expiry-map-mutants. connector-confirmation-window publishes generated hand-overs, refusals, pauses and scripted broker answers through the brokered sink confirmation window on a paused clock, and compares the instant the publish ends, the answers the window holds after each hand-over, the records it settles and the failure that ends it with a reference model of the documented window rule computed in whole milliseconds.
  • Numeric references. The decimal rounding kernels are compared with the exact decimal expansion of each value and with a 128-bit integer reference. That a value holding no more fractional digits than the count is its own rounding is checked apart from the rounded results. approx-into-exact holds the lossless round trips, and approx-into-rounding compares every rounding and truncating conversion with a bit-level IEEE 754 reference: round to nearest, ties to even, and an infinity past the largest finite value, so u128::MAX narrows to an F32 infinity.
  • Kernel differentials. SIMD targets compare every level the host supports, the baseline and the forced scalar fallback with a complete scalar oracle, tails, offsets and unused bits included. A compensated float sum is checked against an exact sum of Shewchuk partials within the bound of compensated summation, because the window contract promises no bit equality across run partitions.
  • JSON rendering. columnar-json-rows renders generated logical values on its own: strings through serde_json, bytes through a reference base64 encoder or octet escaping, and instants as RFC 3339. A float must be a JSON number of at most its type’s significant digits that reads back to its exact bits; its exponent’s spelling is the writer’s, which writes 1e172 where serde_json writes 1e+172.
  • Encoded sizes. The bounded writer, codec row and container encodings, emitter subdivision and packing, row request division, row preparations, retained retry payloads, SQS records and batch requests, MQTT PUBLISH packets and Postgres inserts are checked against the final bytes, framing, headers and escaping included where the size contract includes them. Generated measures grow and shrink with a candidate, so no property assumes that a larger candidate encodes larger.

Lifecycle, acknowledgement and cancellation races keep their production-owner Shuttle checks. Kafka, NATS and Pulsar enforce their size limits inside their client libraries, and the MySQL, MongoDB, ClickHouse and OTEL measures share the division rule while their own measurements keep example tests.

Commands And Enforcement

Server properties embed the real web console assets. The Bolero recipes build them before discovery, requiring Trunk and the repository Rust toolchain’s wasm32-unknown-unknown target. Both CI modes install those prerequisites. Asset builds stage Trunk’s complete output and preserve byte-identical published files, including their timestamps, so preparing assets again keeps the native server’s compiled artifacts reusable.

On a shared host, a cold sanitizer build can exceed the runner’s build deadline. just prepare-bolero <id> prepares one exact registered target through the same instrumented build path that runs its campaigns. CARGO_BUILD_JOBS=1 just prepare-archive-counts-fuzz uses that command for the declared server library shared by the registry and window count properties. Then run the registered campaigns with just fuzz. Preparation records its own build log and metadata with a 7,200-second compilation deadline; it does not count as a completed fuzz campaign. An expected sanitizer skip contributes no execution or coverage evidence. The Check workflow’s validation job and the Bolero randomized job retain their separate scopes and limits. Check calls the reusable Bolero workflow with its exact tested SHA, event and label snapshot. Scheduled and manual Bolero runs execute randomized checks and record a deliberate sanitizer skip.

Documentation-only Selection

Before starting ordinary suites, Check’s test-selection job proves which source it selects for. Its checkout must be the commit the event tests: a merge whose first parent is its base and whose second parent is the event’s exact head. The event and the tested merge need not name the same base. GitHub records a pull request’s base as its base branch’s tip when the pull request opens, and moves it forward only to the newest base commit that a later head merges, while it builds the tested merge on the base branch’s tip whenever the head changes. A push made after the base branch advanced, whose head does not merge the newest base, therefore tests a merge whose base is newer than the one its event names, and label events that follow name the same recorded base.

Selection accepts the tested base when it is the event’s base, or when the event’s base is one of its ancestors. The checkout holds only the tested merge and its parents, so to decide the second case selection fetches the tested base’s complete history as commits, without trees or file contents, and then answers from that local history alone. When the tested base is the event’s own base, it fetches nothing. A checkout that is not the tested merge, a tested commit that does not merge the event’s exact head into one base, an event base that the tested base does not descend from, such as a newer base or one from another branch, and a failed history fetch all fail selection, so none of them can authorize an exclusion.

Selection then reads every changed path between the tested base and the tested merge: what the merge changes on the base it was tested against, never the base branch’s own advance. Rename detection is disabled so deleting or moving product code into a prose location still requires execution. Only AGENTS.md, README.md, Markdown under docs/, and Markdown under .agents/skills/ qualify for the documentation-only exclusion. An empty diff, code, tests, build configuration, workflow definitions, scripts and unknown paths require suites; no-docker supplies no evidence about this choice.

A verified documentation-only selection excludes the ordinary tests, scenarios, extra tests, diagnostic evidence and their coverage aggregation. It excludes the entire reusable Bolero call unless the event carries fuzz. Validation and the conventional PR title check remain independent and mandatory. Label-selected mode checks remain selected by their labels; fuzz still requires both ordinary properties and the complete sanitizer inventory even for documentation. Label addition and removal compute a fresh selection from that event’s label snapshot.

The ci-test-selection artifact records run, attempt, job, event and action, the tested merge, the event’s base, the tested base and the head, every changed path, labels and each ordinary/Bolero requirement. Two different bases record an advance that selection proved. Its selected verdict records a source decision, never successful property execution or coverage. Actual job conclusions remain in the workflow run. The reusable workflow’s bolero-selection record is produced only after all of its required execution succeeds; failed, cancelled, skipped or missing required producers still fail its gate.

A rerun of test-selection reads the original event and tested merge and proves the same ancestry, so it reaches the same selection after the base branch advances again. A validation-only rerun uses the original event and tested commit. Its ordinary producer selection remains in the caller, outside the reusable workflow’s always() gate, so deliberate non-code exclusion never becomes a cancelled property campaign to excuse. Historical attempts retain their actual cancellations; this policy does not turn an interrupted required campaign into success. just test-ci-selection exercises source identity, advanced, stale and tampered bases, stale heads, other merges, shallow checkouts, mixed changes, renames, label events, reruns and strict gates; just coverage-ci-selection measures that Python contract without launching product campaigns. Selection does not claim a model-checker or public Cucumber result.

Sanitizer Shards

A single job running every sanitizer campaign outgrew its limit as the inventory grew. With all 159 targets it took 209 to 258 minutes: discovery took 27 of them, the two server sanitizer builds with one Cargo build job 42, and the campaigns 132. CI therefore divides the inventory into shards that run in parallel, each in a job of its own:

  • A harness, one package’s test target with one feature set, is never split, so a shard builds only the harnesses it runs. The inventory’s prepared_targets names one target of each harness too large for a campaign’s build deadline: today the server library with its empty and testing feature sets.
  • The plan deals the harnesses to the shards, the longest estimate first, each to the shard that holds the least estimated time. A harness’s estimate is its build, 30 minutes for a prepared harness and 3 for any other, plus the inventory’s PR duration and 15 seconds for each of its targets. The plan depends only on the inventory and the shard count, so every runner computes the same one, and just fuzz-plan <count> prints it.
  • Each shard job discovers every registered target, prepares its own declared harnesses with one Cargo build job and the 7,200-second preparation deadline, and runs just coverage-bolero 30 <number>/<count>: the runner tests, its share of the campaigns and the complete failure qualification. Only the server preparation is held to one build job; discovery and library builds use every core. A shard samples CPU use, steal time and used memory every five seconds and adds the summary to its job summary.
  • bolero-fuzz-combined downloads every shard’s completion evidence, without the raw profiles, and runs just combine-bolero-fuzz. It accepts only shards 1 to N of one plan, from one workflow run and attempt at the tested revision and inventory. Each must be complete, must have run exactly its planned targets and must hold a complete record for each of them and a report. Together they must select every registered target once. The job then writes the combined record and the merged Rust source report.

Each shard job allows 180 minutes. bolero-gate requires the randomized job, every shard and the combined proof when the PR carries fuzz, and requires all of the sanitizer jobs to be skipped otherwise. tests/release-evidence.toml holds a qualifying revision to the same checks, every shard of the matrix included.

The fuzz profile uses one codegen unit, optimization level two and debug level one. The full server package uses optimization level one and no debug output to keep its instrumented build within the compilation budget. Both profiles retain debug assertions, overflow checks, AddressSanitizer and libFuzzer coverage feedback. Live campaigns also use Rust source instrumentation; ordinary and release builds retain their own settings.

An instrumented build has a 1,800-second deadline. On a loaded development host, BOLERO_BUILD_TIMEOUT_SECONDS=7200 just fuzz <id> 30 grants compilation more time. The override must be a positive integer and affects only compilation; the inventory’s case limit and the requested campaign duration remain enforced. CI uses the default deadline. The runner prints the effective compilation deadline alongside the build command and retains timeout evidence.

The campaign process has a deadline of its requested duration plus the greater of 30 seconds and three case timeouts. The runner passes -print_funcs=0 to libFuzzer for preparation, campaigns, minimization and failure qualification. This disables the NEW_FUNC progress messages that otherwise start expensive symbolization on large sanitizer binaries. Crash reports and exact replay retain symbolization where a symbolizer is available; sanitizer CI exposes its installed LLVM symbolizer under the executable name libFuzzer discovers.

Ordinary execution prepares each selected package, harness and exact feature set once with cargo test --no-run, under a separate 1,800-second compilation deadline. Discovery can require the union of a package’s features; its binary does not prepare a target with a different declared feature set. Each property still has a 240-second execution bound and must complete its exact randomized iteration count and replay every checked-in corpus input. A failed preparation ends the run before that harness executes any property. The ordinary CI job reserves 60 minutes for discovery, the declared feature builds and every registered target.

Install the dated sanitizer nightly named in the inventory and the pinned CLI. Server properties also require the native server build dependencies, including protoc for the Pulsar driver; both ordinary and sanitizer CI jobs install the Protocol Buffers compiler, Trunk and the browser target. Every compiled Bolero recipe builds the embedded web console before discovering the server properties, using the same configured compiler wrapper as the product build.

rustup toolchain install nightly-2026-09-17 --profile minimal
just install-cargo-bolero
just help
just fuzz-list
just test-bolero
just test-bolero nspl-model
just fuzz nspl-model 30
just fuzz-all 30
just fuzz-plan 3
just fuzz-all 30 2/3
just prepare-bolero-shard 1/3
just coverage-bolero 30
just qualify-bolero
just fuzz-replay nspl-model <saved-input>
just fuzz-replay-sanitizer nspl-model <saved-input>
just fuzz-reduce nspl-model <saved-input>
just combine-bolero-fuzz <shard-evidence> <output>

just validate-bolero compares the inventory with all workspace packages declaring Bolero, scans their property macros within each owning Cargo package, and queries compiled targets through filtered library and integration-test discovery with the inventory’s declared features. Source scanning stops at nested Cargo manifests and excludes Cargo build output; an authored module named target remains part of its package. It rejects missing, unregistered, duplicate, ignored and zero-selected targets, plus corpus paths different from Bolero’s actual work directory. Discovery executes only bolero_ tests of the registered library and integration-test targets under Bolero’s selection mode; it cannot start the server’s scenario harness. It reports discovered, selected, executed and completed counts. just validate and just validate-ci do not run Bolero checks. Bolero commands perform discovery before their selected action, and the dedicated ordinary Bolero CI job owns this check. The inventory includes server-owned Arrow properties, so Bolero recipes prepare the web console assets the server library embeds through just bolero-deps. This compiles assets only; it starts no live service. Both Bolero CI jobs install Trunk, the browser Rust target and native server build dependencies before compiled discovery. Console builds stage the complete real Trunk output and publish only changed assets. Identical assets retain their file identity and timestamps, so repeated preparation preserves an already instrumented server build; failed builds leave the published assets intact.

just test-bolero requires the configured randomized-case count and checked-in corpus replay for every selected property, checking Bolero’s reported input counts. Fuzz runs use real libFuzzer coverage with AddressSanitizer, bounded input length and per-case timeout. The fuzz profile enables optimization, debug information, debug assertions and overflow checks. Builds use the pinned nightly and keep the configured kache wrapper. Modeled execution features are excluded; Loom, Shuttle and Turmoil remain independent build invocations. The server package uses the current profile’s lighter optimization and omits debug output for its single codegen unit; sanitizer instrumentation, coverage, debug assertions and overflow checks remain enabled. The pinned cargo-bolero CLI does not expose a Cargo test-harness selector. Each instrumented build therefore records a run-local Rustup adapter that forwards the declared --lib, --bin or --test selector to its Cargo test calls and forwards toolchain/version queries unchanged. It preserves the toolchain, features, sanitizer flags and compiler wrapper. Library properties compile and execute only their registered harness; they do not compile or invoke the server’s Cucumber executable. The adapter selects the pinned compiler through scripts/bolero_rustc.py, still beneath the configured kache wrapper. For proc-macro2 with span-locations, that compiler adapter removes only its generic cfg(fuzzing): the dependency otherwise deliberately replaces source locations with zero, violating the source parser’s contract and collapsing distinct findings. Sanitizer, coverage and libFuzzer flags remain intact, and every other crate keeps its configuration. The adapter’s content digest separates build directories. The source-location property and saved regression corpus assert the same exact locations in ordinary and sanitizer execution. On a shared host, a cold server build can exceed the campaign runner’s 30-minute compilation limit. just prepare-bolero <id> prepares that same pinned, scoped sanitizer build with a two-hour limit and retains its log and preparation metadata. It does not run a campaign or record fuzz completion. just prepare-archive-counts-fuzz uses this shared path for the declared registry library harness. just prepare-bolero-shard <number>/<count> prepares the declared harnesses one shard runs, as each CI shard does before its campaigns. The target must subsequently pass just fuzz <id> with the ordinary build and case limits; CI campaigns continue to use that bounded runner.

PR CI runs a required ordinary randomized/corpus job through Check’s reusable Bolero call. The sanitizer libFuzzer shards run only when the PR has the fuzz label, with 30 seconds of engine time per target. Every job uses the native CI linker, including sanitizer builds that supply their own compiler flags. Adding or removing the label reevaluates the jobs, and a push to a labeled PR runs the shards again. Scheduled and manual workflow runs execute the ordinary properties. Job limits reserve additional time for compilation, artifacts and cleanup. A cache may seed a campaign but cannot skip a target or replace checked-in regressions. An empty selection, timeout, engine failure, sanitizer finding or property failure fails the job.

Each fuzz run retains its corpus, crashes, log and metadata with revision, target, domain version, toolchain, features, flags, result and exact-input reproduction commands. Retain failure inputs before cleanup. fuzz-replay stages saved bytes in the target’s actual crash directory and runs the ordinary assertion with zero randomized cases. A sanitizer finding passes there, because only the instrumented build detects it: fuzz-replay-sanitizer builds the target’s instrumented harness and runs the exact saved bytes once, and a run’s metadata records both commands. fuzz-reduce uses libFuzzer crash minimization, verifies that the minimized bytes still fail the instrumented target and records whether the ordinary assertion fails on them as well. A saved input inside a run directory replays only while it matches the digest its run’s metadata holds. A missing, unreadable or foreign record, or changed bytes, is a corrupt replay record and is refused; a copy outside the run directories carries no record and replays as given. A random seed reproduces one generated case, not an entire entropy-driven campaign.

just qualify-bolero proves that every way a run must fail does fail, and keeps a qualification.json naming each case, its requirement, what it observed and its evidence:

  • A property mismatch fails the campaign and saves its input, which then minimizes and replays.
  • An AddressSanitizer heap-buffer-overflow fails the campaign with its report, and the instrumented replay of the saved input reproduces it.
  • A case past its timeout, an engine that cannot start and a campaign naming no compiled target fail.
  • Compiled discovery of generated packages accepts an exact registration and fails on an ignored, an unregistered and a missing property.
  • A filter, a shard plan or a campaign that would run nothing fails.
  • An input that differs from its run record, and one whose record cannot be read, are refused.

The deliberately failing targets live in the standalone tests/bolero-qualification package, outside the workspace and the inventory. Every CI shard runs the qualification, so each shard’s runner proves its own failure paths.

Live Rust Source Coverage

just fuzz <id> <seconds> and just fuzz-all <seconds> collect Rust source coverage from the inputs the live libFuzzer process executes, including candidates it discards from its retained corpus. just coverage-bolero <seconds> runs the command/artifact tests, all live campaigns and failure qualification while collecting the separate Python runner report. It does not rerun the randomized producer that CI already requires independently.

The shared build path adds -C instrument-coverage to cargo-bolero’s sanitizer flags, retaining the pinned nightly, assertions, optimization settings, case/input limits and kache wrapper. Encoded Cargo flags are rejected because they would override cargo-bolero’s sanitizer flags. Preparation and the zero-run build probe discard their counters. Ordinary discovery, exact replay and deliberate failure qualification also discard counters; they cannot enter a product campaign report or mix stable profiles with nightly profiles.

Each invocation allocates a fresh target/native-coverage/bolero-fuzz/fuzz/<nightly>/<attempt>/ directory; a shard’s attempt name ends in -shard-<number>-of-<count>, and its record names the shard and the digest of its plan. Each selected target has its own targets/<id>/ directory there, with profiles/%p-%m.profraw, executions.jsonl, lcov.info, export.log and completion.json. The runner records the exact executable returned by the hashed fuzz build and its Cargo fingerprint, effective compiler flags and profile settings. It holds the fuzz build directory through execution and export, so another collector cannot replace those objects. The shared native exporter matches profile binary IDs to retained executables and any required child objects, using the producing nightly’s own llvm-profdata and llvm-cov. Missing objects, absent counters and unreadable or mixed-format profiles fail collection. LCOV paths are repository-relative; dependency, generated and harness files follow the native collector’s exclusion policy. Tests inline in an included source file retain that file’s attribution.

A successful target has a complete record only after a successful engine exit with nonzero completed inputs and a successful source export. The aggregate report beside the invocation’s completion record is complete only after all selected registered targets complete. The record retains the inventory digest and exact entries, selection and discovered/executed/completed counts, revision and whether it was modified, compiler/LLVM identity, workflow run and attempt, event and labels, campaign limits, input throughput and source attribution. Each target’s record also identifies the checked-in corpus the campaign started from, by its input count and a digest of every seed’s name and content. A focused invocation or a shard states its selection and never claims the rest of the inventory ran; only the combined record of every shard does.

A crash, abort, deadline or interrupt stays failed or interrupted even if it left some counters. The runner retains its original corpus, crash input, logs and incomplete evidence; it does not export those counters as a successful campaign. Failure minimization and exact replay remain independent qualification, with their original failure retained. No corpus replay substitutes for the live measurement.

Each CI shard publishes coverage-bolero-fuzz-<number> with its live campaign reports, records and raw profiles, bolero-fuzz-evidence-<number> with its records and aggregate report but no profiles or per-target reports, bolero-runs-<number> for corpus/crash and qualification artifacts with the Python runner report as python.lcov, and runner-load-bolero-fuzz-<number> with its CPU and memory samples. coverage-bolero-fuzz holds the combined record and the merged report, which sums the shards’ line, function and branch counters because they ran disjoint campaigns, and bolero-selection the gate’s exact producer verdicts, including deliberate skips. Fuzz reports remain separate from ordinary workspace coverage and its CRAP report. A sanitizer skip publishes no fuzz coverage artifact.

Adding A Target

Put the Bolero test beside its production owner, name it bolero_..., bound iterations and input length, and assert the complete current-value contract. Register the package, full test name, source, domain version, features, corpus, budgets and invariant in the inventory. Check in current-domain seeds, run just validate-bolero, just test-bolero <id> and just fuzz <id> 30, then run just validate. Both CI jobs include the target in the same change; the shard plan places it with its harness, or deals a new harness to a shard, so a target needs no shard assignment. A new harness whose sanitizer build exceeds a campaign’s build deadline joins prepared_targets. A public product bug found this way first gets a failing focused reproducer and affected public Cucumber scenario before its fix.

Developing Nervix

This section is for working on Nervix itself. Everything here assumes a clone of the Nervix repository and uses its just recipes; none of it is required to use Nervix.

Clone The Repository

git clone https://github.com/nervix-io/nervix
cd nervix

Prerequisites

Nervix is developed on Linux x86_64, Linux aarch64, and macOS arm64. The just recipes fetch an ONNX Runtime build for the host and stop on any other.

The server uses ort 2.0.0-rc.13 with its highest supported C API level, 28. The downloader pins ONNX Runtime 1.30.0 and installs it under .nervix-deps/onnxruntime. Download the CPU package or, on Linux x86_64, the CUDA 13 package:

just download-onnxruntime
just download-onnxruntime gpu_cuda13

The optional second argument selects an upstream platform: host (the default), linux-x64, linux-aarch64, or osx-arm64. For example, just download-onnxruntime cpu linux-aarch64 downloads for Linux ARM64 from another supported development host. CUDA packages are available only for linux-x64. Version and destination are fixed; update the runtime pin together with the selected ort API level.

Install:

  • Rust via rustup
  • just (latest release)
  • zellij

just build-web-console builds the console the server embeds. trunk uses a wasm-bindgen CLI from PATH when its version equals the one Cargo.lock locks, and otherwise downloads it from GitHub without retrying. So the build installs that exact version with cargo install --locked into target/tools/wasm-bindgen-<version>, through the configured compiler wrapper, puts it first on PATH and runs trunk build --offline. Cargo checks the crate against the registry index and retries its own transient network failures. Offline, trunk stops at once when the CLI is missing or reports another version. The image’s builder stage runs the same installer, python3 scripts/wasm_bindgen.py install, before its own offline trunk build.

Build Container Images

just docker-build-debian
just docker-build-cuda

Both recipes require the repository’s remote compiler-cache configuration: KACHE_S3_BUCKET, KACHE_S3_REGION, KACHE_S3_ENDPOINT, KACHE_S3_ACCESS_KEY, and KACHE_S3_SECRET_KEY. They build and load a local image by default; the existing tag, platform, push, and BuildKit cache arguments are available for publishing builds.

Dockerfile.debian has two complete image targets: cpu (the default) and cuda. The recipes select them with --target cpu and --target cuda, respectively. Both targets copy the same binaries and Sonic bundle from the shared builder stage using COPY --link, so those artifact layers are shared across the images. Each target has its own runtime base and ONNX Runtime download stage; building the CPU target requires only the CPU stages.

Both images pin their builder, downloader, and runtime base to debian:trixie-20260918-slim, with its multi-platform digest fixed in Dockerfile.debian. The Debian image contains the CPU runtime and supports AMD64 and ARM64. The CUDA image is AMD64 only. It installs pinned CUDA 13.2.2 and cuDNN 9.25.1 packages from NVIDIA’s Debian 13 repository in one filesystem layer directly above the Debian base. The next layer runs apt-get update and apt-get upgrade before installing Nervix’s dependencies. CUDA packages are held at their pinned versions during that upgrade. Nervix’s user, ONNX Runtime libraries, and binaries follow, so application changes reuse the large CUDA layer. Both images obtain ONNX Runtime through the same pinned downloader.

The CPU layer order is Debian → Debian updates → Nervix; the GPU layer order is Debian → CUDA/cuDNN → Debian updates → Nervix. The CUDA layer is built by Nervix and reused across its image builds.

just test-onnxruntime checks package selection and recipe targets. just test-onnxruntime-docker builds both final targets with fixture binaries replacing the compiler stage. It verifies shared binary layers, Debian upgrades immediately after the selected base, preserved CUDA pins, and runtime shared-library dependencies without requiring remote compiler-cache credentials. The host NVIDIA driver is provided at container startup by the NVIDIA Container Toolkit.

Start The Server

The server crate and executable are both named nervix-server. The just server recipe creates a development CA and a default/node-1 identity, then runs that dedicated server binary.

NERVIX_INIT_DEFAULT_USER_PASSWORD='nervix' just server

Fresh clusters require an initial password for the default user. Set NERVIX_INIT_DEFAULT_USER_PASSWORD or pass --init-default-user-password <password> on the first startup. The leader stores the default user’s Argon2 password hash in the strongly consistent control plane only when that user does not already exist. After the default user has been created, remove the environment variable or flag from normal startup. Later users can be created through NSPL:

CREATE USER my_username WITH PASSWORD 'my_secure_password';

Clustered startup example:

just server -- \
  --addr 127.0.0.1:47391 \
  --http-listen-addr 0.0.0.0:8080 \
  --https-listen-addr 0.0.0.0:8443 \
  --grpc-advertise-addr 10.0.0.10:47391 \
  --cluster-id production \
  --node-id node-1 \
  --interconnect-listen-addr 0.0.0.0:47395 \
  --interconnect-advertise-addr node-1.internal.example:47395 \
  --interconnect-tls-ca /etc/nervix/interconnect/ca.pem \
  --interconnect-tls-cert /etc/nervix/interconnect/node-1.pem \
  --interconnect-tls-key /etc/nervix/interconnect/node-1-key.pem \
  --cluster-bootstrap-host node-2.internal.example:47395

Nervix uses separate listener addresses for plain and TLS server-side traffic:

  • --http-listen-addr for HTTP and WS
  • --https-listen-addr for HTTPS and WSS

All internal node-to-node traffic uses one authenticated HTTP/2 listener. The CA, certificate, and private-key options are mandatory. Each node certificate must support both TLS server and client authentication, contain the advertised DNS name or IP address, and contain exactly one identity URI of the form nervix://cluster/<cluster-id>/node/<node-id>. Peers with a different cluster identity or a certificate identity that disagrees with their protocol identity are rejected. Gossip, Raft, resource transfer, and relay traffic use independent connection pools on this listener; bounded rkyv is the internal control encoding, while relay batches remain Arrow IPC. The server monitors all three interconnect PEM files. It reloads a changed bundle only after two consecutive reads agree and the complete replacement passes certificate, identity, and lifetime validation; an incomplete or invalid update leaves the active bundle in service and is retried.

OpenTelemetry trace export is optional and uses the existing tracing instrumentation:

  • --otel-enabled or NERVIX_OTEL_ENABLED=true enables OTLP trace export
  • --otel-otlp-endpoint or NERVIX_OTEL_OTLP_ENDPOINT sets the OTLP gRPC collector endpoint, defaulting to http://127.0.0.1:4317
  • --otel-service-name or NERVIX_OTEL_SERVICE_NAME sets the OpenTelemetry service name, defaulting to nervix
  • --otel-trace-sample-ratio or NERVIX_OTEL_TRACE_SAMPLE_RATIO sets parent-based trace sampling from 0.0 through 1.0

The node’s trace exporter shares its configured name resolver. Startup, timeout and transport behavior are described in Node Trace Export.

The just deps stack includes Quickwit and Jaeger for local trace storage and viewing. Quickwit receives OTLP traces on host port 4317, and the Jaeger dashboard at http://127.0.0.1:16686 is configured to query Quickwit as its trace backend.

The observability listener exposes health and graph metrics:

  • /livez reports process liveness
  • /readyz reports readiness once a leader is known
  • /metrics reports raw Prometheus text metrics for graph nodes and relays

DESCRIBE commands include the same graph-node and relay metric labels, plus local derived values such as counter rates and histogram percentiles. See Metrics And Observability for the metric families, labels, and rate semantics.

Local Multi-Node Setup

just cluster-dashboard

The zellij dashboard seeds the default user once with the local password nervix and exports the same password for the interactive client pane. Override it by setting NERVIX_PASSWORD or NERVIX_INIT_DEFAULT_USER_PASSWORD before running the dashboard. If the local dashboard state was already initialized with a different password, run just reset-local-dashboard-state before starting fresh.

Start The Interactive Client

The separate interactive client crate and executable are both named nervix-cli. Its options and interactive behaviour are documented in Command Line Client.

just client

The client connects as default unless --username or NERVIX_USERNAME is set. Pass --password, set NERVIX_PASSWORD, or let the client prompt interactively.

Direct subscription example:

just client subscribe notifications

Start Local Broker Dependencies

just deps

The local dependency stack includes broker and service containers used by the documented examples, including Kafka, Pulsar, RabbitMQ, Redis, MQTT, ClickHouse, Postgres, MySQL, MongoDB, RustFS, Prometheus, Quickwit, Jaeger, and a Sentry-compatible Bugsink service.

The local Sentry-compatible service is available at http://127.0.0.1:18090. Sign in with admin@example.org / admin, create a project, and copy its DSN into a TYPE SENTRY client used by a Sentry emitter. These credentials and the Compose SECRET_KEY are development-only.

RustFS provides the local Rust-written S3-compatible target for Iceberg emitters:

  • S3 endpoint: http://127.0.0.1:9900
  • console: http://127.0.0.1:9901
  • access key: rustfsadmin
  • secret key: rustfsadmin
  • bucket: nervix-iceberg

The compose stack also starts fake-gcs for GCS API emulation and azurite for Azure Blob API emulation:

  • GCS endpoint: http://127.0.0.1:4443
  • GCS bucket: nervix-iceberg
  • Azure Blob endpoint: http://127.0.0.1:10000/devstoreaccount1
  • Azure Blob container: nervix-iceberg
  • Azure Blob development account: devstoreaccount1

The current Iceberg OpenDAL adapter honors custom GCS service endpoints, so fake-gcs can be used for local GCS tests. Azure Blob support is exposed through the adapter’s ADLS/Blob URL forms (wasb:// and wasbs://); the pinned adapter derives its endpoint from the storage URL and does not yet honor Azurite’s path-style local endpoint, so Azurite is available in compose for blob-client work but Iceberg Azure local integration needs an adapter patch or upstream endpoint support.

Iceberg emitters stage local batch files under /tmp by default before committing them to blob storage. Use --temp-dir or NERVIX_TEMP_DIR to place runtime temporary files elsewhere.

Prometheus Local Check

curl --get 'http://127.0.0.1:9090/api/v1/query' \
  --data-urlencode 'query=label_replace(vector(42.5), "source", "local", "", "")'

OpenTelemetry Local Check

Start Nervix with --otel-enabled and keep the default OTLP endpoint when just deps is running. Open http://127.0.0.1:16686, select the nervix service, and search for traces. Quickwit is also available at http://127.0.0.1:7280.

Connector Crates

Connector Crates And The Connector Contract is the architecture reference for ownership, source and sink lifecycle, and the cross-layer checklist for adding an integration. The notes here identify the repository locations and validation commands.

The shared contract is in crates/connector, and each external integration is in crates/connectors/<name>. The server’s src/runtime/ingestors and emitter modules compose the typed plans with those crates. Keep driver dependencies in their integration crates; the server test harness may name a driver in [dev-dependencies] to provision or inspect an external system. The node’s own endpoint source lives in src/runtime/ingestors/endpoint.rs because it has no external driver. Follow the architecture chapter’s integration checklist when extending this layout.

Validation And Tests

For repository-wide validation:

just validate

The checks, tests, and scenarios CI jobs install the Go formatters through scripts/install_go_ci_tool.py: dockerfmt is pinned to v0.5.4 and ghokin to v3.9.0. The installer uses Go 1.26 in each job, keeps the public Go checksum database enabled, and disallows an implicit toolchain download. It retries only a transport failure while reading that database, at most three attempts within eight minutes. A checksum mismatch, compiler failure, or invalid module version fails on the first attempt. Each attempt is retained in the job’s go-tool-bootstrap-<job> artifact. Run just test-go-ci-tool to exercise the classifier and workflow wiring without network access.

CI installs the latest just release. just validate, just validate-ci, just lint and just cargo-clippy default to four concurrent recipe bodies across the [parallel] groups. Pass a job count, such as just validate 2, or use just --jobs 2 validate to select another limit. JUST_JOBS also supplies the default. Validation runs the product and compiler tooling Clippy recipes in one invocation, so they share the same limit.

Each package and configuration is a separate parallel dependency of one shared Clippy recipe. The [parallel] groups map package lists onto that recipe, including the packages checked with --all-features and the compiler tooling packages. Feature, target, profile and compiler selections stay separate, so mutually exclusive execution modes never share a Cargo invocation.

Clippy uses clippy/<package>/<configuration_hash> beneath CARGO_TARGET_DIR, or the repository’s target directory when that variable is unset. The hash includes the Cargo arguments and compiler, so each configuration has its own build directory lock. just cargo-clippy-package <package> [arguments ...] runs a focused check of all targets through the same recipe. The compiler driver builds keep their separate typed-ratchet/driver directory. All builds retain the configured kache wrapper. Cargo’s compiler job limit applies separately within each invocation. On CI, where nothing reads a build directory after its lint and the runner’s disk cannot hold every configuration’s at once, a target that passed deletes its own; a failed target keeps its directory, and a local run keeps them all for the next one.

just validate-nspl-docs scans docs/src and parses every exact nspl code fence directly with the parser crate. Use nspl,ignore only for loose grammar synopses and statement fragments that are intentionally not complete NSPL scripts; the fence remains identified as NSPL in the rendered documentation.

For test runs:

just test

For representation properties and sanitizer fuzzing, see Property Testing And Fuzzing. The focused entry points are just test-bolero [filter], just fuzz-list and just fuzz <target> [duration]. These commands and the dedicated Bolero workflow own target discovery; repository validation does not run Bolero checks. just help lists the recipes.

Native CI builds for validation, unit tests, scenarios, client conformance, Turmoil, Shuttle, Loom, Bolero and extra checks use Clang 23 with Wild. Cargo’s x86_64-unknown-linux-gnu linker points to scripts/ci_linker.sh, which selects Wild independently of RUSTFLAGS and CARGO_ENCODED_RUSTFLAGS supplied by coverage, simulation or sanitizer tooling. Browser targets use their own linker. just validate-workflows checks the Actions workflow definitions.

Connector crates have a focused recipe. just test-connectors runs the unit tests of the nervix-connector contract crate and of every nervix-connector-* integration crate. Arguments are passed to the test binaries, so a test name filter narrows the run:

just test-connectors <filter>

Compiler architecture gate

just ratchet, just validate and just validate-ci run the same source-driven compiler architecture gate in the isolated tools/nervix-lint workspace. Product builds remain stable. Tooling, analysis and product artifacts occupy separate target directories. Install the declared toolchains with just toolchains-install, including the pinned analysis compiler’s wasm32-unknown-unknown standard library for browser analysis. Prepare the ordinary server build prerequisites, including just build-web-console. When compiler reports need reanalysis, cleanup selects the configuration’s target as well as its isolated build directory, so browser artifacts cannot remain fresh without their current reports.

just ratchet
just typed-ratchet --show
just typed-ratchet --configuration shuttle --inventory
just test-typed-ratchet
just test-typed-ratchet-docs
just qualify-typed-ratchet-cache
just lint-audit

just lint-audit reads the same complete evidence and reports review candidates for code shaped around an inaccurate lint, each attributed to the commits and debt counts that introduced it. Architecture Rules And The Debt Ratchet states the candidates, the attribution and the review contract.

The complexity verdict is the ratchet’s functions_over_complexity_30 count: the functions that the pinned cargo-crap scores above cyclomatic complexity 30. just ratchet builds that version first with just install-cargo-crap, into target/tools/cargo-crap-<version>/ through the configured wrapper, and refuses a binary of another version. A new function above 30 raises the count and fails just validate and CI’s checks job, which name it among the listed sites:

just install-cargo-crap
just ratchet --show functions_over_complexity_30

Decompose the function rather than moving the number. The function complexity section states the analyzer’s scope, its limits and every failure that refuses an incomplete analysis.

Declare #[cfg_attr(nervix_lint, nervix::context(...))] at the source’s owning execution boundary. Choose recurring, lifecycle, observer, outside or bounded; every kind requires a reason, and bounded protocols also require their identity key and bound. Trait/type/module inheritance and local call relationships supply defaults. Hot callers of lifecycle-only helpers, unknown dispatch, invalid annotations and recurring acquisitions produce ordinary Rust diagnostics. Use a narrow nervix_primitives::expect_lint! with a meaningful reason and owning repair task for retained debt. An expectation covering multiple operations or no operation fails. See Data-Plane Concurrency for the complete authoring contract, inheritance, supported call effects and limitations.

The same gate checks contextual failure contracts, explicit outcome handling and canonical panic APIs through nervix::bare_error_signature, nervix::discarded_outcome and nervix::bare_panic. Aliases, associated outputs, generic calls, inferred closures and boxed future/stream returns are resolved by the compiler. Ordinary values and custom same-named methods keep their own contracts. Classify a semantic outcome or modeled library contract with a reason-bearing nervix::error_boundary(outcome|library, reason = "...") on its exact error type or return contract. These classifications do not inherit module defaults. Standard-error hooks retain a complete report carrier. Errors And Diagnostics owns the classification rules and the current carriers. just validate-typed-errors separately guards textual Result<_, String> source, including inactive forms.

The JSON output records resolved APIs, spans, owner variants, expansions, source contracts, compiled configurations and generated/external exclusions. It is generated evidence under target/, not an approval database. Partial selections are inventories; the required diagnostic gate needs the complete matrix and complete current compiler/Cargo evidence, including zero.

The workspace wrapper nests beneath configured kache. Completion identities cover current source, annotations, rule code, compiler, driver, validator, dependencies, Cargo configuration and flags. Cargo-fresh artifacts reuse matching complete side reports. Missing reports establish analysis using KACHE_KEY_SALT and rebuild only isolated authored artifacts. --fresh reruns Cargo; --recompile discards those authored artifacts to execute the callback. Every path preserves RUSTC_WRAPPER. Changing a caller, callee, annotation or source location needs only a new run.

Cache qualification exercises fresh and Cargo-fresh analysis, an actual kache dependency hit, changed source/annotation/rule/dependency/configuration inputs, missing and interrupted output, fixture doctests twice, and cross-worktree rejection in two isolated worktrees. Kache runs workspace-wrapper chains other than Clippy’s directly while caching ordinary dependencies; complete evidence is validated independently of that detail. Compiler semantic fixtures qualify resolved lints and cross-crate metadata.

The same gate enforces source-owned architectural layers, configuration Model consumption, language references, awaited cluster access and declared identity/installation state. Declare nervix::layer at documented ownership boundaries, nervix::input(plans, ...) on connector engines, and exact value, identity and state roles at their semantic owners. The architecture contracts and typed-state contracts state the supported scope. just test-typed-ratchet-contracts <unittest selector...> runs focused pass/fail compiler fixtures after preparing just test-typed-ratchet-compiler. just test-ratchet-units also qualifies language provenance before resolution; python3 -m scripts.check_layer_provenance prints current source/manifest violations. Calibration logs and complete matrix/coverage outputs belong on the owning task, not in the source tree. Compilation under a modeled mode qualifies recognition and supplies no concurrency execution evidence.

Typed errors and reports

just validate rejects a Result<_, String> in product code and a signature that returns a Nervix failure without an error-stack report. Run the distinct source check, the complete compiler gate, its semantic fixtures and the structural scanner tests with:

just validate-typed-errors
just ratchet
just test-typed-ratchet-contracts
just test-ratchet-units

Errors And Diagnostics holds the checklist for a new fallible operation: which layer owns the failure, what its context carries, where the report ends, how it is rendered and which evidence holds it.

Capability doctests

Execution Primitives distinguishes API doctests, Cargo graph and target probes, compiler fixtures, adapter conformance and actual production-owner model execution. Keep each verdict with the configuration and claim it checks.

just test-capability-docs checks runtime state handles, logical and physical deadlines, subscription and VM predicates, the UDF execution context, consensus capabilities, DNS error carriers, recovery outcomes and SIMD byte classes with compile_fail doctests beside their APIs. Paired compiling examples check that the imports and current signatures remain valid. These checks cover the specific rejected operations in each snippet. They run in just test and CI; the runtime examples require the testing feature, which the recipe enables.

Deterministic concurrency checks

Run every registered Shuttle check of the execution, interconnect, Rust client and server crates with:

just test-shuttle

Each check runs in its own process under the exploration it declares, then again under the uncontrolled-nondeterminism detector. crates/model-harness/shuttle-inventory.toml registers every check by package and test name, and the run fails when a registered check is missing, ignored or did not complete its exploration, and when a shuttle_ test is not registered; add a new check to its package’s list, and rename a renamed one there, in the same change. The command reports how many checks it discovered, selected, executed and saw complete. A substring selects a focused check or protocol family in every package, and a substring that selects nothing fails:

just test-shuttle force_flush

On failure, the runner keeps the schedule Shuttle persisted, the run’s output and its metadata below target/shuttle-failures/<package>/<fully-qualified-test-name>/. Pass the schedule file to the replay recipe; its parent directories identify the exact package and check:

just test-shuttle-replay target/shuttle-failures/<package>/<fully-qualified-test-name>/<schedule-file>

Keep the schedule with the failure report while fixing the owning protocol, then run the focused check and the full suite. just test-shuttle-replay-check proves persistence and replay without changing a protocol: it fails one check deliberately after its invariant held, through SHUTTLE_FORCE_FAILURE=1, and requires the one schedule it persisted to reproduce the failure in a fresh process. Use SHUTTLE_REPORT_STEPS=1 to inspect the highest explored step count when setting a check’s iteration and step budgets. Data-Plane Concurrency defines what these checks model, their limits, and the invariant held by each protocol.

CI runs the checks and the replay check only for a pull request labeled shuttle. Label a pull request that changes a protocol a Shuttle check holds.

Memory-ordering models

Explore every registered Loom model of a production owner to exhaustion, each in its own process:

just test-loom

A substring of a model’s test name or invariant selects it, and a filter that selects nothing fails:

just test-loom cancellation.publication

The run fails when a registered invariant is missing, ignored or did not complete its exploration, and when a loom_* test is not registered in crates/model-harness/loom-inventory.toml. A failed model leaves its Loom checkpoint, output and metadata.json below target/loom-failures/<package>/<invariant>/; replay it with location tracking and tracing enabled:

just test-loom-replay target/loom-failures/<package>/<invariant>

just test-loom-qualification applies each registered weakening to a copy of the working tree and requires its model to fail. The copy lives below target/loom-qualification-build/ and builds into a target directory of its own there, so a weakened build never stands in for the working tree’s. Every file the copy takes or restores gets a fresh modification time, so each weakening rebuilds only what it changed, and qualifications that register the same weakening share one weakened build. The server’s models embed the built web console, which the copy links rather than copies; the Loom recipes build it first. The copied sources are removed when the qualification ends, and the target directory stays, so a later run reuses the dependencies it built. The copy compiles incrementally, because its weakened builds serve that run alone, so a weakening recompiles what it changed rather than the whole crate. just test-loom-qualification NUMBER/COUNT qualifies one shard of the distinct weakenings, the ones at the inventory positions that leave NUMBER - 1 when divided by COUNT; the default, 1/1, qualifies them all.

CI runs the models and their qualification only for a pull request labeled loom. Label a pull request that changes an ordering a Loom model holds.

Primitive boundary and execution modes

Every execution-sensitive primitive comes from nervix-primitives, and every mode is its own build. just validate runs the checks that keep it that way: just validate-primitive-boundary for import origin, permissions, manifests, global cfgs, the analysis cfg and release binaries, over every authored source including the isolated analysis workspace, just validate-execution-mode-dependencies for the ordinary and portable dependency graphs of the workspace and of every package, and just validate-execution-mode-conflicts for the diagnostics of combined modes, of a mode or the native capability requested for the browser’s target, of the diagnostic mode requested without the native capability, and of a product binary built in a mode it does not declare. just lint lints each mode in its own build, including the Shuttle builds and checks in just cargo-clippy-shuttle, the Loom builds in just cargo-clippy-loom and the diagnostic builds in just cargo-clippy-deloxide.

just test-primitives builds nervix-primitives once per execution mode and runs its conformance checks: which backend each mode selects, the same contract scripts of every family against the ordinary libraries and against the Shuttle adapters, the Shuttle checks that a publication between a read and a waiter’s registration is reached, and the check that a Loom build takes the ordinary libraries for the families Loom does not model. The timer checks measure every timer on a paused clock in ordinary execution and show that Shuttle’s timers are scheduling points whose timeouts a check triggers, the Turmoil check shows that sockets, name lookup, timers and admitted CPU jobs belong to the simulated host that uses them, and the Deloxide checks show that the tracked locks keep the contracts of the locks they replace, including their non-blocking Debug and the exact count of waiters a notification wakes. Run it after changing an adapter or the families a mode provides:

just test-primitives

It runs just test-primitives-ordinary, the checks in ordinary native execution, then just test-primitives-modeled, the checks under each other mode’s backend, then just test-primitives-compile, the checks that compile rather than run; each also runs alone. Data-Plane Concurrency defines the primitive boundary, what each mode observes, and every model’s claim.

Integrated primitive qualification

At a release candidate revision, run the ordinary suite and the complete primitive, Shuttle, Loom and Turmoil inventories. Keep the replay and weakening checks separate from successful model exploration: they show that recorded failures reproduce and that the asserted ordering matters. Run the applicable public Cucumber scenarios at the same revision. An empty selection, incomplete inventory or timed-out exploration is a failed qualification, even if another mode passed.

The external smoke and soak suites use a fresh ordinary image built from that revision. The suite resolves the image once and gives every scenario the same image identity; its suite.json and each run’s manifest prove which image, fault action, accepted events and recovery outcome were observed. For pull-request CI, select both chaos and chaos-soak when qualifying the complete matrix; the labels run the suites against that pull request’s own image. A nightly run of a different commit does not qualify the candidate. A recorded replay reproduces the external action plan, not the operating system’s thread schedule.

Measure the ordinary, uninstrumented build on the same host and profile as its baseline:

just bench-primitives
just bench-relay-interaction
just bench-admitted-work
just bench-task-handles

bench-primitives times atomic access, publication loads and stores, an uncontended blocking lock, and shared-reference cloning. The relay and admitted-work suites include their production owners; bench-task-handles retains raw timing and allocation samples. Record the revisions, host/load, toolchain, exact commands, raw results and comparison on the ClickUp task. Keep model evidence, real-process Chaos evidence and performance measurements distinct; none establishes the claim of another method. Generated qualification reports and logs belong on the task rather than in the repository. A pull request labeled benchmark runs the product comparison and the ordinary primitive probe on its benchmark worker and retains the probe output with the benchmark artifacts.

Deadlock diagnostics

Diagnostic builds select deloxide for active cycles, deloxide-order for active and historical order analysis, or deloxide-stress for active cycles under Deloxide’s bounded scheduling disturbance, which delays one in twenty of the acquisitions a thread makes while it holds a tracked lock by 20µs to 200µs. Build and run one with an existing local evidence directory:

just build-diagnostic-server deloxide-order
target/deloxide/debug/nervix-server --deadlock-evidence /var/tmp/nervix-deadlocks ...

The order build’s --deadlock-active-only option disables checking at runtime but keeps compiled instrumentation and its cost. Ordinary product performance is measured separately. An active cycle exits 3; a potential cycle retains evidence and continues; lost evidence or failed diagnostics exit 4.

Run every selection of the diagnostic lane after any change that adds or alters thread-blocking synchronization, the order of tracked acquisitions, or a shutdown, drain, restore, cancellation, handoff or other lifecycle path that uses tracked locks:

just test-deloxide
just test-deloxide-order
just test-deloxide-stress
just test-deloxide-stress-restore
just deadlock-report inspect target/deloxide/test-deloxide/deloxide-order/run.XXXX/evidence/scenarios/deadlock-PID-TIME.rkyv --source potential

The lane runs every workload tests/deloxide-inventory.toml registers for its selection: the disposable-process probes, the tracked locks’ conformance checks, the diagnostic owner tests and the tagged one- and three-node scenarios, without retries. The two stress selections compile one build: deloxide-stress runs the probes, conformance checks, owner tests, lifecycle scenarios and paced-driver scenarios under the disturbance, and deloxide-stress-restore its capture, restore and checkpoint-transfer scenarios, one at a time in a lane of their own. A scenario that joins the active-only selection also joins the stress selection of its family, tagged @deloxide_stress or @deloxide_stress_restore, under a stress. identity. It fails when a registered workload is missing or ignored, when a probe, conformance check, owner test or tagged scenario is not registered, when a process deadlocks, fails its diagnostics, is killed by a signal, outlives its bound or leaves a process behind, and when evidence is missing, partly written or does not qualify. An active deadlock exits 3, a diagnostic failure 4, a timeout or the expired budget 124, and every other failure 1. A potential cycle fails the lane until its order is corrected; the lane keeps no list of approved cycles.

Each run keeps a fresh attempt under target/deloxide/test-deloxide/<selection>/ with every log, probe artifact, evidence file and finding description, and lane.json with the exact command, environment, bound and ending of every launch. Replay one recorded launch in a fresh attempt, and prove the lane’s supervision on deliberately failing processes:

just test-deloxide-replay target/deloxide/test-deloxide/deloxide/run.XXXX/lane.json scenarios
just test-deloxide-qualification deloxide-order

A new probe, conformance check, owner test or tagged scenario joins the inventory with a stable identity, the invariant it owns, its selections and the coverage it declares; a renamed one is renamed there in the same change. A source file that starts acquiring tracked blocking locks needs an [[owner]] record naming the workloads that reach them or the path the lane does not reach and why: just validate-deloxide-applicability, part of just validate, holds the records to the compiler’s acquisition catalog. For a pull request labeled deloxide, which every change the Deloxide rule applies to carries, CI’s deloxide job runs every selection through the native coverage collector, as just coverage-native-extras test-deloxide test-deloxide-order test-deloxide-stress test-deloxide-stress-restore does locally, and keeps their attempts, completion records and diagnostic coverage reports as artifacts. The ordinary report tool also exports selected artifacts, records explicit proofs in reviewed copies and qualifies whole-process evidence. just coverage-deadlock measures the diagnostic implementation and the ordinary report command in separate instrumented builds through the native collector. Data-Plane Concurrency has the lane’s classes, its record, proof requirements, current graph limits and the paths detection does not cover, and Integration Test Lifecycle its bounds.

Deterministic network simulation

Run the interconnect’s seeded Turmoil simulation, its library checks, and every scenario over its committed seeds with:

just test-turmoil

Each seed runs twice in fresh processes and both runs must record the same semantic trace. To iterate on one simulation test, pass its name to the simulation target alone:

just test-turmoil-simulation transport::relay::relay_reconciliation_and_cancellation_survive_lost_replies --exact

A failed seed leaves a JSON record below target/turmoil-failures/ and prints the command that replays it in a fresh process with exactly its recorded inputs:

just test-turmoil-replay target/turmoil-failures/<test>/<case>-seed-<seed>.json

The suite reports how many tests each of its invocations and the whole suite discovered, selected, executed and completed, and keeps each invocation’s output under target/turmoil-suite. tests/turmoil-inventory.toml registers the tests each invocation runs and leaves ignored: the suite fails when a registered test did not run or ran ignored, when a simulation test ran unregistered, and when an invocation executed no test, and a just test-turmoil-simulation selection that matches nothing fails too. just test-turmoil-sweep runs every scenario over sixty-four seeds from 1000 in place of its committed seeds, and just test-turmoil-replay-check proves the record and replay path end to end. Deterministic Interconnect Simulation defines what the simulation controls, its fault model and limits, the scenario matrix, and how to investigate a failure.

CI runs the suite and the replay check only for a pull request labeled turmoil. Label a pull request that changes the interconnect or a seam the simulation plugs into.

External cluster chaos

just chaos runs black-box cluster scenarios against an already built Nervix image through Docker Compose, Pumba and the image’s packaged CLI. It compiles nothing and needs no Rust toolchain. Two suites run selections of those scenarios, each as its own just chaos run command:

just chaos suite list
just chaos suite smoke --image ghcr.io/nervix-io/nervix@sha256:<digest>
just chaos suite soak --image ghcr.io/nervix-io/nervix@sha256:<digest>

The smoke suite runs a baseline, a leader crash with its restart and a partition with its recovery; the soak suite runs every delivered scenario with fixed and rotating mixed-instability seeds. CI runs the smoke suite against a pull request’s own images only for a pull request labeled chaos, and the soak suite for one labeled chaos-soak. The nightly build of main runs the soak suite, and a manual run of the Chaos workflow takes a suite, an immutable image digest and its kind. Label a pull request that changes the chaos runner, or the membership, failover, recovery or delivery behavior its scenarios verify, with chaos.

Each suite runs twice on CI: against the release image and against the Deloxide diagnostic image of the same revision, which just docker-build-diagnostic builds and whose label makes every run judge the deadlock evidence of its nodes. Locally, just build-chaos-diagnostic-image overlays a release-mode diagnostic server of this checkout on an available image:

just build-chaos-diagnostic-image nervix:diagnostic-local ghcr.io/nervix-io/nervix:debian-latest deloxide-order
just chaos suite smoke --image nervix:diagnostic-local --image-kind deloxide-order

scripts/chaos/README.md describes every scenario, the suites, diagnostic images, their budgets and artifacts, and how to reproduce or replay a failed run.

Release evidence

tests/release-evidence.toml declares the evidence a revision needs before it qualifies for release: the checks every pull request runs, the release images, and each label-gated method with its label and checks, Bolero’s sanitizer fuzzing, Loom and its weakening qualification, Shuttle, Turmoil, every Deloxide lane selection, client conformance, and the smoke and soak suites against both the release image and the diagnostic image of the revision.

just release-evidence <pull-request>

The gate reads the pull request’s head revision, its labels and every check run GitHub recorded on that revision. It refuses the revision when a method’s label is missing, so CI never ran it; when the latest run of a required check, or of any shard of a sharded one, is missing, still running, skipped, cancelled, timed out or failed. The register also lists every gap an owner of tracked locks declares in tests/deloxide-inventory.toml, the path the diagnostic lane does not reach and why. A declared gap is a reviewed declaration the Deloxide rule permits, so it does not refuse the revision: just validate-deloxide-applicability, part of the required checks job, holds the records to the compiler’s acquisition catalog, and the register shows each declared limit of the diagnostic evidence to whoever qualifies the release. A rerun of a failed job supersedes the run it repeats; a passing rerun of a nondeterministic failure still needs the failure’s own investigation, which the gate cannot see. The register of every check’s verdict, run and time goes to target/release-evidence/<revision>/ and belongs on the task that qualifies the revision, not in the repository. just test-release-evidence exercises the gate on recorded check runs and reads the Deloxide lane and release image matrices from the workflows, so it fails when one of their jobs is missing from the inventory. A renamed CI job, or a value added to the matrix of one the inventory names, is updated in the inventory in the same change.

SIMD kernel development

SIMD Kernels owns the typed-buffer admission rule, caller catalog, dispatch and build-target matrix, result guarantees, and recorded performance evidence. New kernels need generated-instruction inspection, complete scalar/level differential checks including forced fallback, and a measured caller. Use its qualification recipes and measurement recipes, retaining kache. Ordinary registered Bolero properties run on PRs; sanitizer CI requires the fuzz label, and a skip supplies no execution or coverage evidence. API-rejection checks use paired current compile_fail and compiling doctests. Publish new benchmark and validation artifacts on the corresponding ClickUp task; keep durable architectural conclusions in the chapter.

Source coverage of the native extra checks

The extra checks that execute Nervix code natively in ordinary mode, the compiler synchronization fixtures, benchmark bodies, primitive boundary conformance and NSPL completion walk, also measure which source lines they execute. Run them the way CI does:

just coverage-native-extras

Name producers to run only those: test-typed-ratchet, bench-smoke, nspl-completion-walk, test-shuttle, test-loom, test-turmoil, test-deadlock-evidence-order, test-deadlock-report, test-deloxide, test-deloxide-order, test-deloxide-stress, test-deloxide-stress-restore, or an individual test-primitives-<mode> recipe. The focused diagnostic owner and probes use the deloxide-order build; the local report command uses the ordinary build; each diagnostic lane builds in the instrumented build of the feature its selection compiles, the two stress lanes in one, and exports only after its lane.json records a complete run. test-primitives selects native conformance in ordinary, Shuttle, Loom, Turmoil and Deloxide execution:

just coverage-native-extras nspl-completion-walk
just coverage-native-extras test-primitives
just coverage-native-extras test-shuttle test-loom
just coverage-native-extras test-turmoil
just coverage-native-extras test-shuttle --filter cancellation
just coverage-loom target/loom.lcov execution.cancellation

A producer runs its check exactly as just <producer> does and fails when the check fails. rust-toolchain.toml includes the llvm-tools component because only the compiler’s own LLVM tools read its profiles. What the check needs first, such as the web console the server benches link, is built normally. The recipe that executes Nervix code then runs in the environment cargo llvm-cov show-env --sh --no-rustc-wrapper describes: every crate is compiled with source coverage instrumentation into target/native-coverage-build for ordinary mode and target/native-coverage-build-<mode> for each other mode, and the configured kache wrapper stays in place. The test-deloxide, test-deloxide-order, test-deloxide-stress and test-deloxide-stress-restore lanes instrument only workspace crates: the collector moves the instrumentation flags into scripts/coverage_workspace_wrapper.py, which Cargo runs beneath kache as the workspace compiler wrapper, the scope cargo llvm-cov gives the ordinary coverage build. Their nodes must meet product deadlines while they compile WASM processors and process Arrow state, and instrumented dependencies made that several times slower; the report is the same, because export keeps only repository sources. Kache runs a workspace wrapper chain directly, so their workspace crates are compiled each run while dependencies come from the cache. Each mode has a separate build lock through export. The parts that only compile or target the browser stay uninstrumented; just test-primitives-compile retains their independent verdict. Miri, mutation testing and just test-loom-qualification never run under this command. The compile and qualification commands run without the collector’s profile environment, so altered source cannot contribute to the current-source report.

Every run collects into a directory of its own, target/native-coverage/<producer>/<mode>/<toolchain>/<attempt>/. The attempt is the GitHub Actions run and attempt in CI, and the time and process locally. No run reuses, reads or cleans another run’s directory, so no run can count another’s counters. The directory holds:

EntryContent
lcov.infoThe LCOV report of the repository’s sources, with the absolute paths llvm-cov writes
completion.jsonThe completion record: the verdict and everything it covers
executions.jsonlEvery executable Cargo ran, with its arguments and build ID
models.jsonFor Shuttle and Loom, canonical discovery, selection, execution and completion evidence
export.logThe diagnostics of llvm-profdata and llvm-cov, each bounded
profiles/ and merged.profdataThe raw counters and their merge; they stay on the machine

Cargo runs each instrumented executable through the collector as its runner, which records the execution and points the counters of the executable, and of every process it starts, at profiles/, one file per process and module. Build scripts and procedural macros execute only when a crate is compiled rather than served from the cache, so their ordinary build-time counters are discarded. The compiler fixture is itself an executed check: its loaded procedural macro objects are retained by build ID, including those in nested Cargo output directories. The producer uses its pinned compiler’s LLVM tools. The report is exported with the toolchain’s own llvm-profdata and llvm-cov from exactly the executables that ran and the children they started. It keeps the files inside the repository and leaves out dependencies, harness code below a tests, examples or benches directory, and code generated below a Cargo target directory.

completion.json names the producer and mode, the tested commit and whether the working tree was modified, the CI run and attempt, the compiler and its LLVM version, the instrumentation flags, the recipes that make up the check, the executions and child executables selected, the profile files with the binary IDs they name, the warnings llvm-cov printed, and the executable and covered lines of each package. Model producers also embed models.json: exact package/test identities, InvariantIds and bounds for Loom, and paired exploration/nondeterminism records for Shuttle. The canonical runners validate their own inventories and filters. Missing or ignored checks, zero overall selection, incomplete exploration and nondeterminism failures retain their failure verdicts. A package with no filter match is allowed within a nonempty cross-package selection. Its verdict reads running from the moment the directory exists. It becomes complete only once the whole check passed and the report was written, and failed or interrupted otherwise, with a failure naming the stage, prepare, instrument, run, export or finish, and what went wrong. A collection fails when the recipe ran no executable, when an executable it ran is gone or was rebuilt before the export, when one wrote no profile, or when a profile is unreadable or names an executable that is no longer there. Every attempt keeps its profiles, so a failed one keeps its evidence; remove target/native-coverage when the collections are no longer needed.

Every profile is matched to the executable that wrote it before the export, so a warning from llvm-cov never means a stale profile. When several executables share a function that only some of them run, such as an inlined dependency function, the copies that never ran can carry a different hash, and llvm-cov warns that they have mismatched data and reads the function from the executables that ran it.

CI’s extra-tests job collects native extras and per-mode primitive conformance, and runs primitive compile checks independently. The shuttle job, which runs only for a pull request labeled shuttle, collects its complete inventory, including random/PCT exploration and paired nondeterminism checking, and runs schedule replay qualification independently. For a pull request labeled turmoil, the turmoil job collects the canonical simulation suite once, including each fresh-process attempt, and runs failure replay qualification independently. For a request labeled loom, the loom job collects the complete Loom inventory, and two loom-qualification jobs split weakening qualification into shards, because it rebuilds the server once for each weakening of a server owner. The jobs upload lcov.info, completion.json, executions.jsonl, export.log and model evidence as coverage-native-extras, coverage-shuttle, coverage-loom and coverage-turmoil, whatever the verdict. The ordinary workspace report merges ordinary collections only; mode reports describe modeled or diagnostic execution and retain separate attribution. just coverage-shuttle <output> [filter], just coverage-loom <output> [filter] and just coverage-turmoil <output> copy a successfully completed canonical report to the requested path while retaining its attempt evidence. Replay uses the unchanged exact-check commands and the failure directory under the matching mode build directory. The benchmark bodies run instrumented, so their Criterion test mode shows that each body executes and measures nothing. The collector itself, including real instrumented runs of a fixture crate and its refusal of every incomplete collection, is tested with:

just test-native-coverage

The ordinary workspace report

Every ordinary coverage consumer reads one report, lcov-workspace.info: patch line coverage, the CRAP report and the coverage-merged artifact. just coverage-merge builds it from the ordinary producers’ reports, and fails, writing no report, rather than publish an incomplete one.

just test-coverage and just test-scenarios-coverage export lcov-workspace.info from every profile they collected with --no-default-ignore-filename-regex, which keeps every workspace crate and the test sources the run executed. Each writes the absolute checkout path it ran in to coverage-source-root.txt beside the report. just coverage-report-workspace writes the same pair from the profiles a focused recipe collected. CI uploads the pair as the coverage-tests and coverage-scenarios artifacts, the second with the Python paced drivers’ report, and the extra-tests job uploads its native collections and Python tooling reports as coverage-native-extras. The coverage job then runs:

just coverage-merge coverage-inputs/tests coverage-inputs/scenarios coverage-inputs/native-extras

An input is a producer’s artifact directory or one report, with the producer’s coverage-source-root.txt in the directory or beside the report. The merge reads the workspace export, every Python tooling report named python.lcov, and every complete ordinary native collection below native-coverage. A collection of another mode describes modeled or diagnostic execution, so it is never read: the Shuttle, Loom, Turmoil and Deloxide primitive conformance collections in the native extras artifact are recorded as left out. The reports of the shuttle, loom, turmoil, deloxide and Bolero fuzz jobs are not inputs at all.

Every record is mapped onto the repository. The merge strips the producer’s checkout root and resolves .. segments lexically, so the record of a #[path] module names the file it includes, and it keeps a record only when its file is tracked at the tested revision, test sources included. It drops the rest and counts the dropped files by class: dependencies below ~/.cargo/registry, ~/.cargo/git and /kache/.cargo/registry, the standard library below /rustc/<commit> or a local rustup toolchain, files generated below the checkout’s Cargo target directory, such as the FlatBuffers OUT_DIR sources, untracked files, and any other path outside the checkout. The counters of every report that measured a file are summed. The report holds line and function counters. Branch records are left out: the Rust producers export none, and coverage.py names Python branches in words that LCOV readers such as cargo crap refuse.

The merge fails with a message naming what to fix when:

  • an input is missing, or holds neither a workspace export nor a complete ordinary collection;
  • an input has no root metadata;
  • none of a report’s records lies below its producer’s checkout root;
  • a report contributes no record of a tracked file;
  • a native report has no complete ordinary completion record beside it, or one producer has two collections;
  • a workspace member that cargo metadata lists has no Rust record in the merged report.

Either way it writes its merge record, target/coverage-merge/record.json: the verdict and the tested revision, every input with its checkout root, each report it read with its record counts and its dropped records by class, the collections it left out, the dropped files by class, and the workspace members the report measures. CI retains the report and its record as the coverage-merged artifact.

Without inputs, the merge rewrites in place the lcov-workspace.info that just test-coverage wrote, into the shape CI publishes, and the CRAP report and patch line coverage read it:

just test-coverage
just coverage-merge
just check-coverage
just coverage-patch origin/main

just test-coverage-merge exercises the mapping, every integrity failure and the CI contract, and just coverage-merge-runner exports the merge’s measured Python coverage for CI.

CRAP report

just check-coverage publishes an advisory CRAP report over the merged ordinary workspace report. It runs the pinned cargo-crap, the version scripts/cargo_crap.py declares for the complexity count too, over the whole repository root. The root package’s directory holds every workspace member, and the Cargo projects nested in it, such as the lint workspace, are walked as well, because some ordinary producers measure them. No default exclusion applies, so test, bench and example sources are scored like any other. The analyzer is handed each record of the report under its absolute path, which binds the record to its own file. A relative path would also bind every other file whose path ends with it, such as another crate’s src/lib.rs, to that record’s coverage.

A function’s CRAP score is complexity² × (1 − coverage)³ + complexity, from its cyclomatic complexity and the share of its executable lines the report covers, so at full coverage it equals the complexity. The report lists every function that scores above 30. A function in a file that the report holds no record of is unmeasured: mode-only sources such as primitive adapters and Shuttle checks, build scripts, benches, the lint workspace’s fixtures and the WASM guests. The report lists unmeasured functions with their complexity and never scores them as uncovered. A report file in which the analyzer scores no function, such as a file of #[test] functions, is listed as read without a scored function.

No CRAP score fails the check. Scores move from run to run with the coverage the suites happen to record, and the mandatory complexity verdict is the ratchet count above. The check fails only when the analysis is vacuous, that is when it cannot describe the merged report:

  • the merge record is missing, did not complete, or names another revision than the checkout’s;
  • a record of the report names a source the checkout does not track, such as a path below another checkout’s root;
  • the analyzer’s walk cannot read a Rust file the report holds: a hidden path, a symbolic link or a file Git ignores;
  • the analyzer measured functions in a report file without binding its record to them;
  • the analysis matched no report file.

Whatever the verdict, it writes target/crap-report/crap.json and target/crap-report/crap.md. The JSON report holds the verdict and its cause, the analysed revision, the scope counts, every measured function with its complexity, coverage and score, and every unmeasured function. The Markdown report lists the functions above 30, the unmeasured files and the files without a scored function. CI’s coverage job runs the check after the merge and retains both reports in the coverage-merged artifact. The patch line coverage comment carries the CRAP section: the functions above 30 in the files the pull request changes, the project’s total above 30 and the unmeasured count, or the cause of a vacuous analysis.

The reports name the analysed report by its file name, so a retained coverage-merged artifact reproduces its reports byte for byte without the coverage binaries, in a checkout of the revision its merge record names:

just check-coverage <artifact>/lcov-workspace.info <artifact>/target/coverage-merge/record.json

just test-crap-report exercises the analysis, every vacuity failure and the CI contract, and just coverage-crap-report-runner exports the report’s measured Python coverage for CI.

Patch line coverage

CI runs just coverage-patch against the merged ordinary workspace report and updates one PR comment with patch line coverage, project line coverage, per-file totals and uncovered added line numbers. The comment names the merge base and tested commit and links to the workflow artifacts. Patch coverage is advisory: no percentage is a build gate, and a missing report or a failed comment publication does not fail CI. The complexity verdict is the just ratchet count of functions above cyclomatic complexity 30, which reads no coverage. With --crap target/crap-report/crap.json, as CI passes it, the comment also carries the section of the CRAP report for the files the pull request changes, including the files it only shortens.

The label-enabled Turmoil job publishes a separate Turmoil patch coverage comment using --scope turmoil after its canonical instrumented suite and independent replay qualification pass. The scope selects a distinct marker, so the two jobs update their own comments regardless of completion order. This report describes only the current Turmoil build’s product sources; it does not merge modeled counters into the ordinary report or its CRAP report. Failed collection keeps its verdict and diagnostics without publishing a successful coverage report.

Run the same calculation manually after collecting coverage for the committed source revision:

just coverage-patch origin/main lcov-workspace.info
just coverage-patch origin/main lcov-workspace.info --pr 123 --repo nervix-io/nervix
just coverage-turmoil target/turmoil.lcov
just coverage-patch origin/main target/turmoil.lcov --scope turmoil --output target/turmoil-patch-coverage.md

The first command prints the Markdown report and writes target/patch-coverage.md; the second also creates or updates the authenticated publisher’s marked PR comment through gh. Additional --report <path> arguments union line hits from multiple LCOV reports. Coverage must describe the tested commit’s line coordinates. The reporter maps sources onto tracked files as just coverage-merge does, so a .. segment names the file it reaches, and the merged report, already relative to the repository, needs no checkout root. CI’s reporter reads that report, so the different checkout paths of Blacksmith and GitHub-hosted runners are resolved once, by the merge. A focused local report, such as the Backup and Visual Create coverage recipes write, describes the local checkout and needs none either. For reports collected in another checkout, use --source-root <original-checkout-path>, or --source-root-file with the producer’s coverage-source-root.txt, to remap absolute source paths explicitly. Repeat either when reports come from multiple source roots.

The command compares the base’s merge base to HEAD, or to --head <commit>. Only added lines with LCOV DA counters enter its denominator; repeated records count each file/line once, and a hit in any report covers the line. Deleted lines, unchanged lines and non-executable additions do not enter it. A changed file absent from LCOV is shown as unavailable. An empty measured patch has no percentage. Project coverage describes the tracked files present in the supplied reports; no baseline project report is assumed.

CI compares the PR base to the tested synthetic merge commit, so the diff uses the same line coordinates as the collected coverage. It passes the PR head separately with --pr-head and checks that the PR is still open at that head before publication. Fork PR tokens may lack comment access; the local report, workflow summary and artifact remain available. just test-patch-coverage exercises accounting and publication, and just coverage-patch-runner exports the reporter’s measured Python coverage for CI.

The scenario suite’s execution budget

The Cucumber suite bounds its own run. A step, a teardown diagnostic or a node stop that never returns ends the whole run at the budget rather than leaving the process alive until CI cancels the job, which kills it mid-scenario with no record of what each scenario was doing. When the budget expires the suite prints every scenario still active with its attempt, its phase, how long it has been in that phase and the cluster nodes it holds, asks every live node to stop, waits one bounded cleanup window for them, and exits with status 124. A passing run still exits 0 and a failing one still panics, so a wedged suite is told apart from a failing one by the exit status alone.

The default budget leaves the workflow job time for the work that precedes the suite and for the artifact upload that follows a timeout. Give a run a budget of its own with --suite-budget or the NERVIX_TEST_SUITE_BUDGET environment variable, which is how the timeout path is exercised without waiting out the suite’s own budget:

just test-scenarios --input tests/features/cluster/rejoin.feature --suite-budget 5s

The focused regressions that hold the harness’s startup, status, teardown and watchdog budgets run in seconds:

just test-harness-liveness

Integration Test Lifecycle defines every harness deadline, the phases a scenario reports, and how each failure reaches CI output.

Building The Documentation

just book <version> renders this book, and just book-pdf <version> additionally produces nervix.pdf through pandoc and XeLaTeX.

Both recipes require the exact mdBook release pinned by MDBOOK_VERSION in scripts/build_book.py, and they fail with that version in the message when a different one is installed. CI resolves the same constant, so a local build always matches what is published. The pin is exact because the rendered theme targets mdBook’s internal element IDs and because the PDF relies on mdBook rewriting print-page links into in-document anchors — on an older release every cross-chapter link in the PDF would point back at the website instead.

Install the pinned release with:

cargo install mdbook --version <MDBOOK_VERSION> --locked

Building the PDF also needs pandoc and a XeLaTeX installation on PATH.

License

Nervix is licensed under the Fair Core License (FCL).

The full license text is included in the repository root as LICENSE.md.