Skip to main content
Version: Next

Architecture

This page describes the parts of a Scadex gateway and how a value travels through them. It is for people who run a gateway, write modules or build on the SDK, and want to know what holds what and where the guarantees come from.

The parts​

PartRole
Core (Scadex.Core)The gateway host: tag identity and metadata, current state, controlled reads and writes, event ordering, durability, replay, security, the public APIs and module hosting
ModulesOptional drivers and plugins (OPC UA, MQTT, historian, cron jobs today) loaded into Core at run time behind the SDK contract
NATS JetStreamThe internal event backbone for tag values and metadata. Not optional: a hard runtime dependency, bundled in the image
SQLiteConfiguration and a projection of the current state
The dashboardA React single-page app, built with Vite and served from Core's wwwroot
The TypeScript SDK (@scadex.io/sdk)The client the dashboard uses for the REST API and the /sync WebSocket
Script workersSeparate Scadex.ScriptWorker processes that run scripts for Core

Protocols and consumers live behind the module boundary. Core itself has no driver-specific controllers, services or tables.

The event backbone​

Every accepted tag value and every metadata change is an event on NATS JetStream, running inside the gateway's container on the loopback interface (nats://127.0.0.1:4222, its data under /data/nats/jetstream). Three streams exist:

StreamHoldsKept for
SCADEX_VALUES_V1Every accepted tag value, in orderEventBackbone__ValueRetention, 7 days by default
SCADEX_CHECKPOINTS_V1Each tag's last value, published every hour on the hourThe value retention plus two hours
SCADEX_METADATA_V1Tag created, changed, deleted and other configuration eventsEventBackbone__MetadataRetention, 30 days by default

The rules that follow from this:

  • A value is accepted only after JetStream returns a persisted acknowledgement. Until then it is not current, subscribers have not seen it, and alarms have not evaluated it. The runtime batches values before publishing, and a batch is bounded by both count and latency (and by size, at 900 KiB), so a burst is split and a trickle is not held back for long.
  • JetStream is the authoritative recent timeline. Replay, the history fallback when no historian runs, and every value consumer (the historian, event streams) read from it. A consumer that falls behind catches up for as long as the stream retains the values; MissedMessages on a batch means retention outran it.
  • Checkpoints make replay possible for quiet tags. The value stream holds only what changed, so without a checkpoint a tag that stood still for a day would have nothing to replay from. EventBackbone__CheckpointInterval sets the spacing, a whole fraction of an hour of at least a minute.
  • Metadata changes go through a durable outbox and enter a replayable stream, so a tag created while a consumer was away is still seen by it.

Values and metadata events use versioned protobuf schemas in Scadex.Sdk/Wire/Protos.

SQLite​

SQLite holds the configuration (tags, types, users, connections, module state, alarms and their state, audit rows) and a current-state projection of the tags. It runs in WAL mode with synchronous=NORMAL, and commits do not checkpoint: a background task does, every 5 seconds. So a commit does not wait for an fsync, though a stalled disk can still delay a write.

The database cannot be corrupted this way, and a gateway crash loses nothing. A power cut can lose the last few seconds of commits, which can include recent configuration changes and write audit rows. That is the same window JetStream has, which also syncs on an interval.

warning

Back up DATA_DIR/keys together with the database. Module state, which includes driver passwords, is encrypted with those keys; without them the stored state cannot be read. See Secrets at rest and Backup and restore.

The value path​

The diagram follows one reading from a device to everyone who needs it.

  1. A driver reports a value for a tag it owns. Scaling and the deadband run here, inside the runtime, before the value gets a sequence number, so everything downstream holds the engineering value with the raw value beside it as rawValue.
  2. The runtime publishes the value to the value stream and waits for the persisted acknowledgement. Only then is the value current.
  3. Subscribers on /sync receive a tag.value frame. A slow client loses its oldest queued frames rather than slowing the runtime, and one that falls too far behind is disconnected.
  4. Alarms evaluate the value on their own task, from a queue, so evaluation can never delay or fail publication. The queue is memory, and holds 32,768 values.
  5. The historian and any other module that consumes values reads the stream from the position it last reported, on its own task. All a slow or failing consumer can do is fall behind.

A write follows the same path in reverse: every write converges on one method of the tag runtime (ITagRuntime.WriteAsync), which records it against its principal in the audit log, inverse-scales it, and hands it to the owning module or stores it for a memory tag. Clients never invoke a driver directly. The accepted value then comes back round through the steps above like any other.

The module boundary​

A module is a folder with a module.json, a LICENSE and an assembly. Core reads module.json before loading any of the module's code, so a disabled, incompatible or unlicensed module never executes. The manifest names the module's id, version, license, the sdkVersion it was built against, and whether it requiresLicense.

Each module loads in its own collectible AssemblyLoadContext and shares only the stable SDK contracts (Scadex.Sdk, Scadex.Sdk.AspNetCore) with Core. Because the context is collectible, a module can be stopped, restarted or upgraded while the gateway runs, each time as a fresh instance running the files on disk now; the gateway tries to free the old code and says in the module's log if something still holds it.

What Core gives a module and what it keeps for itself:

  • Core stores module state opaquely by module id and key. It does not own or interpret connection URLs, credentials, native addresses or technology. A tag's external ownership is the neutral tuple moduleId, instanceId and an opaque binding document.
  • API-capable modules declare endpoints through a restricted ASP.NET contract. Core maps them under /api/v1/modules/{module-id}/, applies the role policies, and reports the routes. A module has no say in its policy: a GET needs viewer and any other method needs engineer, unless the module marks an endpoint as one that writes tag values, which lets operators call it.
  • Drivers get a guaranteed baseline surface for connections, test, browse and discovery that the dashboard renders from the module's descriptor. Browse results are neutral labels, hierarchy, data types and opaque bindings; the dashboard turns selections into tags through Core's own tag API.
  • A module can consume every accepted value (IModuleValueConsumer), answer history reads (IModuleHistoryProvider), and since contract 1.3 run scripts in the gateway's sandbox (IModuleContext.Scripts).

Modules run as trusted in-process code. The restricted route contract prevents accidental routing and policy violations; it is not a security sandbox for untrusted assemblies. Writing a module has the contract in detail, and Licensing the signing and grant rules.

The dashboard​

The dashboard is a static single-page app that the Docker build compiles and copies into Core's wwwroot before publishing. Core serves it on the same origin as the API, so there is no separate static host and no CORS in production. Bundle files under /assets are named by content and cached for a year; index.html is always re-checked. Any path nothing else matched falls back to index.html, except under /api, where a missing route still answers 404.

Requests that change anything, and the /sync upgrade, must come from an origin listed in AUTH_TRUSTED_ORIGINS. See Security.

The TypeScript SDK and /sync​

@scadex.io/sdk is the client the dashboard is built on, and the one you use to build your own pages. It wraps the REST API and the /sync WebSocket, which carries {event, data} JSON frames: subscribe to tags and get tag.value frames on every change, write with tag.write, subscribe to alarms, run a replay, send screen events. It relies on same-origin cookies, so it runs in a browser served by the gateway or through a proxy that forwards /api and /sync to it, or with an API key from a script.

Values reach /sync subscribers only after the backbone has durably accepted them. Each user may hold 32 sockets, and each socket 10,000 subscriptions. An open socket closes within 30 seconds of its session ending. Sync lists the frames, and TypeScript SDK the client.

Script workers​

Expression tags, screen scripts and module scripts do not run inside the gateway process. They run in a small pool of Scadex.ScriptWorker processes that talk to it over stdin and stdout, one script per process at a time: two ready from startup and, on a machine with the cores for them, up to four when needed, each using about 50 to 60 MiB.

A worker's environment holds none of the gateway's settings or secrets. A script stuck where its limits cannot be checked is killed a second after its limit; one that crashes its worker ends only that worker, which is replaced, and the script is refused for 5 seconds, doubling each time up to 5 minutes. Workers end with the gateway, however it ends, because their stdin closes. The worker is not a stronger security boundary than the JavaScript sandbox itself: it has the gateway's user and file access. The scripting section has the limits per script kind.

Secrets at rest​

Module state, which includes driver passwords, is encrypted in SQLite with ASP.NET Data Protection, using a purpose per module. A stored row starts with enc:v1:. Modules never see that: their state store reads and writes plain JSON. On startup any row still in plaintext is encrypted, and the database is then rewritten and its WAL emptied so that the old text does not linger in free pages.

This protects against a leaked database file. It does not protect against someone holding the whole data volume, because the keys are stored unencrypted beside the database in DATA_DIR/keys. Without those keys the stored state cannot be decrypted, and the module that owns it reports failed with an error saying so.

Invariants​

These are the rules the design holds to. If a behavior seems to contradict one of them, it is a bug.

  1. Tags have stable public ids, and the numeric ids the hot path uses are never recycled.
  2. A value is accepted only after JetStream returns a persisted acknowledgement. Nothing downstream sees a value that was not durably stored.
  3. Value batches are bounded by both count and latency.
  4. SQLite stores configuration and a current-state projection; JetStream is the authoritative recent event timeline.
  5. Metadata changes use a durable outbox and enter a replayable stream.
  6. Modules are optional. Each has its own version and licence in a module.json that Core reads before loading any of its code.
  7. Modules load in collectible AssemblyLoadContext instances and share only the stable SDK contracts with Core.
  8. API-capable modules declare endpoints through the restricted contract; Core owns their route namespace, authorization, availability and diagnostics.
  9. Core stores module state opaquely. It never interprets connection URLs, credentials, native addresses or technology.
  10. A tag's external ownership is the neutral tuple moduleId, instanceId, binding.
  11. Technology is optional manifest metadata for discovery and the UI. Core never branches on it.
  12. Writes converge on ITagRuntime.WriteAsync. Clients never invoke drivers directly, and every write is recorded against its principal before it is attempted.
  13. Every route names the role it needs, and a route that names none is closed.

What runs in the container​

The appliance image contains ASP.NET Core and NATS Server. Its entrypoint (deploy/entrypoint.sh) creates the data directories (/data/nats/jetstream, /data/historian, /data/certificates, /data/backups, /data/internal, /data/licenses, /modules), starts nats-server with the bundled configuration (loopback only, JetStream on, monitoring on 127.0.0.1:8222), then starts dotnet /app/Scadex.Core.dll and waits on it. A signal to the container stops Core first, then NATS. Core starts the script worker processes itself.

ProcessWhat it is
nats-serverThe event backbone, loopback only
dotnet Scadex.Core.dllThe gateway: API, /sync, dashboard, module host
Scadex.ScriptWorker (2 to 4)Script execution, started by Core

Persistent data lives in the /data volume (DATA_DIR): SQLite, Data Protection keys, JetStream storage, the historian's file, module logs and data. Installed modules live in /modules. The API and dashboard listen on one port, 8080 in the compose file. Installation and Configuration cover the settings.

Versions​

Nothing is versioned as a single product:

ComponentVersion
Core0.1.0
Module contract (Scadex.Sdk)1.3. A module's sdkVersion is its major.minor; it loads on a gateway with the same major and the same or a newer minor
Each moduleIts own semantic version and its own LICENSE, declared in module.json

Core, the SDK and each module can be open or proprietary independently of the others. @gateway.version reports the running Core version as a tag.