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
| Part | Role |
|---|---|
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 |
| Modules | Optional drivers and plugins (OPC UA, MQTT, historian, cron jobs today) loaded into Core at run time behind the SDK contract |
| NATS JetStream | The internal event backbone for tag values and metadata. Not optional: a hard runtime dependency, bundled in the image |
| SQLite | Configuration and a projection of the current state |
| The dashboard | A 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 workers | Separate 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:
| Stream | Holds | Kept for |
|---|---|---|
SCADEX_VALUES_V1 | Every accepted tag value, in order | EventBackbone__ValueRetention, 7 days by default |
SCADEX_CHECKPOINTS_V1 | Each tag's last value, published every hour on the hour | The value retention plus two hours |
SCADEX_METADATA_V1 | Tag created, changed, deleted and other configuration events | EventBackbone__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;
MissedMessageson 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__CheckpointIntervalsets 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.
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.
- 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. - The runtime publishes the value to the value stream and waits for the persisted acknowledgement. Only then is the value current.
- Subscribers on
/syncreceive atag.valueframe. A slow client loses its oldest queued frames rather than slowing the runtime, and one that falls too far behind is disconnected. - 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.
- 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,instanceIdand an opaquebindingdocument. - 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.
- Tags have stable public ids, and the numeric ids the hot path uses are never recycled.
- A value is accepted only after JetStream returns a persisted acknowledgement. Nothing downstream sees a value that was not durably stored.
- Value batches are bounded by both count and latency.
- SQLite stores configuration and a current-state projection; JetStream is the authoritative recent event timeline.
- Metadata changes use a durable outbox and enter a replayable stream.
- Modules are optional. Each has its own version and licence in a
module.jsonthat Core reads before loading any of its code. - Modules load in collectible
AssemblyLoadContextinstances and share only the stable SDK contracts with Core. - API-capable modules declare endpoints through the restricted contract; Core owns their route namespace, authorization, availability and diagnostics.
- Core stores module state opaquely. It never interprets connection URLs, credentials, native addresses or technology.
- A tag's external ownership is the neutral tuple
moduleId,instanceId,binding. - Technology is optional manifest metadata for discovery and the UI. Core never branches on it.
- Writes converge on
ITagRuntime.WriteAsync. Clients never invoke drivers directly, and every write is recorded against its principal before it is attempted. - 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.
| Process | What it is |
|---|---|
nats-server | The event backbone, loopback only |
dotnet Scadex.Core.dll | The 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:
| Component | Version |
|---|---|
| Core | 0.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 module | Its 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.