OA-Gateway Guide
Configuration, architecture, and protocol-specific how-tos for OA-Gateway. Crate APIs — including the host binary’s own modules — are in the rustdoc reference instead.
Start with the glossary if the acronyms are unfamiliar, or architecture for how the crates fit together.
Glossary
This page defines OA-Gateway’s own terms, then the domain terms it borrows. Expansions are those that can be confirmed against the standards documents themselves, which are not shipped in this repository — see using a custom XSD for how the UCI schema is fetched. Where a standard does not establish an expansion, the entry describes the role the term plays in this repository.
OA-Gateway’s own terms
| Term | Meaning |
|---|---|
| Envelope | The value that crosses the engine: id, route, string headers, content-type label, and an opaque Bytes payload. The engine reads only the route. |
| RouteKey | An address: topic plus an optional type_hint. |
| topic | The routing coordinate every envelope has. On the ActiveMQ path it equals the UCI message type name and the JMS/STOMP destination suffix. |
| type_hint | An optional discriminator within a topic — an OWP message name, a UCI message type. The engine only compares it for equality. None on a subscription means “every type on this topic”. |
| Adapter | A protocol plugin that owns its own I/O loop. It talks only to the engine, never to another adapter. |
| Engine | The in-process router. It is protocol-agnostic and does not parse payloads. |
| wildcard subscription | A subscription with type_hint: None, matching every type on its topic. |
| conversion | Mapping a payload between OMS JSON and UCI XML. Deliberately forgiving: an element the schema does not declare is carried rather than refused, and a value that does not fit its declared type is carried as written rather than coerced into fitting. |
| validation | Checking a payload against what the compiled schema states — declarations, occurrence ranges, alternations, abstract types, facets, and the primitive a value has to be. Separate from conversion, because a message can convert cleanly in both directions and still not be a valid instance of the standard. Controlled by uci.validate. |
| facet | A constraint a simple type puts on a value: the enumeration it has to belong to, the length it has to be, the numeric bounds it sits within, the pattern it has to match. The published UCI catalog declares 7,766 enumerated values and 143 patterns across 945 types. |
| violation | One way a payload departs from the schema, with a dotted path to the element. A message is reported in full rather than at the first fault. |
Domain terms
| Term | Meaning |
|---|---|
| OMS | Open Mission Systems. The standards family this gateway interoperates with. |
| UCI | Universal Command and Control Interface. Supplies the message schema — message types such as PositionReport and SubsystemStatus. oa-gateway-uci compiles the published XSD, so conversion covers whatever catalog the operator loads through uci.schema; scripts/fetch-uci-schema.sh fetches the published UCI 2.5 documents. |
| CAL | Critical Abstraction Layer. The OMS component boundary a participant implements. Java CALs speak OpenWire; uci-cal-jms and sk-cal are CAL implementations this gateway is meant to sit alongside. |
| OWP | OMS WebSocket Protocol. The text-frame protocol oa-gateway-owp serves, with INIT/SUB/PUB/MSG/OK/ERR operations. Its grammar comes from OMSC-SPC-013, the language-agnostic CAL specification. |
| MT | Message type. Used in this repo for the UCI type name carried in type_hint, as in “the wrapper MT and the inner MT”. |
| A-GRA | The standard defining the MA_RxDataPayload and MA_TxDataPayloadCommand wrappers that oa-gateway-agra peels, published as A-GRA_MessageDefinitions_v5_0_a.xsd alongside the ASK 5.0a interface volumes. |
| MA-C2, MA-MA, MA-VI, MA-MS | A-GRA interface designators. They line up with the ASK 5.0a interface volumes: Command and Control, Peer, Vehicle, and Mission Systems respectively. The first two are external interfaces and use the Rx/Tx hexBinary wrappers; the platform-facing two use native MTs and skip the wrapper. |
| hexBinary | xs:hexBinary, the XSD type A-GRA uses to carry a complete inner message as hex text inside a wrapper’s EncodedPayload. |
| PolySample | A UCI construct whose JSON form carries a $type discriminator; oa-gateway-uci handles it explicitly. |
| ASB | In this repo, the ActiveMQ Classic broker acting as the shared bus between protocols — the setup config/asb.toml and compose/activemq.yml bring up. The “ASB path” is the naming rule where UCI message type, engine topic, STOMP destination, and JMS topic are all the same name. |
Messaging protocols
| Term | Meaning |
|---|---|
| STOMP | Simple Text Oriented Messaging Protocol. A text framing over TCP that ActiveMQ accepts on :61613. oa-gateway-stomp is a STOMP client, not a JMS implementation. |
| JMS | Java Message Service. The Java messaging API whose topic model ActiveMQ exposes; a JMS topic demo is STOMP destination /topic/demo. |
| OpenWire | ActiveMQ’s native binary wire protocol, and what Java CAL peers use. The gateway does not speak it — ActiveMQ bridges OpenWire and STOMP when the destination names match, which is why the naming rule matters. |
| DDS | Data Distribution Service. oa-gateway-dds joins a domain as a participant. Engine topic equals DDS topic. Samples are A-GRA Rx/Tx (MaDataPayload on the wire) rather than a generated UCI IDL catalog. The first provider is rustdds; a later Cyclone or Fast DDS stack is another DdsProvider, not a change to the adapter. |
| WebSocket | The transport OWP runs over, on ws://127.0.0.1:9000/ with subprotocol owp. |
Architecture
OA-Gateway routes messages between protocols in a single process. The engine matches envelopes by topic and does not parse payloads. Each protocol is an adapter that translates its own frames and communicates only through that engine. A new protocol is a new adapter; it is not a change to oa-gateway-core.
The adapter contract is in writing-an-adapter.md, terms are in glossary.md, and configuration keys are in configuration.md.
Layers
The diagram is a UML component view. Each package is a layer. A hollow triangle means the type realizes Adapter, a solid arrow is a use, and a dashed arrow is a start or an optional dependency.
classDiagram
direction TB
namespace Host {
class Gateway {
<<composition root>>
oa-gateway
+serve(config)
}
}
namespace Adapters {
class LoopbackAdapter {
<<component>>
oa-gateway-loopback
}
class OwpAdapter {
<<component>>
oa-gateway-owp
}
class StompAdapter {
<<component>>
oa-gateway-stomp
}
class DdsAdapter {
<<component>>
oa-gateway-dds
}
class DdsProvider {
<<interface>>
oa-gateway-dds
}
}
namespace Contract {
class Adapter {
<<interface>>
oa-gateway-adapter
+id()
+run(engine, shutdown)*
}
}
namespace Codecs {
class Uci {
<<library>>
oa-gateway-uci
}
class Agra {
<<library>>
oa-gateway-agra
}
}
namespace Core {
class Engine {
<<core>>
oa-gateway-core
+publish(envelope)
+subscribe(route)
}
}
Adapter <|.. LoopbackAdapter
Adapter <|.. OwpAdapter
Adapter <|.. StompAdapter
Adapter <|.. DdsAdapter
DdsAdapter --> DdsProvider : uses
Adapter --> Engine : uses
Gateway ..> LoopbackAdapter : starts
Gateway ..> OwpAdapter : starts
Gateway ..> StompAdapter : starts
Gateway ..> DdsAdapter : starts
Gateway ..> Engine
Gateway ..> Uci : compiles
OwpAdapter ..> Uci : convert
OwpAdapter ..> Agra : unwrap
StompAdapter ..> Agra : unwrap
DdsAdapter ..> Agra : unwrap
Agra --> Engine : Envelope
| Crate | Role | Depends on |
|---|---|---|
oa-gateway-core | Payload-blind pub/sub: Envelope, RouteKey, and Engine. | tokio |
oa-gateway-adapter | The Adapter trait: id and run(engine, shutdown). | core |
oa-gateway-uci | XSD compilation, JSON ↔ XML conversion, and validation. A library, not an adapter. | none of the workspace crates at runtime |
oa-gateway-agra | Peel and wrap MA_RxDataPayload / MA_TxDataPayloadCommand. A library. | core |
oa-gateway-loopback | In-process peer with no socket. | adapter and core |
oa-gateway-owp | WebSocket server: framing, per-connection subscriptions, optional convert/validate, and reconnect. | adapter, core, uci, and agra |
oa-gateway-stomp | STOMP 1.2 client toward a broker, including echo skip and reconnect. | adapter, core, and agra |
oa-gateway-dds | DDS participant: engine topic equals DDS topic, A-GRA Rx/Tx samples, rustdds provider, optional validate and reconnect. | adapter, core, uci, and agra |
oa-gateway | Composition root: load TOML, compile the schema, resolve addresses, and spawn run. | every runtime crate |
oa-gateway-testing | Cross-engine tests and harnesses. Adapter crates must not depend on it. | optional and one-way |
oa-gateway-uci does not depend on the engine, so the schema codec can be used without a router. The engine sees XML or JSON bytes and a route. It never sees a Message tree.
Envelopes and routing
An Envelope carries an id, a RouteKey (topic plus an optional type_hint), string headers, a content-type label, and an opaque Bytes payload. The engine reads only the route.
A publish with type_hint: Some("Ping") is delivered to both typed(topic, "Ping") and topic(topic) subscribers. Fan-out uses try_send: a full subscriber loses that delivery instead of blocking the publisher. Drops are counted in EngineStats, and the host logs those counters on [engine] stats_interval_secs.
Headers are namespaced by owner (oag.*, stomp.*, agra.*). The oag.* constants live in core. The engine does not interpret header values.
Message flow
config/asb.toml is the two-adapter path: OWP on one side and a STOMP client toward a broker on the other. One OWP adapter id covers every WebSocket connection. STOMP is a single client session on the bus. config/dds.toml is the same idea on a DDS domain: loopback plus one rustdds participant. The sample type is A-GRA Rx/Tx; the DDS topic name is the engine topic.
sequenceDiagram
participant WS as OWP client
participant OWP as oa-gateway-owp
participant Eng as Engine
participant STOMP as oa-gateway-stomp
participant AMQ as ActiveMQ
WS->>OWP: PUB topic + payload
Note over OWP: optional unwrap, convert, validate
OWP->>Eng: publish Envelope
Eng->>STOMP: Delivery on matching sub
Note over STOMP: skip if is_echo_of(stomp)
STOMP->>AMQ: SEND /topic/{name}
AMQ->>STOMP: MESSAGE
STOMP->>Eng: publish with_origin(stomp)
Eng->>OWP: Delivery
OWP->>WS: MSG
Inbound MESSAGE frames are stamped with oag.origin_adapter. When suppress_echo is on, outbound SEND skips envelopes that originated on this adapter. The engine does not skip by origin. OWP uses one adapter id for every WebSocket, so a core-level origin skip would hide a message from other clients on the same server.
Conversion (JSON ↔ XML) runs only in OWP. Validation runs in OWP and DDS; the host hands [uci].schema and validate to both. STOMP forwards bytes to ActiveMQ; Java CAL peers already speak XML on that bus. With xml_baseline, the WebSocket side is OMS JSON while the engine and the broker see UCI XML.
TLS terminates at the socket, in the adapter, and is opt-in per adapter. OWP terminates it as a server when owp.tls_cert/owp.tls_key are set; STOMP originates it as a client when stomp.tls is set, verifying the broker against stomp.tls_ca or the operating system trust store. Neither the engine nor the codecs ever see a wrapped stream: oa-gateway-adapter’s MaybeTlsStream is what OWP’s Session and STOMP’s FrameReader/FrameWriter hold instead of a bare TcpStream, and its plaintext variant is exactly what a deployment with no certificate/CA configured uses. DDS is not a candidate for this — RTPS runs on UDP, not a TCP stream — so its equivalent is DDS Security, which this build does not configure.
owp.tls_client_ca goes one step further: oa_gateway_adapter::tls::server_tls builds a rustls::server::WebPkiClientVerifier from that bundle and requires a matching client certificate on every connection, refusing anything else at the handshake — mutual TLS, and the one place this build authenticates a peer connecting to it. It stops at the handshake: nothing about which certificate connected is threaded into Session or the engine, so this is authentication without authorization, by design. stomp.tls_client_cert/stomp.tls_client_key are the mirror image on the client side: client_tls presents that certificate to the broker via ClientConfig::with_client_auth_cert instead of verifying anyone else’s, authenticating the gateway to the broker rather than a peer to the gateway. SECURITY.md covers what that does and does not mean.
Host
The binary owns process lifetime. It does not implement a protocol.
- Parse the command line: one config path, or
--help/--version. - Load the TOML file. An unknown key is a startup error. A named adapter table is on; an omitted table is off.
- Compile
[uci].schemabefore any adapter listens. - Resolve
owp.bindandstomp.broker. DDS has no hostname;[dds] qosis checked as a path. - Spawn each enabled adapter’s
runon the sharedEngineand a cancellation token. - Log engine counters until Ctrl-C, then cancel and join every task.
If run returns Err, that adapter is down. The host logs the error and leaves the others running. It does not restart a finished run. STOMP, OWP, and DDS each retry inside their own loop, built on the same after_join/OnPanic decision in oa-gateway-adapter; on_panic on each one chooses abort or reconnect after a session panic. Loopback has no session and no on_panic key — nothing in it can fail or panic in normal operation.
src/config/ and src/adapters/ name loopback, OWP, STOMP, and DDS. A new protocol is a crate plus a host section, not a dynamically loaded plugin. The DDS crate talks to rustdds only through a DdsProvider trait so a later vendor stack is another implementation, not a change to the adapter.
Libraries
oa-gateway-uci and oa-gateway-agra are codecs. Adapters depend on them; they are not adapters and they do not own a socket.
- UCI compiles XSD, converts JSON ↔ XML, and reports schema violations. Conversion is forgiving; validation is a separate pass. See using-custom-xsd.md.
- A-GRA unwraps an Rx/Tx hex payload so subscribers can route on the inner message type. OWP, STOMP, and DDS call it when
unwrap_ma_payloadsis on. DDS samples carry the inner bytes already decoded;unwrapped_from_partsbuilds the same wrapper and inner envelopes without serializing to XML first. Platform-facing A-GRA interfaces use native message types and do not use this crate.
Design constraints
- The core does not parse payloads and does not name a protocol. Logic that depends on the meaning of the bytes belongs in an adapter or a codec crate.
- Adapters do not call each other. They share an
Engine, which is what makes them independently testable and removable. - Echo suppression is the bridging adapter’s responsibility. A new bus adapter must stamp origin and skip its own id the same way STOMP does. The engine will not do it.
- The gateway can terminate TLS, and OWP can optionally authenticate a peer’s certificate, but there is no authorization. Both are opt-in and off by default. Verifying the peer’s identity (
owp.tls_client_ca) is a further opt-in step past plain TLS, and stops at the handshake — nothing decides what an authenticated peer may do. OWP frame, connection, and subscription limits are still what isolate one client from the others. SECURITY.md states the assumptions.
Further reading
| Task | Document |
|---|---|
| Implement a protocol | writing-an-adapter.md and the Echo doc-test in oa-gateway-adapter |
| Change a configuration key | configuration.md |
| Bridge ActiveMQ | connecting-active-mq.md |
| Join a DDS domain | connecting-dds.md |
| Browse crate APIs | cargo doc --workspace --no-deps --document-private-items --open |
Configuration
The host takes one TOML file as its only argument. Every section in that file is optional. Naming [loopback], [owp], [stomp], or [dds] starts that adapter; omitting the table leaves it off. Set enabled = false to keep the keys in the file without starting the adapter. An unknown key is a startup error. At least one adapter table must be present and enabled.
./target/release/oa-gateway config/default.toml
Paths in the file are relative to the process working directory, normally the repository root. Hostnames (owp.bind, stomp.broker) are resolved once at startup. A name that does not resolve fails then, not later in a retry loop.
TLS is off unless configured. owp.tls_cert / owp.tls_key make the OWP listener serve wss://; stomp.tls makes the STOMP client dial the broker over TLS instead of plaintext. DDS has neither — its transport is UDP, not a TCP stream. A plain TLS connection is encrypted, not trusted; owp.tls_client_ca (a client authenticating to OWP) and stomp.tls_client_cert/stomp.tls_client_key (the gateway authenticating to a broker) are further, separate opt-in steps for that. There is still no authorization anywhere in this build — an authenticated peer is not treated any differently from one that was not. Bind loopback, or keep both ends on a trusted segment. SECURITY.md states the assumptions.
Shipped files
| File | Role |
|---|---|
config/default.toml | Local development: loopback and OWP on loopback, STOMP off, no schema. |
config/compose.toml | Container example for compose/gateway.yml: OWP on 0.0.0.0:9000, STOMP off. |
config/asb.toml | Host-side ActiveMQ bridge with a schema, xml_baseline, and STOMP enabled. Requires scripts/fetch-uci-schema.sh and a broker. |
config/dds.toml | Loopback plus a rustdds participant on domain 0. Requires config/dds-qos.xml. |
shipped_configs_parse fails if one of those files names a key the structs do not declare.
[engine]
The engine has no runtime settings of its own. This section controls how often the host logs engine counters. Omitting it uses a 30-second interval.
| Key | Default | What it does |
|---|---|---|
stats_interval_secs | 30 | Seconds between EngineStats log lines (published, delivered, dropped). 0 disables the ticker. A line also warns when dropped increased since the last one. |
[uci]
This section names the schema documents and what to do when a payload is not an instance of them. Conversion and validation both need the files listed explicitly; the standard is not redistributed here. See using-custom-xsd.md.
| Key | Default | What it does |
|---|---|---|
schema | [] | XSD paths. Empty means no conversion and no validation. List every document the catalog spans; xs:include and xs:import are not followed. |
validate | "warn" | "warn" reports a departure and carries the message; "reject" refuses it and tells the peer; "off" skips the check. Ignored when schema is empty. A typo is refused as uci.validate: …. |
owp.xml_baseline requires a schema. Startup refuses that combination rather than failing on the first converted message.
[loopback]
Loopback is an in-process adapter with no socket. It is off unless this table is in the file.
| Key | Default | What it does |
|---|---|---|
enabled | on when the section is present | false keeps the keys without starting the adapter. |
id | "loopback" | Engine adapter id. |
[owp]
OWP is the WebSocket server. It is off unless this table is in the file.
| Key | Default | What it does |
|---|---|---|
enabled | on when the section is present | false keeps the keys without starting the adapter. |
id | "owp" | Engine adapter id. One id covers every WebSocket; it is not a per-connection name. |
bind | "127.0.0.1:9000" | Listen address, host:port. |
tls_cert | "" | PEM certificate chain served to clients, leaf certificate first. Empty leaves the listener plaintext. Requires tls_key; setting one without the other is a startup error. |
tls_key | "" | PEM private key for tls_cert, in PKCS#8, PKCS#1, or SEC1 form. Empty leaves the listener plaintext. |
tls_client_ca | "" | PEM bundle of certificate authorities a client certificate must chain to. Empty accepts a client with or without one. Requires tls_cert/tls_key; a client that cannot present a certificate from this bundle is refused at the handshake. |
server_id | "oa-gateway-0" | Identity sent on INIT. |
system_label | "OA-Gateway Prototype" | Human-readable label sent on INIT. |
schema | "002.5.0" | Protocol version string a client INIT must match exactly. This is not [uci].schema. Empty disables the check. |
unwrap_ma_payloads | true | Peel A-GRA Rx/Tx hex wrappers on PUB and publish the wrapper and the inner message. |
xml_baseline | false | Convert OMS JSON ↔ UCI XML at the socket so the engine and a broker see XML. Requires [uci].schema. |
max_frame_size | 16777216 | Largest frame accepted from a client, in bytes. An oversized frame ends that session. |
max_connections | 256 | Connections served at once. Further connections are closed on accept. |
max_subscriptions | 1024 | Subscriptions one connection may hold. A SUB past the limit is refused and the session continues. |
init_timeout_secs | 30 | Seconds from an accepted connection to a successful INIT before it is closed. Measured from the handshake, not reset by traffic. 0 disables. |
idle_timeout_secs | 600 | Seconds with no frame in either direction on an active session before it is closed. Any client frame and any server frame (including a delivered MSG) resets it, so an active publisher or subscriber is never closed for being idle. 0 disables. |
allowed_origins | [] | Exact Origin header values accepted at the WebSocket handshake. Empty accepts any origin, including none. A non-empty list refuses a handshake whose Origin is not one of these — a missing Origin included — with 403. Match is verbatim: list every scheme, host, and port a browser client connects from. |
reconnect | false | Rebind and accept again after the accept loop ends or panics, instead of leaving the adapter stopped until the whole process restarts. Defaults off so an existing deployment sees no behavior change until it opts in. |
reconnect_delay_secs | 1 | Seconds to wait between rebind attempts. |
on_panic | "abort" | "abort" ends the adapter when the accept loop panics; "reconnect" treats the panic as a failed session and then follows reconnect. A typo is refused as owp.on_panic: …. |
A client is not authenticated, so max_frame_size, max_connections, and max_subscriptions isolate one peer from the memory of the others, and init_timeout_secs / idle_timeout_secs keep a peer from holding a connection slot without making progress. The subscription default is larger than the UCI catalog, so a client can still subscribe to every message type in the standard.
With tls_cert and tls_key set, the listener speaks wss:// and nothing else; a plaintext client is refused at the handshake. Set tls_client_ca too, and a client that cannot present a certificate from that bundle is refused as well — the one form of peer authentication this gateway has. It stops there: a client that connects with a valid certificate is not treated any differently from one that connected without tls_client_ca set at all. It may publish and subscribe exactly as before, and the gateway does not record or act on which certificate it was.
[stomp]
STOMP is a client toward an ActiveMQ or other broker. It is off unless this table is in the file, so a configuration that never names it does not need a broker. Worked examples and topic mapping are in connecting-active-mq.md.
| Key | Default | What it does |
|---|---|---|
enabled | on when the section is present | false keeps the keys without starting the adapter. |
id | "stomp" | Engine adapter id. |
broker | "127.0.0.1:61613" | Broker address, host:port. |
host | "/" | STOMP host header. ActiveMQ Classic typically wants "/". This is not tls_server_name — it is a protocol header, not a hostname. |
login | "" | CONNECT login. Empty omits the header instead of sending a blank. |
passcode | "" | CONNECT passcode. Empty omits the header. Sent only when login is set. |
destination_prefix | "/topic/" | Prepended to each topic to form a STOMP destination. |
topics | ["demo"] | Engine topic names and STOMP destination suffixes, bridged both ways. A name you do not list is not bridged. |
unwrap_ma_payloads | true | Peel A-GRA Rx/Tx hex wrappers on inbound MESSAGE and publish the wrapper and the inner message. |
reconnect | true | Retry the broker after a dropped session. |
reconnect_delay_secs | 1 | Seconds to wait between reconnect attempts. |
connect_timeout_secs | 5 | Seconds for TCP connect, the TLS handshake when tls is set, and the CONNECTED wait, each. |
suppress_echo | true | Skip outbound SEND when the envelope came from this adapter, so a message does not loop between the gateway and the broker. |
on_panic | "abort" | "abort" ends the adapter when a session task panics; "reconnect" treats the panic as a failed session and then follows reconnect. A typo is refused as stomp.on_panic: …. |
max_frame_size | 16777216 | Largest frame accepted from the broker, in bytes. It bounds both the read buffer and the content-length a peer can claim. |
tls | false | Wrap the broker connection in TLS. ActiveMQ Classic’s SSL transport connector conventionally listens on 61612, not 61613. |
tls_ca | "" | PEM bundle of the certificate authorities the broker’s certificate must chain to. Empty uses the operating system trust store, which is where an organizational CA normally lives. |
tls_server_name | "" | Name checked against the broker’s certificate. Empty uses the host part of broker; a bare IP address there requires an IP SAN in the certificate, which most do not have. |
tls_client_cert | "" | PEM certificate chain presented to the broker, leaf certificate first. Empty presents nothing. Requires tls_client_key and tls = true. |
tls_client_key | "" | PEM private key for tls_client_cert, in PKCS#8, PKCS#1, or SEC1 form. Empty presents nothing. |
login and passcode are the broker’s credentials. They are sent in the clear unless tls is on, which is the reason to turn it on. tls_client_cert/tls_client_key are a separate, further step: presenting a certificate to a broker whose SSL transport connector requires one (ActiveMQ’s needClientAuth), independent of whatever login/passcode also send.
[dds]
DDS is a participant on a domain. It is off unless this table is in the file. There is no broker hostname to resolve. Worked examples, the QoS subset, and topic mapping are in connecting-dds.md.
| Key | Default | What it does |
|---|---|---|
enabled | on when the section is present | false keeps the keys without starting the adapter. |
id | "dds" | Engine adapter id. |
provider | "rustdds" | Which DdsProvider to construct. "rustdds" is the only legal value in this build. A typo is refused as dds.provider: …. |
domain_id | 0 | DDS domain the participant joins. Peers must use the same id. |
qos | required | Path to a QoS file. Missing or empty is a startup error when the section is present. rustdds parses a documented DDS-XML subset (reliability, durability, history). A later vendor provider may pass the same path to its own loader. |
topics | ["demo"] | Engine topic names and DDS topic names, bridged both ways. A name you do not list is not bridged. |
unwrap_ma_payloads | true | Peel A-GRA Rx/Tx wrappers on inbound samples and publish the wrapper and the inner message. |
suppress_echo | true | Skip outbound writes when the envelope came from this adapter, so a message does not loop between the gateway and the domain. |
reconnect | false | Rejoin the domain after the session ends or panics, instead of leaving the adapter stopped until the whole process restarts. Defaults off so an existing deployment sees no behavior change until it opts in. |
reconnect_delay_secs | 1 | Seconds to wait between rejoin attempts. |
on_panic | "abort" | "abort" ends the adapter when the session panics; "reconnect" treats the panic as a failed session and then follows reconnect. A typo is refused as dds.on_panic: …. |
max_sample_size | 16777216 | Largest inbound sample accepted, in bytes, before it is unwrapped or converted. An oversized sample is dropped and logged rather than ending the session — DDS has no per-peer connection to end. |
Inbound samples are checked against [uci].schema the same way OWP traffic is; [uci].validate decides what a violation costs, and has no effect without a schema. Unlike OWP, DDS has no peer connection to notify, so reject drops the sample and logs it rather than answering an error frame.
[dds] is omitted from config/default.toml because the local toy has no domain to join. Use config/dds.toml when you want one.
Adding a section
A new adapter needs a #[derive(Deserialize)] struct in crates/oa-gateway/src/config/ with #[serde(deny_unknown_fields)] and a default on every field, and a corresponding block in a shipped example (config/default.toml, or a worked file such as config/dds.toml when the adapter is opt-in). Writing an adapter covers the rest of the wiring.
Writing an adapter
An adapter owns the protocol on one side of the gateway: the socket, framing, handshake, and any schema translation. The engine owns routing and nothing else. A new protocol is a new adapter; it is not a change to oa-gateway-core. The crate graph and an example path are in architecture.md.
A minimal example is in the oa-gateway-adapter crate docs (cargo doc -p oa-gateway-adapter --open, or crates/oa-gateway-adapter/src/lib.rs). That example is a doc-test, so CI fails if it stops compiling. crates/oa-gateway-adapter/tests/echo.rs runs the same adapter under traffic: a Ping on demo must produce a Pong.
Contract
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Adapter: Send + Sync + 'static {
fn id(&self) -> &AdapterId;
async fn run(
self: Arc<Self>,
engine: Arc<Engine>,
shutdown: CancellationToken,
) -> Result<(), AdapterError>;
}
}
run is the adapter’s lifetime. Accept connections, read frames, publish envelopes, and return when shutdown fires. Returning Err is fatal for that adapter only. The host logs the error and leaves the others running.
The design depends on four rules:
- Do not parse a payload in the core, and do not name a protocol there. If
oa-gateway-corewould need to know what the bytes mean, the logic belongs in the adapter crate. - Do not call another adapter. Adapters share an
Engineand nothing else. Two adapters exchange data by publishing and subscribing, which is what makes them independently testable and removable. - Own the delivery channel. Create the
mpsc::Senderpassed tosubscribe, and read the matching receiver. - Leave the engine clean. Call
engine.drop_adapter(id)on stop. Otherwise the subscriptions keep matching and silently discard messages.
Routing
A RouteKey is a topic plus an optional type_hint:
RouteKey::typed("PositionReport", "PositionReport")matches one message type on one topic.RouteKey::topic("PositionReport")matches every type on that topic. A subscription withtype_hint: Noneis a wildcard.
type_hint is whatever discriminator the protocol has: an OWP message name, a UCI message type, or a PDU type. The engine compares it for equality and does not interpret it.
Publishing is fan-out to matching subscribers. A publish with type_hint: Some("Ping") reaches both typed(topic, "Ping") and topic(topic) subscribers.
Envelopes and headers
An Envelope carries an id, a route, string headers, a content-type label, and an opaque Bytes payload. The engine reads only the route.
Headers are namespaced by owner so envelopes stay legible as they cross adapters:
| Prefix | Owner | Examples |
|---|---|---|
oag. | gateway-wide | oag.origin_adapter, oag.topic, oag.type_hint, oag.id |
stomp. | STOMP adapter | stomp.destination, stomp.message-id |
agra. | A-GRA wrappers | agra.wrapper, agra.command_id, agra.originator_uuid |
The oag.* constants live in oa-gateway-core (HDR_ORIGIN, HDR_TOPIC, HDR_TYPE_HINT, HDR_ID). oa-gateway-stomp re-exports them. Use Envelope::with_origin and Envelope::is_echo_of rather than repeating the strings.
Echo suppression
An adapter that bridges an external bus must not send back what it just received, or a message loops between the gateway and the broker. The convention is two-sided:
- On the way in, stamp with
envelope.with_origin(&your_id). - On the way out, skip when
envelope.is_echo_of(&your_id).
The engine does not skip by origin. One adapter id may cover many connections, as OWP does. STOMP implements the convention in inbound_publish and forward_outbound. DDS does the same, and the rustdds provider also drops samples whose writer shares this participant’s GUID prefix, because rustdds delivers local writes. [stomp] suppress_echo and [dds] suppress_echo (both default true) are the knobs. A new bridging adapter must do the same.
Backpressure
Engine::publish uses try_send, so a full subscriber loses the message instead of blocking the publisher. Channel capacity defaults to DEFAULT_CHANNEL_CAPACITY (64). If the adapter can be slower than the traffic it subscribes to, read the receiver on a dedicated task and buffer on the adapter’s own terms.
Drops are counted in EngineStats. The host logs those counters on [engine] stats_interval_secs (default 30; 0 disables) and warns when dropped increased. Publish sites that inspect PublishOutcome also log a per-message drop.
Lifecycle
The adapter sequences its own startup, teardown, and reconnect:
- Subscribe after the transport is up, so deliveries are not queued before they can be sent.
- Select on
shutdown.cancelled()in the same loop that reads the transport, so cancellation is observed promptly. - Put the retry loop outside the session.
StompAdapter::serve_inner,OwpAdapter::run, andDdsAdapter::runeach run one session on a child task, so a panic there is a join error rather than an unwind that would take the retry loop down with it — the join result is fed to the sharedoa_gateway_adapter::after_join.on_panicisabort(default: a panic endsrun) orreconnect(treat the panic as a failed session, then follow the adapter’s ownreconnectsetting). The host does not restart a finishedrun. Loopback has no session and skips this entirely — nothing in it can fail or panic in normal operation. - Call
drop_adapteron the way out, and again when a session restarts. The STOMP adapter also calls it at session start, which clears stale subscriptions left by a previous connection.
Host wiring
The host crate (crates/oa-gateway) reads a TOML section per adapter (src/config/), validates it, and spawns run (src/adapters/). Naming the table starts the adapter. enabled must default to true when the section is present and to false in Default (the omitted-section path).
To add an adapter, add a #[derive(Deserialize)] section struct in src/config/ with #[serde(deny_unknown_fields)] and #[serde(default = "...")] on every field, resolve addresses with resolve_addr before spawning when the protocol has a hostname, and start the adapter from src/adapters/. Add the section to a shipped example. DDS has no hostname; it checks that [dds] qos exists instead. The shipped_configs_parse test fails if a shipped file and the struct disagree.
Testing
Adapter crates keep unit tests for their own codec and mapping logic. Traffic that crosses the engine belongs in oa-gateway-testing. Its harness feature provides OWP, STOMP, and DDS helpers and start_mini_broker(), an in-process STOMP broker on an ephemeral port, so most tests do not need Docker.
The fastest end-to-end check is against Loopback: subscribe a loopback handle to the topic the adapter publishes, drive the adapter’s transport, and assert on the envelope that arrives. crates/oa-gateway-testing/tests/stomp_bridge.rs and tests/dds_bridge.rs are the pattern to copy.
Reference adapters
Read them in this order:
oa-gateway-loopback— the smallest complete adapter, about 90 lines including the trait implementation.oa-gateway-stomp— a client bridge: framing, handshake, reconnect, echo suppression, and destination mapping.oa-gateway-dds— a domain participant: provider shim, file-based QoS, A-GRA samples, and echo skip including local rustdds writes.oa-gateway-owp— a server: it accepts connections, tracks per-connection subscriptions, and performs schema translation.
Using a custom XSD
Nothing about a message set is compiled into the binary. The gateway reads XSD at startup and builds its schema from the documents you name. A program-specific Message Set, a later UCI revision, or a trimmed subset all work the same way. scripts/fetch-uci-schema.sh is only a convenience for the published UCI 2.5 documents.
[uci] keys and defaults are also listed in configuration.md.
Configuration
[uci]
schema = [
"/path/to/YourMessageDefinitions.xsd",
"/path/to/YourSecurityMarkings.xsd",
]
validate = "warn"
Pass every document the schema spans. xs:include and xs:import are not followed, because following them would mean reading file paths out of the schema text. A document you leave out appears as unresolved type names, not as a type that quietly goes missing later.
A schema is optional. Without one, payloads cross the engine untouched. owp.xml_baseline requires a schema, because converting between OMS JSON and XML is what the schema is for.
Startup output
A schema that will not compile stops the gateway and names the reason. Otherwise the log reports what was read:
INFO oa_gateway: uci schema compiled files=1 messages=1 complex_types=1 simple_types=1
Two warnings are worth reading. Both mean the same thing: a constraint the gateway cannot read enforces nothing, and no later log can distinguish that from a value that passed.
WARN values of these types will not be checked beyond the facets on them primitives="xs:base64Binary"
WARN some schema patterns cannot be checked and will not be enforced count=1 types="TagType"
The first names a primitive with no check behind it, such as xs:base64Binary or xs:anyURI. xs:string is never listed, because there is nothing to check about a string beyond the facets on it. The second names a type whose xs:pattern uses a corner of XSD’s regex language that this build does not translate: character-class subtraction, or the \i and \c name shorthands.
Neither warning fires on the published UCI catalog.
Supported XSD subset
The compiler follows UCI’s Schema Style & Design Specification rather than XSD at large. It refuses what falls outside that subset instead of guessing. A custom schema needs to keep to:
- Named top-level types. An element with an anonymous inline type is refused. Give the type a name and refer to it.
- Extension as the only complex derivation.
xs:restrictionon complex content is refused, as isxs:redefine. - Flat compositors. An
xs:sequenceorxs:choicethat holds another compositor, or that carries its ownminOccurs/maxOccurs, has no representation in the flat element model. - Restriction for simple types, with the facets the validator understands:
enumeration,pattern,length,minLength,maxLength, and the fourmin/maxbounds.whiteSpaceis accepted and ignored. Any other facet is refused, because a constraint that is silently dropped cannot be told apart from one that was never there.
The constructs outside that list do not appear in UCI, so none of them is a limitation you will meet with the published schema. Attributes and xs:any are refused by name. substitutionGroup= is the one construct read past rather than refused: the element still compiles, but the substitution relationship is not known, so nothing enforces it. If a message set needs any of those constructs, the compiler is crates/oa-gateway-uci/src/xsd/, and the error names the construct it stopped on.
Checking a schema before deploy
The suite has a test that compiles a real schema and walks every type reference. Point it at a custom catalog:
OAG_UCI_XSD=/path/YourDefs.xsd:/path/YourMarkings.xsd \
cargo test -p oa-gateway-uci -- --ignored --nocapture
It reports what compiled, how long it took, and the deepest message the catalog can express. That depth has to stay under the conversion limit, or nesting the schema permits will not convert.
Validation
validate decides what a payload the schema does not permit costs. warn reports it and carries it; reject refuses it and tells the peer what was wrong; off skips the check. See SECURITY.md for what validation covers and the two things it still does not.
Connecting a WebSocket client
OWP (OMS WebSocket Protocol) is the one adapter a client talks to directly rather than through another broker or bus. STOMP and DDS bridge a fixed list of topics between the engine and something else; an OWP client opens a WebSocket, says INIT, and then SUB/PUB whatever topics and message types it wants for the life of that connection. oa-gateway-bench’s ping scenario and the quickstart in the README are both OWP clients.
Launch
./target/release/oa-gateway config/default.toml
config/default.toml serves OWP at ws://127.0.0.1:9000/ with no TLS. Send one message through it:
cargo run -p oa-gateway-bench --release -- ping --url ws://127.0.0.1:9000/
That does INIT, SUB Ping demo, PUB demo {"Ping":{"n":1}}, waits for the MSG it should get back, and exits 0. Reading crates/oa-gateway-bench/src/scenarios/ping.rs is the fastest way to see a minimal client in Rust; crates/oa-gateway-testing/src/owp.rs has a second one built on plain tokio-tungstenite.
Configure the adapter
Every OWP key and its default is in configuration.md. The keys that shape the client-facing protocol:
[owp]
id = "owp"
bind = "127.0.0.1:9000"
server_id = "oa-gateway-0"
system_label = "OA-Gateway Prototype"
schema = "002.5.0"
unwrap_ma_payloads = true
xml_baseline = false
| Key | What it does |
|---|---|
bind | host:port the WebSocket listener binds. |
server_id, system_label | Sent back on INFO; identify this gateway to the client. |
schema | The protocol version string INIT.schema must match exactly. Not a UCI XSD path — that is [uci].schema. Empty skips the check. |
unwrap_ma_payloads | An A-GRA MA_RxDataPayload/MA_TxDataPayloadCommand wrapper on PUB is peeled; the engine sees the wrapper and the inner UCI message as two envelopes. |
xml_baseline | Convert OMS JSON ↔ UCI XML at the socket, so a client sends and receives JSON while the engine and any bridged broker see XML. Requires [uci].schema. |
allowed_origins | Opt-in browser Origin allowlist. See Limits. |
init_timeout_secs, idle_timeout_secs | See Timeouts. |
The protocol
A frame is one line of text: a keyword, then space- or tab-separated fields. INIT, PUB, and INFO keep a JSON body as the rest of the line rather than tokenizing it. Binary WebSocket frames are refused and end the session.
Connect and INIT
Open the WebSocket with the owp subprotocol — the handshake is refused with 400 if Sec-WebSocket-Protocol does not list it, case-insensitively:
GET / HTTP/1.1
Sec-WebSocket-Protocol: owp
The first frame from the client must be INIT, or the session is closed with an Illegal-State error:
INIT {"versions":["1.0"],"schema":"002.5.0","service_id":"web-app","verbose":true}
versions must include "1.0"; schema must match [owp].schema exactly when that key is set; service_id must be an OWP identifier (^[A-Za-z0-9_\-.]+$). verbose defaults to true and controls whether PUB/SUB/UNSUB get a +OK on success — errors are always sent regardless. A failed INIT (wrong version, wrong schema, bad service_id) gets a -ERR and the connection closes; a well-formed one gets +OK (if verbose) and then:
INFO {"version":"1.0","server_id":"oa-gateway-0","uuids":{"system":"…","service":"…"},"system_label":"OA-Gateway Prototype"}
uuids.service is UUIDv5 of service_id, so the same service_id always gets the same service UUID.
Publish, subscribe, receive
PUB <topic> <payload>
SUB <sid> <message_name> <topic> [group]
UNSUB <sid>
topic and sid are OWP identifiers; sid is chosen by the client and must be unique per connection — reusing one before UNSUB is -ERR Illegal-Argument. payload on PUB is the rest of the line: OMS JSON or, with xml_baseline, UCI XML — whichever it looks like decides how it is handled, and the root key (or XML root element) becomes the route’s type. SUB always names a message_name; there is no wildcard subscribe over OWP the way an engine subscription can be untyped. group is accepted and currently ignored.
A subscription receives:
MSG <sid> <payload>
one per matching engine delivery, for as long as the SUB is live. PUB to a topic nothing subscribes to is still +OK — pub/sub, not RPC — and is logged once per route rather than silently dropped:
WARN nothing is subscribed to this route, so the publish went nowhere route=Foo adapter=owp
Errors
-ERR <name> [details…]
name | When |
|---|---|
Unsupported-Version | INIT.versions does not include "1.0". |
Unsupported-Schema | INIT.schema does not match [owp].schema. |
Unsupported-Service | In the wire vocabulary; this build never sends it. |
Illegal-State | INIT was not the first frame, INIT was sent twice, or the per-connection subscription limit was reached. |
Illegal-Argument | A frame parsed but failed a semantic check — a bad identifier, a duplicate sid, an unknown sid on UNSUB. |
Illegal-Operation | A binary WebSocket frame was sent. The session ends. |
Invalid-Message | PUB failed to convert, unwrap, or (in reject mode) validate; or a delivery to this subscription failed to convert, or itself failed reject-mode validation, and could not be forwarded. |
Internal-Error | The engine subscribe call itself failed. |
A frame the codec cannot parse at all (unknown keyword, missing field) is Illegal-Argument with the parse failure as details, and does not close the session — only a rejected INIT, a protocol violation after INIT, or a binary frame does that.
Timeouts
A connection that never completes INIT is closed after init_timeout_secs (default 30), timed from the accepted WebSocket rather than reset by traffic — sending frames that never form a valid INIT does not buy more time. Once active, idle_timeout_secs (default 600) closes a session with no frame in either direction; any client frame and any server frame, including a delivered MSG, resets that clock, so a subscriber that only receives never gets closed for being idle. Either is 0 to disable. configuration.md has both keys.
Limits
- There is no authentication. Any peer that completes the WebSocket handshake can
INITunder anyservice_idand publish or subscribe to anything. SECURITY.md states the assumptions; bind loopback or front this with a reverse proxy. allowed_originsis a browser-only control, not a substitute for that proxy. Unset, anyOrigin(including none) connects. Set, a handshake whoseOriginis not listed verbatim — a missingOriginincluded — is refused with403. A non-browser client sends whateverOriginit likes, so this closes cross-site WebSocket access from a browser and nothing else.tls_cert/tls_keymake the listener speakwss://only; a plaintext client is refused at the handshake. Addtls_client_caand a client that cannot present a certificate from that bundle is refused too — the one form of peer authentication this gateway has, and it stops at the handshake: a verified client is not treated any differently once connected.max_frame_size,max_connections, andmax_subscriptionsbound what one peer can cost the others; aSUBpast the limit is-ERR Illegal-Stateand the session continues, an oversized frame ends the session, and a connection past the limit is closed on accept.
Connecting an ActiveMQ broker
The STOMP adapter dials the broker as a client and bridges a named list of topics in both directions. It speaks STOMP 1.2 over plain TCP. ActiveMQ Classic serves that protocol on port 61613 by default. Java CAL applications and this gateway can then sit on the same broker.
Launch with Compose
The gateway and the broker are separate Compose files. Start a broker when one is needed:
docker compose -f compose/activemq.yml up -d
The broker console is at http://127.0.0.1:8161/ (admin / admin). STOMP listens on 127.0.0.1:61613.
The gateway Compose stack runs only oa-gateway and requires a configuration path:
OAG_CONFIG=$PWD/config/compose.toml docker compose -f compose/gateway.yml up --build
config/compose.toml is the container example: OWP on 0.0.0.0:9000, STOMP off. To bridge a broker from the container, add [stomp] to a configuration of your own and set broker to a host the container can reach (for example host.docker.internal:61613 when the broker is published on the Docker host). On the host itself, config/asb.toml and a local binary are the simpler path.
Configure the adapter
config/asb.toml is the host-side example. Every STOMP key and its default is in configuration.md. The section is:
[stomp]
id = "stomp"
broker = "127.0.0.1:61613"
host = "/"
login = ""
passcode = ""
destination_prefix = "/topic/"
topics = ["PositionReport"]
unwrap_ma_payloads = true
reconnect = true
| Key | What it does |
|---|---|
broker | host:port of the broker’s STOMP listener. Hostnames are resolved once at startup, so a name that does not resolve is a startup error rather than a silent retry loop. |
host | The STOMP host header. ActiveMQ Classic wants /. |
login, passcode | Left empty, both headers are omitted from CONNECT, which is what an unsecured broker expects. |
destination_prefix | Joined to a topic name to make the STOMP destination. /topic/ yields JMS topics on ActiveMQ Classic; /queue/ works the same way for queues. Artemis or another broker with a different naming scheme is a change to this prefix. |
topics | The bridge list. See the next section. |
unwrap_ma_payloads | Publish the inner payload of an A-GRA wrapper alongside the wrapper itself, so subscribers can route on the payload rather than reading hex. |
reconnect | Retry a lost session instead of stopping the adapter. |
max_frame_size | Largest frame accepted from the broker, 16 MiB by default. It bounds both the read buffer and the content-length a peer can claim. |
Topic mapping
Each entry in topics is bridged both ways. The adapter subscribes to {destination_prefix}{topic} on the broker and to the same name in the engine. An engine topic is therefore a JMS topic of the same name. For UCI traffic that name is the message type.
A topic that is not listed is not bridged in either direction. Publishing one from a WebSocket client is accepted by the protocol and then matches nothing. The gateway reports that once per route rather than dropping the message silently:
WARN nothing is subscribed to this route, so the publish went nowhere route=Foo adapter=owp
Limits
- Set
stomp.tlsbefore sending credentials off a trusted segment. Without it,loginandpasscodecross in the clear. ActiveMQ Classic’s SSL transport connector conventionally listens on61612; pointbrokerthere and settls = true. TLS verifies the broker’s certificate — it does not authenticate the gateway to the broker, which is whatloginandpasscodeare for, and the gateway still does not authenticate its own peers. SECURITY.md states the assumptions. - Heartbeats are off. The gateway negotiates
heart-beat: 0,0, so a dead TCP connection is noticed when a write fails rather than on a timer. A broker that requires heartbeats needs a change incrates/oa-gateway-stomp/src/client.rs. - Connect waits
connect_timeout_secs(default 5) for TCP, for the TLS handshake whentlsis set, and for CONNECTED, each. - Echo is suppressed when
suppress_echois true (the default). Inbound MESSAGE is stampedoag.origin_adapter. Outbound SEND skips that id so a message does not loop between the gateway and the broker.
Connecting a DDS domain
The DDS adapter joins a domain as a participant and bridges a named list of topics in both directions. The engine topic and the DDS topic are the same name. Samples are A-GRA Rx/Tx only: MA_RxDataPayload and MA_TxDataPayloadCommand fields plus the inner UCI bytes. The adapter does not generate the UCI catalog as IDL types.
The first provider is rustdds (Apache-2.0). The adapter never names rustdds types. A later Cyclone DDS or Fast DDS implementation is another type behind the same DdsProvider trait; it is not in this build.
Launch
There is no broker to start. Two rustdds participants on the same domain_id discover each other. The shipped example is loopback plus one participant:
./target/release/oa-gateway config/dds.toml
config/dds.toml is omitted from the default local toy because that process has no domain to join. Add [dds] to a configuration of your own when a peer will be on the domain.
Configure the adapter
Every DDS key and its default is in configuration.md. The section is:
[dds]
id = "dds"
provider = "rustdds"
domain_id = 0
qos = "config/dds-qos.xml"
topics = ["demo"]
unwrap_ma_payloads = true
suppress_echo = true
| Key | What it does |
|---|---|
provider | Which stack constructs the participant. "rustdds" is the only legal value now. An unknown name is a startup error. |
domain_id | The DDS domain. Peers that should see these topics must use the same id. |
qos | Path to a QoS file. Required when the section is present. A missing file fails at startup. |
topics | The bridge list. See the next section. |
unwrap_ma_payloads | Publish the inner payload of an A-GRA wrapper alongside the wrapper itself, so subscribers can route on the payload rather than reading hex. |
suppress_echo | Skip outbound writes that originated on this adapter, so a message does not loop between the gateway and the domain. |
Topic mapping
Each entry in topics is bridged both ways. The adapter creates a DDS topic of that name and a wildcard engine subscription on the same name. A topic that is not listed is not bridged in either direction.
DDS allows one type per topic name. The on-wire type is therefore a single struct, MaDataPayload, with a kind of "rx" or "tx". encoded is the inner UCI payload as octets, not hex text. The type name passed to rustdds is "MaDataPayload".
QoS file
[dds] qos is a file path, not a set of TOML knobs. The adapter opens it only to fail startup if the file is missing. Interpretation is the provider’s job.
rustdds has no vendor XML loader, so the first provider parses a documented DDS-XML subset into reliability, durability, and history. Unknown elements are refused. The shipped profile is config/dds-qos.xml: reliable, volatile, keep-last 16.
Allowed elements are dds, qos_library, qos_profile, datawriter_qos, datareader_qos, reliability, durability, history, kind, and depth. Reliability kinds are RELIABLE and BEST_EFFORT. Durability kinds are VOLATILE and TRANSIENT_LOCAL. History kinds are KEEP_LAST (with depth) and KEEP_ALL.
A later FFI provider may pass the same path straight into that library and ignore this subset parser. Partitions, per-topic QoS, and DDS Security are out of scope for this adapter.
Echo
Inbound samples are stamped oag.origin_adapter. When suppress_echo is true (the default), outbound writes skip that id. The rustdds provider also drops samples whose writer shares this participant’s GUID prefix, because rustdds delivers local writes to the local reader. The engine does not skip by origin.
Who this talks to
This build talks to another rustdds participant, including a second OA-Gateway process that names the same domain_id and topic list. A vendor CAL that already speaks DDS is the next interoperability target; it is not covered by the in-process tests. Those tests start a second rustdds participant in the same process and do not require Docker or a system DDS library.
Limits
- DDS traffic is not encrypted and peers are not authenticated. RTPS runs on UDP, so the TLS available to the OWP adapter does not apply here; the DDS equivalent is DDS Security, which this build does not configure. Keep participants on a trusted segment. SECURITY.md states the assumptions.
- One QoS profile applies to every topic the adapter creates. There is no per-topic override in the TOML.
- Volatile durability means a write before discovery is lost. A peer that joins late will not see earlier samples unless the QoS file uses
TRANSIENT_LOCAL. - No native UCI IDL catalog. Inner messages stay bytes inside
MaDataPayload.encoded.
Benchmarking
oa-gateway-bench measures publish-to-delivery latency, throughput, and engine drop counts. It is a client of the public APIs. It does not change routing, framing, or conversion.
Shared GitHub runners are noisy. Numbers from CI are a snapshot, not a regression gate. Compare two runs only when the machine and the flags match.
Scenarios
Each command isolates one cost layer. In-process runs share a clock, so one-way latency is Instant at send minus Instant at receive. Sequence numbers live in the JSON payload (n), not in engine headers.
| Command | Path | What the latency is |
|---|---|---|
engine | Engine::publish → subscriber channels | One-way to recv |
loopback | Loopback A → engine → Loopback B | One-way, including the extra forwarder task |
owp | WebSocket PUB → MSG | One-way; --ack-latency also times PUB → +OK |
uci | Message::from_json / to_xml (or the reverse) on the PositionReport fixture | Convert time |
engine --capacity is the channel the bench passes to the existing Engine::subscribe. The default is 4096 so the run measures routing, not the production 64-slot try_send drop policy. engine --capacity 64 is how you measure backpressure. Loopback keeps its hardcoded 64-slot channels; there is no new parameter on that adapter.
owp --xml-baseline starts the same in-process OWP helper the tests use (oa_gateway_uci::slice::v25) and sends PositionReport so conversion is on the path. owp --url ws://127.0.0.1:9000/ attaches two connections to a running host instead. Handshake schema is 002.5.0.
STOMP and DDS are not in this utility. They need a broker or a domain; use the live ActiveMQ script when you want that path.
Commands
cargo run -p oa-gateway-bench --release -- engine --duration 10s --warmup 1s
cargo run -p oa-gateway-bench --release -- engine --capacity 64
cargo run -p oa-gateway-bench --release -- loopback --duration 10s
cargo run -p oa-gateway-bench --release -- owp --duration 10s
cargo run -p oa-gateway-bench --release -- owp --xml-baseline --duration 10s
cargo run -p oa-gateway-bench --release -- owp --url ws://127.0.0.1:9000/
cargo run -p oa-gateway-bench --release -- uci --iterations 2000
--rate 0 (the default) means as-fast-as-possible. --json PATH writes the same numbers the summary prints, plus git_sha, rustc, profile, started_unix, and the flags.
--warmup is a prefix of --duration. Sends in that window still count toward sent / received; they are omitted from the latency histogram.
A run exits non-zero if the binary cannot start, a handshake fails, or nothing is received. It does not exit non-zero because a percentile moved.
Reading drops
Engine::publish uses try_send. A full subscriber channel increments dropped and the message is gone. The summary prints both the per-run drop count and, for embedded scenarios, EngineStats (published, delivered, dropped). High throughput with --capacity 64 or the loopback adapter will drop. That is the backpressure policy, not a bench bug.
CI
The unit job does not run the long suite. After test passes, a bench job runs scripts/ci-bench.sh: a release build, five short scenarios (5s + 1s warmup, or 2000 UCI iterations), JSON under bench/, and bench/summary.txt.
If jq and gnuplot are available (the script installs them when it can), it also writes bench/latency.png and bench/throughput.png. A missing plotter does not fail the job.
GitHub uploads the bench/ directory as a bench artifact (14 days). Pull requests upload too, so a reviewer can download the PR zip next to one from the default branch. Do not treat those pictures as a pass/fail signal.