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

Vejas

Vejas is an open-source integration platform with no builder UI: agents write the integration code, humans own what it means. Flows are written in VejasScript — a small, pure, per-event language whose business surface (thresholds, transcoding tables, rules) is extracted from the code itself and edited by domain experts in a panel, without touching the code.

Three design commitments shape everything:

  1. One infrastructure. NATS/JetStream is the only dependency: transport, persistence, KV, locks, audit — the bus is the platform’s memory and its scaling substrate. Two containers, one docker compose up (ADR-0002).
  2. Agent-native. The runtime is an MCP server. An agent connects, reads the language contract (vejas_language), writes a flow, tests it against a fixture, and it lands running — governance optional but first-class: in governed mode agents can only propose; a human approves (ADR-0006, ADR-0024).
  3. Measured, not claimed. Every number in these docs comes from a reproducible benchmark in bench/: cold start 11 ms, 6–8 MB RSS under load, end-to-end p50 2 ms uncongested, every hop persisted, cluster promote in 60 ms — lossless.

Where to go

Install & first run

git clone https://github.com/cpoder/vejas && cd vejas
docker compose up          # nats + vejas — nothing else

The panel is at http://localhost:8686. The webhook entry listens on :8787POST /ingest/<subject> publishes the JSON body on the bus.

From source

cargo build --release --manifest-path core/Cargo.toml
nats-server -js &          # the only dependency
VEJAS_ROOT=./my-root core/target/release/vejas-runtime

VEJAS_ROOT is a plain directory: flows/, connectors/, tables/, tests/. Everything is a file; git is the source of truth.

Connect an agent

claude mcp add --transport http vejas http://localhost:8686/mcp

Any MCP client works — the runtime is the MCP server, no separate process. Ask the agent for a flow in plain words; it reads vejas_language over MCP, writes the .vjs, tests it against a fixture, and the supervisor picks it up.

Security defaults

The panel/MCP port (8686) binds to localhost by default — that surface writes flows and can run commands (exec connectors), so expose it deliberately. Set VEJAS_TOKEN and every write (/mcp included) requires Authorization: Bearer. The webhook port (8787) only publishes events. For a “no agent lands anything alone” posture, see governed mode.

Your first flow

Say it to your agent:

Watch helpdesk tickets on vx.helpdesk.tickets. Priority arrives in French — map critique→P1, haute→P2, otherwise P3 — and post P1/P2 alerts to Slack with the requester’s email, lowercased.

What lands (flows/helpdesk_ticket_alerts.vjs):

# flow: helpdesk_ticket_alerts
source "vx.helpdesk.tickets"

# French priority label -> severity code. The business expert edits this table.
SEVERITY_CODES = {"critique": "P1", "haute": "P2"}
ALERT_LEVELS = ["P1", "P2"]

code = SEVERITY_CODES[priority] ?? "P3"
email = lower(requester?.email)

if code in ALERT_LEVELS:
    emit "vx.slack.out", {text: f"[{code}] {subject} — {email}"}
end

Three things to notice:

  • The business lives in UPPERCASE literals. SEVERITY_CODES and ALERT_LEVELS appear in the panel as editable tables — a domain expert corrects "haute" to "P1" without reading a line of code, and the flow restarts itself (ADR-0005). Adding a new row — "bloquante" → "P1" — is the same gesture: the panel inserts it span-exact, no agent needed (changing the flow’s logic is the one that’s a sentence to the agent).
  • The language is pure. No I/O, no clock, no network in a flow — input event in, emits out. That is what makes replay, time-travel and canary structurally safe later.
  • Delivery is at-least-once, persisted. The event was on JetStream before the flow saw it; the emit is acknowledged before the input is.

Feed it an event:

curl -X POST localhost:8787/ingest/helpdesk.tickets \
  -d '{"priority":"critique","subject":"SAP down","requester":{"email":"[email protected]"}}'

then watch it in the panel — the event, its emit, and the editable surface, side by side. More recipes, each validated live: the cookbook.

The panel

One embedded HTML page at http://localhost:8686 — no build step, no SPA stack. It is the human side of the platform: everything an expert or an operator needs to read the system and own the meaning, nothing that turns them into a flow developer.

What you can do there

  • See the pipeline. The diagram is a filter: click a node and the event cards, flows and connectors narrow to that path; deep-link with #focus=f:<flow> (multi-selection supported).
  • Edit the business surface. Every UPPERCASE literal — thresholds, transcoding tables, queue names — is editable in place. Saving goes through the same audited write path agents use (/surface/set), and the unit restarts itself. Credential-shaped values are masked (single-sourced pattern, ADR-0017).
  • Read the rules. The rules view projects each flow’s decision logic as faithful sentences — whole-sentence or verbatim code, never a half translation (the rules view).
  • Watch events. A live ring of traced events with payloads; any event can be replayed against a candidate version (the replay strip), or starred ★ into a curated golden test (golden traffic).
  • Approve. In governed mode the approval queue lists agent proposals with their evidence next to the Approve button (governed mode).
  • Dead letters. The DLQ card shows death envelopes (version-tagged) and offers explicit replay (DLQ & replay).

The bus

NATS with JetStream is the only infrastructure (ADR-0002). Not “the message broker among other services” — the entire platform substrate:

  • Transport & persistence. Every event between units crosses a JetStream stream (VEJAS, subjects vx.>). A hop is acknowledged only after the next stage’s write is confirmed — publish-before-ack, so a crash anywhere redelivers instead of losing (at-least-once, end to end).
  • State. Offsets, versions, leases, proposals: JetStream KV buckets. There is no database.
  • Scaling. Flows and sinks are competing consumers on durables — add an instance, throughput distributes; kill -9 one, nothing is lost (measured: 20 000/20 000 through an instance kill under load). Sources that must be singletons take a KV lease (clustering).
  • Audit. Mutations append to an audit stream; the DLQ is a subject hierarchy (vxdlq.>) with death envelopes.

Subjects

Everything speaks vx.<domain>.<name> (root configurable via VEJAS_SUBJECT_ROOT). A connector publishes into the taxonomy or consumes from it; a flow declares source "vx…" and emits to other subjects. An external connector in any language is a first-class citizen by following the same rules on the same bus — publish JSON on vx.<domain>.<name>, consume with a durable, ack after your side effect. The full contract: Subjects reference.

Delivery contract

At-least-once, per-subject FIFO, redelivery after VEJAS_ACK_WAIT_SECS (default 30s; floor 1s), 5 attempts then the dead-letter queue with an explicit envelope — never a silent drop (DLQ & replay). Consumers must be idempotent or carry a dedup key; the transport invariants are CI-tested on a live bus (FIFO, redelivery→cap→DLQ, kill -9 no-loss, reconnection).

Flows & VejasScript

A flow is one file under flows/: a pure, per-event program. One trigger (source subject, api route, or MCP tool), transformations, emits and/or a respond. No I/O, no clock, no network — the language cannot express them. That purity is load-bearing:

  • the business surface (UPPERCASE literals) can be extracted, shown and edited safely — a change is a literal swap plus a unit restart;
  • any persisted event can be replayed through any version of the flow with zero side effects — which is what makes time-travel and canary structural rather than bolted on;
  • a fixture plus vejas_run_flow is a complete test.

The language fits in 20 lines — see the VejasScript reference. Highlights: null-safe access (requester?.email), array projection/filtering (orders[total > 100]), f-strings, transcoding via literal dicts, invoke to compose services (services/<name>.vjs, cross-package with EXPORTS), secret("path/key") for credentials — never a literal.

Lifecycle

The supervisor watches VEJAS_ROOT: a new or changed file becomes a running unit; a broken one fails loudly without taking the rest down. Each flow gets a durable consumer on its source subject, so stopped is not losing: events accumulate on the stream and drain on restart. Tests live next to the code (tests/vjs/ golden cases, per-flow fixtures) and run in CI with vejas-runtime vjs-test.

Composing services

A flow does one job end to end. When several flows need the same piece of logic — format an alert, classify a ticket, shape a payload — you factor it into a service and invoke it. Same language, same purity, no duplication.

A service is a flow without a trigger

A service lives in services/<name>.vjs (or packages/<pkg>/services/). It looks like a flow but declares no source, api, or tool — it never runs on its own. It receives named arguments, and produces variables. Real example (services/format_alert.vjs):

# service: format_alert — Inputs: sev, subj, email. Output: alert_text.
alert_text = f"[{sev}] {subj} - {lower(email)}"

Because a service is pure VejasScript, everything true of a flow holds: its literals are part of the business surface, it is statically analyzable, and it has no I/O of its own.

invoke — merge, or capture

A flow calls a service with named arguments, two ways:

# merge: the service's outputs land in THIS flow's variables
invoke format_alert(sev: code, subj: subject, email: requester.email)
emit "vx.slack.out", {text: alert_text}          # alert_text came from the service

# capture: take the service's whole pipeline as one document
d = invoke format_alert(sev: code, subj: subject, email: requester.email)
emit "vx.slack.out", {text: d.alert_text}

Merge is the common case — the service extends the caller’s pipeline. Capture is for when you want the result as a self-contained document (to nest, compare, or pass on).

Across packages: EXPORTS

Packages group flows and services. A service is package-private by default. To call one from another package, the owning package must export it in its package.vjs:

# packages/notifications/package.vjs
ENABLED = true
EXPORTS = ["notify_slack"]     # callable from other packages; the rest stays private

Then, from another package:

invoke notifications:notify_slack(channel: "#ops", text: alert_text)

When to compose, and when to use the bus

  • invoke when it’s synchronous shared logic inside one processing step — formatting, classification, a lookup shape. It runs in the caller’s pipeline, same event, no persistence boundary.
  • The bus (emit to a subject another flow sources) when it’s a separate stage that deserves its own delivery guarantee, retry/replay, or independent scaling — the persisted, at-least-once path. Between packages, prefer the bus; EXPORTS is the deliberate exception for genuinely shared synchronous helpers.

A rule of thumb: if you’d want the step to survive a crash and replay on its own, it’s a bus hop, not an invoke.

Connectors

A connector puts external systems on the bus. There are three shapes, by escalating need (ADR-0007/0011):

  1. A manifest on a built-in driver — most SaaS is this: a .vjs file with driver "http-poll" (or oauth-poll, http-out, http-in, mqtt-in/out, timer, slack-out…) and UPPERCASE config. No code. The driver catalog with each config contract: GET /drivers or the vejas_drivers MCP tool.
  2. An exec bridgeexec-source / exec-sink / exec-stream-source / rpc:exec run a child program in any language over stdio. Kafka rides this (kcat carries the full librdkafka auth matrix); the generic offset-resume (OFFSET_KV) gives publish-before-commit resume in our KV, kill -9-proof (CI-tested).
  3. A first-class standalone binary — when the ordering that guarantees no-loss must live in one process with its own bus client: IBM MQ (MQGET-under-syncpoint → bus pub-ack → MQCMIT) and AMQP/RabbitMQ (consume→pub-ack→ack; publish→confirm→ack). Configured by env, not by manifest. See Brokers.

Whatever the shape, the same rules hold: subjects under vx., at-least-once with the ack after the side effect, credentials through secret() or the deployment’s secret machinery — never a literal (a CI lint enforces it on every certified recipe), and an admission test that proves data actually flows (the certified catalog).

The business surface

The core bet of the platform (ADR-0005): the agent owns how, the human owns what it means. In practice, “what it means” lives in the flow’s UPPERCASE literals — thresholds, transcoding tables, routing keys, feature lists. The runtime extracts them from the AST with their exact source spans and serves them as the surface:

  • the panel shows them as editable values and tables next to live sample events;
  • editing writes back literally (span-exact, no reformatting), audits the change, and restarts just that unit;
  • credential-shaped keys are masked and must be secret() references — the same single-sourced pattern gates CI, the panel, and generation.

Three levels of “editable” (ADR-0019):

  • N1 — parameters: the literals above; already editable.
  • N2 — the rules view: the flow’s decision logic read as faithful sentences (guide) with its inline literals editable; the structure itself is read-only.
  • N3 — structure: changing the logic is an agent conversation — never a form-based rules editor. That line is deliberate: half-editable code is how platforms rot.

Secrets

The rule is absolute (ADR-0008): a credential never appears as a literal — not in a flow, not in a manifest, not in the panel, not in git diff.

secret("path/key") resolves at run time, fail-closed, from:

  • VEJAS_SECRET_<PATH> environment entries (containers, CI);
  • a file store (VEJAS_SECRETS_FILE) for dev;
  • HashiCorp Vault (VAULT_ADDR / VAULT_TOKEN / VEJAS_VAULT_MOUNT).

What keeps the rule real, rather than aspirational:

  • One pattern, three enforcement points. The credential-shaped-key pattern is a single constant in the runtime (vejas-runtime secret-pattern prints it); the panel masks with it, CI lints every certified recipe with it (env-file recipes included), and the agent generation contract embeds it. The pattern itself was chosen on a 55-key labeled benchmark, and a CI test pins the exact profile — changing it reopens the decision with data, never silently.
  • Standalone binaries follow the deployment’s own machinery (env from a secret store, CCDT/TLS keystores) — their recipes are linted the same: a credential-shaped env key must be a ${VAR:?} reference, never a value.
  • Secret paths are listed (GET /secrets); values are never returned.

Agents & MCP

There is no builder UI to click together — the flow-writing interface is an agent, and the runtime is the MCP server (POST /mcp, ADR-0006). No sidecar, no plugin: the platform’s own write path, test path and telemetry are the 29 MCP tools.

The loop an agent follows (enforced by the tool descriptions themselves — the generation contract):

  1. vejas_language — read the language and its rules first.
  2. vejas_drivers / vejas_surface / vejas_topology — see what exists.
  3. Write: vejas_write_flow / vejas_new_connector — literals for anything a human may want to change, secret() for anything confidential.
  4. Prove: fixture + vejas_run_flow (pure, no side effects), probe connectors with vejas_test_connector.
  5. Ship — or in governed mode, propose: vejas_propose carries the change plus the evidence (time-travel result, canary stats), and a human approves in the panel (governed mode).

Flows themselves can face agents: tool "description" exposes a flow as an MCP tool — the platform grows its own tool surface as flows are written. And the self-healing loop closes the circle: a dead letter → the agent reads vejas_dlq, drafts a candidate, proves it on real traffic (vejas_time_travel, vejas_canary_start), proposes with evidence → human approves → cluster-wide promote in 60 ms → vejas_dlq_replay.

Expose an API — sync and async

Vejas gives you both interaction semantics, chosen per endpoint — not a global mode:

Async ingestionSync API flow
Entryhttp-in connector: POST :8787/ingest/<subject>api "VERB /path" in the flow: /api/... on the panel port
Caller gets202 after the JetStream pub-ackThe flow’s respond <status>, {body}
DurabilityPersisted before processing; at-least-once through the whole pipelineComputed in the request; a crash mid-request is the caller’s retry (plain HTTP semantics)
BackpressureThe stream absorbs bursts; consumers drain at their paceThe caller waits
Use forWebhooks, events, anything fire-and-forgetLookups, validations, request/response REST

Async: a webhook that cannot lose

# connector: orders_webhook
driver "http-in"
PORT = 8787

POST /ingest/shop.orders publishes the JSON body on vx.shop.orders and answers 202 only after JetStream confirmed the write — the caller’s success means persisted, not processed. Flows consume from there with the full delivery contract (redelivery, DLQ).

Lock the port to its subjects. The /ingest port is unauthenticated by design — it only publishes. Without a limit, any caller can publish to any vx.* subject, a sink’s subject included, triggering an outbound side effect with no flow in between. Set ALLOW to the subject suffixes this webhook is for:

driver "http-in"
PORT = 8787
ALLOW = ["shop"]          # shop, shop.orders, shop.refunds … — 403 for anything else

Match is by subject segment: "shop" allows shop and shop.orders, not shop_internal. Absent, the port stays open to any vx.* (backward compatible) — set it whenever the port is reachable by anyone but the operator (ADR-0029). It is defence in depth, not authentication: put the port behind your ingress trust boundary as you would any webhook.

Sync: a flow that is an API

# flow: order_status
api "GET /orders/{id}"
API_RESPONSE = {id: "string", status: "string"}

respond 200, {id: id, status: "shipped"}
  • One flow per verb: a REST resource is several small flows.
  • {path params}, the JSON body and query all arrive as event variables.
  • respond is the HTTP answer; emit still fires bus side-effects (best-effort — if the side effect must be guaranteed, emit to a subject and let an async flow own it).
  • The whole API self-describes at GET /api/openapi.json (API_REQUEST/API_RESPONSE literals type it; VEJAS_API_TITLE and friends fill the metadata).

Mixing them

A common shape: POST /api/orders (sync) validates and answers 201 with an id, and emits vx.orders.accepted — everything downstream (ERP sync, notifications) rides the async pipeline with its delivery guarantees. You choose the boundary per flow, and can move it later without changing infrastructure.

Handle failures: DLQ & replay

The delivery contract ends in one of two places: the message is processed, or it is parked with a verdict — never silently dropped (ADR-0015).

How a message dies

A flow error → redelivery (after VEJAS_ACK_WAIT_SECS) → up to 5 attempts → the dead-letter queue. Unparseable input (bad JSON) is direct poison — no pointless retries. The dead letter lands on vxdlq.<original subject> wrapped in a death envelope: original payload, error, attempt count, timestamps, and the flow version that killed it (ADR-0021) — so a post-mortem knows which logic failed, even after a promote.

Seeing and replaying

  • Panel: the DLQ card; API: GET /dlq; agent: vejas_dlq.
  • Replay is explicitPOST /dlq/replay (vejas_dlq_replay), never automatic: if the logic was wrong, replaying before fixing just kills the message again. Purge (/dlq/purge) is equally explicit and audited.

The loop that makes it self-healing

  1. A message dies; the envelope carries version v3.
  2. An agent reads vejas_dlq, drafts a candidate flow, proves it: replay yesterday’s real traffic through it (time-travel), watch it shadow live traffic (canary).
  3. In governed mode it proposes with that evidence; you approve — the promote fans out cluster-wide in 60 ms.
  4. vejas_dlq_replay — the dead messages pass under the new version, and their envelopes record the transition.

The human owns exactly one step: the meaning.

Change safely: versions, time-travel, canary

The business surface is meant to be edited live — a threshold, a mapping table, a routing rule (see the rules view). The point of this guide is the safety around that edit: you never change meaning blind. You preview the change against real traffic, promote it atomically, and can roll it back — all without a redeploy (ADR-0005, ADR-0021).

Preview before you promote

Shadow-replay reruns the flow’s last real events through the proposed change and shows you the before/after diff — nothing is published, nothing is committed.

  • Panel: edit a literal → Apply → the shadow strip shows what would change → Promote or Discard.
  • API: POST /surface/replay; agent: vejas_replay_literal.

If there is no recent traffic to replay against, Apply promotes directly — there is nothing to preview.

Promote and roll back

A promote rewrites one literal in place, hot-reloads the flow, and records an audit entry — no process restart.

  • Promote: POST /surface/set (vejas_set_literal).
  • Roll back: POST /surface/rollback (vejas_rollback_literal) — itself a forward-only, audited promote back to the value the literal held before its last change. History is never rewritten.

Bigger changes: time-travel and canary

For a change that touches a whole version of a flow (not one literal), two tools let you judge it against reality before it goes live:

Time-travelCanary
AgainstA window of past persisted trafficLive traffic, as it arrives
What it doesReplays that window through the candidate version, diffs vs. currentShadow-follows the flow, diffs each live event
Readvejas_time_travel / POST /surface/timetravelvejas_canary_status, GET /surface/canary
Start/stopone-shotvejas_canary_start / _stop

Both obey the shadow invariant: the candidate runs in a shadow engine and its emits are never published. You are comparing outcomes, not double-sending them.

In a cluster, a promote is a version

With VEJAS_CLUSTER=1, a local file write is refused — a promote instead publishes a version into a shared JetStream KV overlay that every instance converges on (benchmarks: 60 ms convergence, lossless mid-burst). If a later git deploy moves the baseline the promote was made against, the overlay is evicted loudly (git wins; the promoted content is kept in version history to re-promote) — visible at GET /evictions. You never get a half-cluster running two meanings.

The whole loop

Fix a dead letter: read the failure, draft the change, prove it (time-travel yesterday’s traffic, canary today’s), promote — and in governed mode that proof is the evidence a human approves. The human owns one decision: the meaning.

Governed mode: proposals & approvals

An agent is good at how — reading a dead letter, drafting the fix, proving it against real traffic. It should not be the one who decides what a rule means in production. Governed mode draws that line in the runtime: agents propose, a human approves (ADR-0024).

Turn it on

VEJAS_REQUIRE_APPROVAL=1
VEJAS_APPROVAL_TOKEN=<a secret distinct from VEJAS_TOKEN>

The approval token must differ from the agent’s VEJAS_TOKEN — the agent holds VEJAS_TOKEN to reach /mcp, so a shared secret would let it approve its own change. The runtime refuses to start if VEJAS_REQUIRE_APPROVAL is set without a distinct VEJAS_APPROVAL_TOKEN: a governance mode with a shared key is governance in name only.

What changes

With it on, every mutation door — the mutating MCP tools and the raw HTTP endpoints (/surface/set, /flows/new, /secrets/set, …) — stops executing and answers:

409  approval required: submit a proposal (vejas_propose, or the panel)

Reads stay open. A direct write is never silently accepted; it is turned into a request.

The flow

  1. Agent proposesvejas_propose with the change and its evidence (a shadow-replay diff, a canary result). It cannot approve. vejas_proposals lists the queue.
  2. Human decides — in the panel’s Approval queue card, or POST /proposals/{id}/approve|reject with the X-Approval-Token header. The card shows the evidence; a proposal with none is flagged “⚠ No evidence” loud — approving blind is a deliberate act.
  3. On approve — the change executes exactly as a normal promote would (hot-reload, or a cluster-wide version in 60 ms) and is recorded.

Two safeguards

  • Audit outlives the queue. The live queue is a bounded JetStream KV (VEJAS_PROPOSALS); the durable proof of who approved what is a separate audit stream (VEJAS_AUDIT) — a proposal aging out of the queue never takes its approval record with it.
  • No stale approvals. The baseline is re-checked at approve time: if the surface moved since the proposal was made, it auto-expires — re-propose against the current state.

Where it fits

Governed mode is the seam of the whole change-safely loop: the agent does the work and the proving, the human owns the meaning, and every step is on the record. Leave it off for a single-operator dev box; turn it on where a wrong meaning is a real incident.

Cluster & zero-downtime

Run N instances of the runtime against the same bus. There is no coordinator to install and no quorum to configure — the bus is the coordination (ADR-0020). Turn it on with one variable:

VEJAS_CLUSTER=1

What scales, and what stays single

UnitIn a clusterWhy
Flows, sinksAll N run — a shared JetStream durable load-balancesCompeting consumers: each message goes to exactly one instance
http-inAll N run behind your load balancerEach has its own listener; ingestion is stateless
Singleton sources (interval, poll, exec, stream, mqtt)Exactly one runsN timers/getters would produce N× the events

A singleton source takes a lease in a JetStream KV bucket (VEJAS_LEASES) before it runs:

  • acquire = atomic create-if-absent — exactly one instance wins.
  • renew = compare-and-set — a paused leader that wakes with a stale revision stands down (fencing: two instances never keep running the same unit).
  • release = delete on graceful shutdown — instant handoff.
  • failover = the bucket’s TTL (VEJAS_LEASE_TTL_SECS, default 10 s) ages a crashed leader’s lease out; a stand-by acquires.

Rolling a deploy with no loss

Because every publish is confirmed by JetStream before the source acks its input, a killed instance loses nothing — the message redelivers to a survivor.

  • Instance kill -9 under load: 20 000/20 000, zero loss.
  • Singleton failover: ~2.6 s graceful, ~5.9 s crash (TTL-bound).

(benchmarks.) Graceful shutdown rides SIGTERM (a k8s rolling restart): the lease hands off, in-flight work drains, the instance exits.

Changing meaning across the cluster

In cluster mode a local file write is refused (409) — a split where one instance fixed a rule and the others did not is the worst failure for a business surface. Change flows through GitOps, or through a version that publishes cluster-wide and every instance converges on in 60 ms, lossless (change safely).

Scaling past one getter

A singleton source is single by correctness, not capacity. When one getter is a bottleneck, partition: one manifest per key range (e.g. one Kafka consumer per partition set, each its own offset key). Ordered by default; throughput is a choice you make on purpose.

The rules view

The reason Vejas keeps flows as one screen of readable VejasScript is so the meaning — the thresholds, the mappings, the routing conditions — is visible and correctable by the person who owns it, not buried in code (ADR-0019). The rules view is where a domain expert reads and fixes that meaning without touching logic.

The business surface

Every flow’s editable meaning — its constants, transcoding tables, and mappings — is the business surface. It self-describes at GET /surface (vejas_surface):

{ "name": "SEVERITY_CODES", "kind": "table",
  "value": { "critique": "P1", "haute": "P2", "normale": "P3", "basse": "P4" } }

The panel renders each entry as an editable card; the agent reads the same surface to know what it may safely change.

Rules as sentences

Beyond the raw literals, the rules view projects a flow’s decision branches into plain conditions — GET /rules?file=<flow>:

{ "rules": [
    { "kind": "if", "when": "severity in ALERT_SEVERITIES",
      "then": ["→ vx.slack.out"], "literals": ["ALERT_SEVERITIES"],
      "projectable": true } ]}

You read “when severity is in ALERT_SEVERITIES, alert Slack” — and the literals tell you exactly which value to edit to change who gets alerted. A branch that is too dynamic to project cleanly is marked projectable: false and shown as its raw source rather than a misleading sentence.

Correcting a value

Editing one entry is a promote:

  • Panel: change the card → Apply.
  • API: POST /surface/set; agent: vejas_set_literal.

It hot-reloads that flow — the one unit picks up the new value, no process restart and no deploy. And you don’t do it blind: Apply previews the change against real traffic first (shadow-replay), so you see what would have differed before you commit. That whole safety story — preview, promote, roll back, canary — is change safely.

Why this is the differentiator

Anyone can generate glue that moves data. The thing an integration platform rarely gives you is a place where the person who knows the business can correct what a rule means — live, previewed, and audited — while the person who knows the plumbing keeps owning the plumbing. The rules view is that place.

Observability

Three windows into a running runtime, all built in — no sidecar, no agent to install (ADR-0016): a metrics endpoint to scrape, traces to a collector, and a live event ring to see what each flow just did.

Metrics — Prometheus

GET /metrics is always on, plain Prometheus exposition:

vejas_up 1
vejas_units{kind="flow"} 4
vejas_units{kind="connector"} 3
vejas_flow_restarts{unit="order_sync"} 0

Gauges (up, supervised units by kind, restarts per unit) come live from the supervision registry; the flow hot path adds counters and latency histograms. Point your Prometheus at the panel port and you have unit health, throughput, and error rate with no configuration.

Traces — OTLP

Set one variable and the runtime exports spans to any OpenTelemetry collector:

OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
OTEL_SERVICE_NAME=vejas          # optional label

Unset, it is a genuine no-op — no exporter thread, no overhead. Each flow execution becomes a span (subject, ok/error, emit count, timing), so an end-to-end request shows up as a trace across the pipeline.

The event ring — what just happened

For the everyday “did that message go through?” question there is a live in-memory ring of recent events:

  • Panel: the Recent events card (refreshes every 4 s).
  • API: GET /events; agent: vejas_events.

Each entry carries the subject, ok/error, the emitted subjects, a payload preview, and — for sinks — what the downstream answered (a rejected fact, a skip), so you see not just that a message was sent but how it landed.

Health and failures

  • GET /healthz — liveness for a load balancer or k8s probe.
  • Anything that failed past its retries is not lost — it is parked with a verdict in the DLQ, which is its own inspectable surface.

Everything here is a read; none of it changes what runs. The performance cost of all three, measured, is in the benchmarks — the hot path stays a few-MB, few-thousand-per-second runtime with the windows open.

Golden-traffic curation

Fixtures written by hand go stale; real traffic is the truth. Golden curation turns one real event into a permanent test in one click:

  • In the panel’s event ring, star ★ an event (or POST /events/golden).
  • The runtime re-runs the flow on that event now and captures the actual emits as the expectation.
  • What lands is a file: tests/vjs/curated_<flow>_<n>.vjs — input event + expected emits — which vejas-runtime vjs-test (and CI) runs forever.

That closes the expert→CI loop: the domain expert who recognizes a representative case doesn’t write a test — they point at reality, and the platform freezes it. When a flow’s behavior must change, time-travel and canary say what changes against history (changing safely); the curated cases say what must never change.

In a cluster, curation is refused on instances that hold no write access to the root (the cluster guard) — curate where the files live.

The certified catalog

Twenty recipes, every one admitted by CI — not “documented”, proven: credential lint (no literal secrets, single-sourced pattern), parse, then a real data-flow check. The recipes live in docs/examples/connectors/ — copy the manifest, fill your instance URL, set the secret it references, wire a flow.

FamilyRecipesCertified against
SaaS (http-poll / oauth-poll / http-out)ServiceNow ×2, Jira ×2, Slack, Workday RaaS, Stripe, SendGrid, GitHub, PagerDuty, DiscordA mock through the product’s own write path — data must flow
Webhook inShopify ordersIts own ingest: POST the fixture, see it on the bus
MQTTsource + sinkA real mosquitto on every CI run (incl. a 50-message QoS-1 burst)
AMQP/RabbitMQsource + sinkA real RabbitMQ on every CI run (through the broker and back)
Kafkasource + sink (kcat bridge)Stated exception + the offset-resume kill -9 test in CI
IBM MQsource + sink (transactional binary)Stated exception + a real-queue-manager verification transcript (MQCSP auth, live backout)

Three certification regimes, honestly labeled:

  • Mock-certified: the admission boots a throwaway runtime + the recipe’s mock; the probe must pass and a real message must flow.
  • Real-broker-certified: the recipe ships broker.sh (a throwaway container) and dataflow.sh; CI sends messages through the actual broker.
  • Stated exception: when a meaningful mock would be the system (a Kafka broker, a queue manager), the exception file says exactly what CI proves instead and what to verify against a dev instance — the first line is printed in the CI log, never silent.

Brokers: Kafka, MQTT, AMQP, IBM MQ

Each broker got the integration its protocol deserves — a deliberate spike-then-decide per broker (ADRs 0022, 0023, 0025, 0026, 0027):

BrokerShapeWhy
MQTTHand-rolled 3.1.1 client, in-binaryThe protocol is small; QoS 1 maps our at-least-once natively (source holds PUBACK until the bus confirms; sink acks the bus after PUBACK). Measured: 2 285 rt/s through a real mosquitto, QoS 1 both ways.
KafkaExec-bridge over kcatThe auth matrix (TLS, SASL, Kerberos) rides librdkafka in the child, never in our binary. Offsets in our KV: publish-before-commit, cadenced (100 ms), kill -9-proof in CI.
AMQP / RabbitMQFirst-class binary, pure Rustamiquip (sync, no tokio) + TLS via rustls over its mio loop — no OpenSSL anywhere. Consume→pub-ack→ack; publish→confirm→ack. Certified against a real RabbitMQ in CI.
IBM MQFirst-class transactional binaryThe no-loss guarantee is an ordering: MQGET under syncpoint → bus pub-ack → MQCMIT, in one process. Hand-declared MQI FFI, dlopen at runtime — builds with no MQ installed. MQCSP user/password auth. Verified live against a real queue manager: ordered drain, sink, and a bus-outage backout with zero loss.
PulsarDeferred, documentedThe client crate imposes tokio + OpenSSL as hard deps and ~233 crates — the highest cost of the wave for the least established demand. The build path is written down for when a real user asks (ADR-0027).

Operational notes that matter:

  • Singleton because order (MQ, AMQP, Kafka sources): instances contend on a KV lease; exactly one consumes. *_COMPETING=1 opts into competing consumers when throughput outranks global order — destructive reads make competing duplicate-safe.
  • IBM MQ packaging: the redistributable client needs its full directory structure; lib64 alone segfaults inside libmqic. Point VEJAS_MQ_LIB at <extract>/lib64/libmqic_r.so.
  • Recipes: mqtt_sourcemq_sink — env families in each .env.example.

SAP

The hard case that shaped the connector doctrine: a native protocol, a C SDK, IDocs — none of it belongs in the core binary.

The SAP connector is a standalone binary (connectors/sap-rfc) that loads SAP NW RFC at runtime (hand-declared FFI + dlopen, ADR-0014 — the same move later reused for IBM MQ): the build needs no SAP anywhere; only the run needs the SDK. It registers as an RFC server (SAP_PROGRAM_ID at the gateway) for inbound IDocs and speaks over the bus like every other citizen.

Configuration is the SAP_* env family (SAP_ASHOST, SAP_SYSNR, SAP_CLIENT, SAP_USER, SAP_PASSWD, SAP_PROGRAM_ID, gateway host and service) — credentials from your secret machinery, never inline. The request/response path (rpc:exec) is cluster-safe through a queue group: each instance holds its own SAP logon, the bus distributes.

The end-to-end bridge (IDoc in → flow → response) is exercised in the recorded demo; a live SAP is required for the last mile, which is why the recipe carries a stated exception rather than a mock that would prove nothing.

VejasScript

The language reference below is extracted from the runtime’s own vejas_language contract — the exact text every agent reads before writing a flow. It is the source of truth; if this page and the tool ever disagree, the tool wins.

VejasScript in 20 lines:
  # comment
  source "vx.domain.name"            <- the flow's input subject, REQUIRED, line 1
  SEVERITY_CODES = {"critique": "P1", "haute": "P2"}   <- UPPERCASE literal dicts are transcoding tables the business expert edits
  ALERT_LEVELS = ["P1", "P2"]        <- UPPERCASE literal lists/scalars are editable constants
  x = priority                       <- the incoming event's top-level fields are variables; `event` is the whole document
  code = SEVERITY_CODES[priority] ?? "P3"
  email = lower(requester?.email)    <- builtins: upper lower trim len str num split join replace round abs; ?. is null-safe
  ids = orders[].id                  <- array projection
  big = orders[total > 100]          <- array filtering
  out = out + [{sku: l.sku}]         <- array concatenation builds lists inside a for
  fact = {source: "graph", in: 2}    <- doc keys and .field names may be ANY word, keywords included
  invoke format_alert(sev: code)     <- compose a service from services/<name>.vjs; its outputs MERGE into this pipeline
  d = invoke format_alert(sev: code) <- or capture its whole pipeline as a document
  invoke pkg:svc(k: v)               <- cross-package composition (the target package must list svc in its EXPORTS)
  key = secret("slack/webhook")      <- credentials resolve from the Vault at run time; NEVER a literal
  if code in ALERT_LEVELS:
      emit "vx.slack.out", {text: f"[{code}] {subject}"}
  end                                <- every if/for closes with `end`

Exposing a flow (instead of, or besides, `source`):
  tool "what calling this flow does" <- exposes the flow as an MCP tool
  api "POST /orders"                 <- expose the flow as a SYNCHRONOUS HTTP endpoint under /api (POST /api/orders)
  api "GET /orders/{id}"             <- a REST resource = several flows, ONE per verb; {path params} become event variables (here `id`)
  API_REQUEST = {customer: "string", total: "number"}   <- optional: typed request schema for the generated OpenAPI
  API_RESPONSE = {id: "string", status: "string"}       <- optional: typed 200 response schema
  respond 201, {id: id, status: "created"}   <- the SYNCHRONOUS HTTP response (status code + JSON body); `emit` still fires bus side-effects

Rules:
- Known sinks: vx.slack.out (payload {text: "..."}). All subjects start with "vx.".
- Put every business-meaningful value (thresholds, tables, queue names) in UPPERCASE literals.
- A flow file's first line is `# flow: <snake_case_name>`; it lives under flows/ (or packages/<pkg>/flows/).
- Its sample input lives at flows/fixtures/<flow>.json (or packages/<pkg>/fixtures/) — one JSON event.
- A flow is triggered by ONE of: `source "vx…"` (bus), `tool "…"` (MCP), or `api "VERB /path"` (HTTP). An `api` flow answers with `respond <status>, {body}`; the request's JSON body, {path params} and `query` are all in the event. The whole API is described at GET /api/openapi.json.
- A connector manifest's first line is `# connector: <name>`, then `driver "<name>"` (catalog: vejas_drivers) and UPPERCASE literal config; any credential uses secret("path/key"), never a literal.

Design notes

  • Pure by construction (ADR-0001): no I/O, clock, or network exists in the language. Effects happen only at the edges (emit, respond) — which is what makes replay, time-travel and canary structurally safe.
  • UPPERCASE literals are the contract with humans: anything a domain expert may want to change belongs in one (the business surface).
  • Files are the deployment unit: flows/<name>.vjs, connectors/<name>.vjs, fixtures next to them, golden tests under tests/vjs/. Git is the source of truth; the runtime supervises the directory.

Environment variables

Everything below is read from the environment at process start. Nothing is mandatory for a dev run: vejas-runtime with a local NATS and a VEJAS_ROOT directory is a complete system.

Core runtime

VariableDefaultPurpose
NATS_URLnats://127.0.0.1:4222The bus. The only infrastructure dependency (ADR-0002).
VEJAS_ROOT.Root directory: flows/, connectors/, tables/, tests/.
VEJAS_HTTP_ADDR0.0.0.0:8686Panel + HTTP API + /mcp listen address.
VEJAS_TOKEN(unset)When set, the whole mutating surface (including /mcp) requires Authorization: Bearer.
VEJAS_SUBJECT_ROOTvxBus subject prefix.
VEJAS_STREAMVEJASJetStream stream name the runtime creates/uses.
VEJAS_ACK_WAIT_SECS30Redelivery window per consumer. Floor is 1s — JetStream silently rejects less. Cluster guidance: 3–5s.
VEJAS_STATUS_SECS10Status heartbeat cadence.
VEJAS_TENANT(unset)Tenant label for provisioned packages.
VEJAS_BUNDLE(unset)Bundle path for provisioning (vejas_provision).
VEJAS_AGENT_CMD(unset)Command the panel uses for its agent-assist box.

Secrets (ADR-0008)

VariablePurpose
VEJAS_SECRETS_FILEFile-backed secret store (dev). VEJAS_SECRET_<PATH> env entries also resolve.
VAULT_ADDR / VAULT_TOKEN / VEJAS_VAULT_MOUNTHashiCorp Vault backend for secret("path/key").

Clustering (ADR-0020)

VariableDefaultPurpose
VEJAS_CLUSTER(off)Enables the cluster guard: local file mutations are refused (409) — changes go through versions or proposals.
VEJAS_INSTANCEhostnameInstance identity for leases and audit.
VEJAS_LEASE_TTL_SECS10Singleton-lease TTL: crash-failover bound.

Governed mode (ADR-0024)

VariablePurpose
VEJAS_REQUIRE_APPROVAL=1Every mutating path answers “submit a proposal instead”.
VEJAS_APPROVAL_TOKENThe human approval credential (X-Approval-Token header) — deliberately distinct from the agent’s VEJAS_TOKEN. The runtime refuses to start governed mode without it.

Observability (ADR-0016)

VariablePurpose
OTEL_EXPORTER_OTLP_ENDPOINTOTLP/HTTP-JSON export (hand-rolled, no SDK tree). /metrics (Prometheus) is always on.
OTEL_SERVICE_NAMEService name for exported spans/metrics.

API flows

VariablePurpose
VEJAS_API_TITLE / VEJAS_API_VERSION / VEJAS_API_DESCRIPTIONOpenAPI metadata for flows exposed as APIs.

Standalone connector binaries

Each first-class connector binary reads its own VEJAS_<NAME>_* family plus NATS_URL and VEJAS_STREAM. The certified recipes under docs/examples/connectors/ are the authoritative, linted reference for: IBM MQ (VEJAS_MQ_*, plus VEJAS_MQ_LIB to point at the redistributable client and VEJAS_MQ_USER / VEJAS_MQ_PASSWORD for MQCSP auth), AMQP/RabbitMQ (VEJAS_AMQP_*, TLS via VEJAS_AMQP_TLS_CA / VEJAS_AMQP_TLS_SERVER_NAME), SAP (SAP_*), and Salesforce (SF_*).

HTTP API

One listener (VEJAS_HTTP_ADDR, default :8686) serves the panel, the API and MCP. With VEJAS_TOKEN set, every mutating route requires Authorization: Bearer <token>. In governed mode (ADR-0024) mutating routes answer with a didactic refusal pointing at the proposal queue.

Request headers

HeaderOnPurpose
Authorization: Bearer <token>every mutating routeWrite protection, when VEJAS_TOKEN is set.
X-Approval-Token: <token>/proposals/{id}/approve|rejectThe human approval credential, distinct from VEJAS_TOKEN (ADR-0024).
X-Vejas-Actor: <id>any mutating routeOptional. Records who made the change in the audit trail; absent, the actor is panel. The enterprise auth proxy sets it after authenticating a user (ADR-0030) — useful open too, for a single operator naming themselves. Trimmed, capped at 128 chars.

Health & introspection

RoutePurpose
GET /healthzLiveness.
GET /metricsPrometheus text format — hand-rolled, always on.
GET / · GET /panelThe panel (single embedded HTML).
GET /topologyUnits and their wiring.
GET /graphThe flow graph (drives the panel’s clickable diagram).
GET /eventsRecent traced events (ring).
GET /driversLive driver catalog with config contracts.
GET /rulesThe rules view (ADR-0019): N1 parameters, N2 read-only projection.
GET /surfaceThe business surface: editable literals, tables, spans.
GET /evictionsVersion-overlay evictions (git-wins, loud — ADR-0021).

Files & surface

RoutePurpose
GET /file · POST /file/setRead / write a flow or connector file (guarded paths).
POST /surface/setEdit one literal through the product write path (targeted restart).
GET /fixture · POST /fixture/setPer-flow test fixtures.
POST /previewRun a flow against a fixture without publishing.
POST /flows/new · POST /connectors/newCreate from the panel/agent.
POST /connectors/testConnector probe (auth reachability, no writes).
POST /reloadReload units after out-of-band file changes.

Failure handling

RoutePurpose
GET /dlqDead letters with death envelopes (version-tagged).
POST /dlq/replay · POST /dlq/purgeExplicit replay / purge (ADR-0015).

Versions, time-travel, canary (ADR-0021)

RoutePurpose
POST /surface/timetravelCandidate vs live over a window of persisted real traffic.
POST /surface/canary/start · /stop · GET /surface/canaryShadow canary on live traffic.
POST /surface/replayReplay literal history (ADR-0018).
POST /surface/rollbackForward-only rollback of a literal.

Governance (ADR-0024)

RoutePurpose
GET /proposalsThe proposal queue.
POST /proposals/{id}/approve · /rejectHuman decision — requires X-Approval-Token (distinct credential).

Curation & provisioning

RoutePurpose
POST /events/goldenCapture a ring event as a curated test case (golden traffic).
POST /provisionInstantiate a tenant package from a template.
GET /secrets · POST /secrets/setSecret paths (values never returned).

Agents

RoutePurpose
POST /mcpJSON-RPC 2.0 — the runtime is the MCP server. See MCP tools.
POST /ingest/<suffix> (http-in connector, own port)Webhook ingestion → vx.<suffix> on the bus, 202 after the JetStream pub-ack.

MCP tools

The runtime is the MCP server: JSON-RPC 2.0 over POST /mcp (ADR-0006). Point any MCP client at http://<host>:8686/mcp; when VEJAS_TOKEN is set, send it as Authorization: Bearer <token>. The list below is generated from the tool descriptions the agent actually sees — they are the contract.

vejas_topology

List running flows and connectors with their status.

vejas_graph

The pipeline graph: sources, flows, composed services, destinations, connectors.

vejas_surface

The business surface of every flow: mappings, transcoding tables, constants.

vejas_language

The VejasScript reference: grammar, builtins, and the rules for flow files and connector manifests. Read this before writing any .vjs.

vejas_read

Read a script file (.vjs) or fixture (.json).

vejas_write_flow

Create or overwrite a .vjs script (parse-validated, hot-reloaded) or a .json fixture. path under flows/, connectors/, or packages/<pkg>/flows|services|fixtures.

vejas_set_literal

Rewrite one literal of the business surface in place (constant, or a table/mapping entry via key).

vejas_rollback_literal

Roll a business-surface literal back to the value it held before its most recent promote (from the VEJAS_AUDIT trail). Rollback is itself an audited promote to the recorded previous value — forward-only, hot-reloaded, previewable first with vejas_replay_literal. Returns {restored, was, rolled_back_promote_ts}.

vejas_time_travel

Time-travel (ADR-0021): replay a window of REAL persisted traffic through a whole CANDIDATE version of a flow and diff its emissions against the current effective version, joined by stream sequence. Read-only, the bus untouched, a candidate’s emits never reach a real subject. Use to preview an arbitrary rewrite (not just one literal) before promoting it. Returns {events, changed, results:[{seq, before, after, changed}]}.

vejas_canary_start

Start a canary (ADR-0021): shadow-follow a flow’s LIVE traffic and diff a candidate version against the current effective version as events arrive, accumulating a diff. Read-only (shadow — no real emit). Refuses if a canary is already running for the flow. Auto-stops if the live version changes under it (reason in status).

vejas_canary_status

Read a canary’s accumulating diff: {running, events, changed, stop_reason, results:[{seq, before, after, changed}]}.

vejas_canary_stop

Stop a running canary for a flow (its shadow consumer exits; the last diff stays readable).

vejas_propose

Submit a governed change PROPOSAL for a human to approve in the panel (ADR-0024) — you can propose but never approve. kind=‘set_literal’ (payload {file, name, key, value}) or kind=‘version’ (payload {file, candidate: whole source}). Attach evidence you gathered (vejas_time_travel results, vejas_canary stats) — the panel shows it next to Approve, and flags ‘no evidence’ loudly. The proposal is pinned to the current baseline and auto-expires if a deploy/promote moves it. Returns the stored proposal (id, status:pending).

vejas_proposals

List the proposal queue with status (pending/approved/rejected/expired) and evidence. Read-only. Approve/reject are human panel actions, not tools.

vejas_replay_literal

Shadow-replay a proposed literal change against REAL persisted traffic: hydrate the flow’s recent events from JetStream (read-only, the bus untouched — falls back to the in-memory trace ring when the stream is empty or the flow has no bus source), rerun them against the current AND the patched script, and return the before/after emit diff (with source: jetstream|trace-ring). Nothing is written — promote with vejas_set_literal.

vejas_preview

Run a flow on its fixture and return the emitted messages plus the final pipeline.

vejas_run_flow

Run any flow on a supplied input event and return its emits (does not touch the bus).

vejas_events

The most recent events processed by the flows — subject, ok/error, emitted subjects, payload preview — newest first. Optional filter: flow (e.g. “flow:stripe_alerts”).

vejas_reload

Rescan flows and packages; start new, stop removed, restart changed.

vejas_drivers

List the available connector drivers (name, kind, description) for writing connector manifests.

vejas_secrets

The secret references declared by flows and connectors, who uses each, and whether it RESOLVES against the store — references and statuses only, never values.

vejas_set_secret

Write one secret value into the store (rotation included) and restart the units that reference it. WRITE-ONLY: no surface ever returns the value.

vejas_test_connector

Synchronously test one connector instance: evaluate its manifest with the real secrets, reach the remote side with the driver’s probe, touch nothing. Returns {ok, detail} in plain words.

vejas_provision

Instantiate a tenant package from a template (templates/<name>/, ${param} substitution, every file parse-checked, hot-started). Returns created files, started units and the secret references left to write. Refuses an existing package unless force (which overwrites template-rendered files).

vejas_dlq

List dead letters — poison messages parked in the DLQ (ADR-0015) instead of dropped: unit, original subject, attempts, last error, payload, each with a seq for replay/purge. Newest first.

vejas_dlq_replay

Replay dead letters — re-inject each to its ORIGINAL subject so the (now corrected) flow reprocesses it, then remove it from the DLQ. Target one by seq, a whole unit, or all (omit both). Do this AFTER fixing the cause (vejas_set_literal, previewed with vejas_replay_literal).

vejas_dlq_purge

Discard dead letters without replaying — by seq, by unit, or all (omit both).

vejas_new_flow

Ask the agent to write a new VejasScript flow from a natural-language request; it lands running.

vejas_new_connector

Ask the agent to write a new connector manifest from a natural-language request (picks a driver, writes config, uses secret() for credentials); it lands running.

Subjects

The single source of truth for the subject convention and the driver catalog is docs/SUBJECTS.md in the repository — included verbatim below.

The subject convention (this is the whole connector interface)

Everything on the bus lives under one subject root: vx. (configurable via VEJAS_SUBJECT_ROOT). One JetStream stream named VEJAS binds vx.>.

Bundled connectors are native Rust drivers run from a declarative manifest (connectors/<name>.vjs: driver "..." + literal config, editable in the panel, hot-addable). No subprocess, no Python. Drivers today:

  • http-in (source:webhook) — POST /ingest/<suffix>vx.<suffix>. Config: PORT.
  • timer (source:interval) — emits PAYLOAD on SUBJECT every INTERVAL_SECS (an object payload gains a ts field, ISO 8601 UTC, when absent).
  • http-poll (source:poll) — GETs URL every INTERVAL_SECS → SUBJECT. Optional HEADERS; optional ENVELOPE = true publishes {endpoint, fetched_at, body} (like oauth-poll) so a stateless flow gets a collected_at.
  • oauth-poll (source:poll) — OAuth2 client-credentials REST poller: token from TOKEN_URL (CLIENT_SECRET via secret()), GETs each of ENDPOINTS with the Bearer, pagination via NEXT_LINK_FIELD (default @odata.nextLink, absolute links followed as-is) capped by MAX_PAGES, publishes one {endpoint, fetched_at, body} message per page on SUBJECT. SCOPE is optional (omitted from the token form when empty — e.g. CrowdStrike). EXPAND = [{name, list, detail, key, as, list_field?}] adds a client-side $expand — every item of the list array (list_field, default value; a bare-string item becomes {key: id}, so CrowdStrike’s resources: [ids] → per-id detail works) enriched with its detail call, the page shipped as one envelope — for list APIs without a server-side expand. One generic OAuth+REST driver stands in for most of a connector catalog.
  • slack-out (sink) — consumes vx.slack.out → Slack webhook / DRY-RUN.
  • http-out (sink) — consumes SUBJECT → POST to URL. Optional HEADERS doc for authenticated pushes, values via secret(): HEADERS = {"Authorization": f"Bearer {secret("acme/api_token")}"}.
  • mqtt-in (source) / mqtt-out (sink) — a hand-rolled synchronous MQTT 3.1.1 client, in-binary, zero dependency (ADR-0025). QoS 1 maps our at-least-once natively: the source holds the broker’s PUBACK until the bus publish is confirmed; the sink acks the bus only after the broker’s PUBACK. TLS / QoS 2 / MQTT 5 → the mosquitto exec-bridge escape hatch.
  • mq source/sink (standalone binary, connectors/mq) — IBM MQ as a first-class transactional citizen (ADR-0023): MQGET under syncpoint → bus publish (await pub-ack) → MQCMIT, and the mirror for the sink. Hand-declared MQI FFI, dlopen of the MQ client at runtime — builds with no MQ present. Configured by env (VEJAS_MQ_*, see the recipe), not by a manifest.
  • amqp source/sink (standalone binary, connectors/amqp) — RabbitMQ / AMQP 0-9-1 as a first-class citizen (ADR-0026): pure Rust, sync, no tokio, TLS via rustls over amiquip’s mio loop (no OpenSSL). Source acks AMQP only after the bus pub-ack; sink acks the bus only after the publisher confirm. Configured by env (VEJAS_AMQP_*, see the recipe). Certified against a real RabbitMQ in CI.
  • exec-source / exec-sink — bridge an external program in ANY language over stdio (source prints JSON on stdout; sink reads JSON on stdin). The hot-add path for new connector types without recompiling the core or loading native libs (ADR-0011).

An external connector in any language is still a first-class citizen: it is just a process that follows these rules on the same bus. Language, host and supervisor are irrelevant.

  1. Publish JSON, UTF-8, on vx.<domain>.<name>.
  2. Consume with a durable pull consumer whose durable name identifies you.
  3. Ack a message only after its side effect succeeded; on failure, nak with a delay. Redelivery is the retry mechanism; make side effects idempotent.
  4. Ensure the VEJAS stream exists before first use (idempotent create).
  5. Expose nothing else. No registry, no manifest, no RPC handshake.

Flows (VejasScript) follow the same contract; the runtime does the boring parts and guarantees every emit is published before the incoming message is acked, so a crash means redelivery, never a lost emit.

One sibling root is reserved: vxc.<tenant>.> — the remote-collector control channel (leaf-node uplink, closed command allowlist, local approval for content changes). It lives OUTSIDE vx.> on purpose: control traffic is transient and must never be captured by a stream (a stream on the command subject would hijack request/reply with its pub-ack). Specification: CONTROL.md, decision: ADR-0013.

Benchmarks

Every number quoted in these docs is reproducible from bench/ — scripts, not claims. Machine of record: an 8-core dev machine under WSL2; run them on yours.

Current numbers

MetricValueReproduce with
Cold start (spawn → healthz)11–13 msbench/run.sh
Runtime RSS under load6–8 MB (49 MB with 50 live flows)bench/run.sh, bench/multi-flow.sh
Binary / image6.2 MB / 201 MB
e2e latency, uncongested (webhook→flow→sink)p50 2 ms, p99 3 msbench/paced.sh 20 15
e2e paced sustained ~1 900/sp50 14 ms, p99 36 ms, 20 000/20 000bench/paced.sh 2000 15
e2e saturated (32 conns)~4 900/s ingest, ~2 650/s delivered (sink-bound)bench/run.sh 15 32
Isolated flow hop8 110/s (9 948/s over 10 flows)bench/flow-only.sh, bench/multi-flow.sh
MQTT loopback, QoS 1 both ways, real mosquitto2 285 rt/s, 5 000/5 000bench/broker-mqtt.sh 5000
Cluster: instance kill -9 under load20 000/20 000, zero lossbench/cluster.sh
Cluster: singleton failover~2.6 s graceful / ~5.9 s crash (TTL-bound)bench/cluster-gaps.sh
Cluster-wide version promote60 ms convergence, lossless mid-burstbench/cluster-promote.sh

Every hop persisted in JetStream throughout — the guarantee never moved while these numbers were earned. Methodology, the five ceilings that fell (and their causes), and the honest comparison table against Redpanda Connect and n8n: bench/README.md and bench/compare/.

Architecture decisions

The platform’s memory: every consequential decision is an ADR — context, decision, consequences, and what was rejected. They are the honest answer to “why is it built this way”, and the moat a rewrite would have to re-earn.