NATS Wire Format
Table of Contents
1. Summary
Every NATS message has an envelope (subject, reply-to, headers,
correlation id) and a payload (an opaque byte buffer). ORE Studio's
envelope is always transport metadata, handled uniformly regardless
of protocol; only the payload's encoding is a project-wide choice
between JSON and MessagePack, made once at process startup and
exposed as ores::nats::wire_codec. Most rfl::json::write=/=read
call sites encode or decode that payload and must route through the
process-wide codec. A minority of std::string fields that happen to
hold JSON text are not the wire payload – they are persisted or
embedded data (DB columns, workflow command bodies, opaque result
blobs) that stays JSON by design, independent of wire_format. Confusing
the two categories is the most common mistake when migrating a NATS
call site to be format-agnostic.
2. Detail
2.1. Envelope versus payload
A NATS message (see ores.nats/domain/message.hpp) carries:
- Envelope:
subject,reply_subject,headers(a string-to-string map used forAuthorization,X-Delegated-Authorization,X-Error,X-Workflow-Step-Id, correlation ids, etc.), and transport-level concerns like timeouts and reply matching. - Payload:
data, astd::vector<std::byte>– the actual message body, opaque to NATS itself.
The envelope is never protocol-dependent: headers and subjects are
always plain strings, decided by application logic, not by
wire_format. Only the payload – the request/response struct's
serialised bytes – is subject to the JSON/MessagePack choice this
document covers.
2.2. wire_codec: the payload's protocol, decided once
ores::nats::wire_codec (ores.nats/domain/wire_codec.hpp) is a
small value type fixed to one wire_format (json or msgpack) at
construction:
class wire_codec final { public: explicit wire_codec(wire_format format); template <typename T> std::vector<std::byte> encode(const T& obj) const; template <typename T> rfl::Result<T> decode(std::span<const std::byte> data) const; };
wire_format is resolved once per process from .env
(ORES_NATS_WIRE_FORMAT) / the --nats-wire-format CLI flag, and
ores::nats::service::client's constructor calls
set_default_wire_codec() to publish it process-wide. Every later
encode=/=decode call reads that same value via
ores::nats::default_wire_codec() – there is no per-message
negotiation, no content-type header, no runtime auto-detection. This
is a deliberate, narrow exception to per-instance dependency
injection: safe because the format is a single, process-wide,
decided-at-startup value, not a per-instance or per-message choice.
How ORES_NATS_WIRE_FORMAT (and the other shared NATS knobs) reach
every service's parser from .env alone, with no argv threading, is
the Config flow section below.
2.3. Config flow: shared NATS knobs reach every service from .env alone
ORES_NATS_URL, ORES_NATS_SUBJECT_PREFIX and ORES_NATS_WIRE_FORMAT
are shared NATS knobs: one value set once in .env, read directly by
every service's own config parser – no CLI-argument threading between
launcher layers. This single-source-of-truth design replaced the old
five-layer passthrough, in which a new knob had to be manually fixed in
five independent places before every service actually received it:
compass_services.py's controller-launch argv (Python),process_supervisor'sdefault_args_template(C++),- five custom
args_templaterows incontroller_service_definitions_populate.sql, - the Qt client's launch,
- compass shell's
flag_formapping plus a missing per-app .env mirror variable.
Layers 2 and 3 are gone entirely (the controller was decommissioned
and systemd_generate.py now reads the codegen service-registry model,
not SQL); layer 1's controller launcher is gone; layer 5's threading
of --nats-url/--nats-subject-prefix/--nats-wire-format was stripped.
A shared NATS knob added today needs exactly two edits:
- Emit it in
env_init.py's shared .env block (projects/ores.compass/src/env_init.py). - Add its suffix to
nats_configuration::register_shared_domain()'s allowed set (projects/ores.nats/src/config/nats_configuration.cpp).
The mechanism is a generic shared-domain fallback tier in
ores.utility's environment_mapper_factory (see
ores.utility/src/program_options/environment_mapper_factory.cpp and
ores.utility/include/ores.utility/program_options/shared_domain_registry.hpp).
Each application's parser maps ORES_<APP_NAME>_* variables to its own
options as before; but when a variable is not app-prefixed yet starts
with a registered shared domain's prefix (ORES_NATS_*) and its
suffix is in that domain's allowed set, only the fixed ORES_ prefix
is stripped – the domain name becomes part of the option name
(nats-url, not url), matching make_options_description()'s own
naming. The allowed-suffix set is deliberate, not a blanket prefix:
the domain's raw environment namespace can contain server-side
settings (e.g. a hypothetical ORES_NATS_LISTEN_PORT) that are not
command-line options, and matching those too would make
parse_environment reject them as unrecognised options.
Registration is explicit and idempotent:
nats_configuration::register_shared_domain() is called from
ores::service::config::standard_service_options::make_options_description()
– the single call site every domain-service parser goes through since
the parser consolidation (standard_service_options::parse() feeds
parse_environment via environment_mapper_factory::make_mapper(app_name)).
The registry itself is domain-agnostic: any config module can register
itself (prefix + allowed suffixes) and its options then reach every
service the same way, with no changes to environment_mapper_factory.
Deliberate exclusions, unchanged by this design:
- The =nats-tls- trio* (
--nats-tls-ca/cert/key) stays CLI-supplied: the cert/key file paths are genuinely per-service, and a shared fallback for CA alone would lettls_ca_certresolve while cert/key stay empty for services with no per-app mirror – trippingclient.cpp's mTLS gate at connect time. The trio is not in the registered suffix set. ores.shellkeeps its ownORES_SHELL_NATS_*mirror block in .env: shell's parser reads those directly via its app-prefix mapper, and compass shell no longer threads the three shared flags to the binary (only the TLS trio remains CLI-supplied there).- Per-app overrides still win: if one service ever needs a different
URL,
ORES_<APP_NAME>_NATS_URLoutranks the sharedORES_NATS_URL, because the app-prefix mapping is consulted before the shared-domain fallback.
Regression tests pinning this contract live in
projects/ores.nats/tests/config_nats_configuration_tests.cpp
(register_shared_domain_makes_unprefixed_nats_vars_reach_an_unrelated_app,
register_shared_domain_does_not_resolve_tls_ca) and, at the real
service-parser level, in each domain service's config_parser_tests.cpp
(e.g. shared_nats_env_var_reaches_service_without_argv in
ores.iam/service/tests/config_parser_tests.cpp). See the
Collapse NATS config passthrough to a single environment-mapper fallback tier
story for the full decision trail.
The canonical shape for a NATS request/reply call site is:
const auto& codec = ores::nats::default_wire_codec(); const auto reply = session.request(subject, codec.encode(request)); auto result = codec.decode<Response>(reply.data);
ores.nats/service/request_helpers.hpp packages this pattern as
request_and_decode=/=authenticated_request_and_decode for callers
that don't need the intermediate steps, and ores.shell's
do_request=/=do_auth_request (ores.shell/app/request_helpers.hpp)
is a thin convention wrapper over the same primitive.
2.4. Publish/subscribe pairs must agree
Fire-and-forget publish()=/=js_publish() calls and their matching
subscription handlers are a matched pair: whichever wire_codec the
publisher used to encode must be the one the subscriber uses to
decode, since NATS carries no content-type metadata to disambiguate.
When migrating one side of such a pair, always migrate the other side
in the same change – see the entity_change_event publishers across
service application.cpp files, the work_assignment_event pair
between ores.compute.core (producer) and ores.compute.wrapper
(consumer), or the step_completed_event=/=start_workflow_message
pair between ores.service::messaging::workflow_helpers (producer)
and ores.workflow.core::workflow_engine (consumer) as examples.
2.5. What is not wire payload: embedded/persisted JSON fields
Several domain structs have a plain std::string field that holds
serialised JSON as its value, independent of whatever the current
process's wire_format is. These are not exempt by oversight – they
are a different kind of data, and migrating them to wire_codec
would be a category error (or, for DB-backed fields, would require an
unrelated schema change to a binary column type). Recognised examples:
- Workflow step commands and results (
ores.workflow): the FSM transition functions in the workflow API build/consumecommand_jsonandresult_jsonas literal JSON text;workflow_engine::publish_commandtakes aworkflow_stepentity'scommand_jsonfield and publishes it raw onto the wire, unconditionally, regardless ofwire_format. These fields are persisted to a JSON-typed DB column and are always JSON by the workflow engine's design. - Scheduler job action payloads (
ores.reporting,ores.scheduler):job_definition::action_payloadis a DB-persisted field re-published later by the scheduler's own action handlers; building it withrfl::json::writeis populating a stored column, not encoding a NATS message. - Import/report "request_json" fields:
ore_import_execute_request:: import_choices_json,start_workflow_message::request_json, and similar fields carry an inner request as opaque JSON text inside an outer message. The outer message (start_workflow_messageitself) is the wire payload and must go throughwire_codec; the innerrequest_jsonstring is embedded content and stays JSON. - JWKS/public-key bootstrap (
ores.iam,ores.nats::service::jwks): the JWKS public-key fetch is deliberately JSON-only on both sides, independent ofwire_format, because it is bootstrap plumbing that may run before a client's ownwire_codecis even established. Both theauth_handler::public_keyreply andfetch_jwks_public_key's parse hard-code JSON (the latter via rawboost::json, notrfl, since it runs before reflect-cpp context is relevant).
When auditing a NATS call site, ask: is this string field the actual
bytes handed to publish=/=request=/=authenticated_request, or is it
a value stored inside one of those bytes (or in a DB row)? Only the
former is this document's concern.
2.6. Practical audit pattern
To find remaining hard-coded call sites when preparing for a msgpack
rollout, search for rfl::json::write=/=read co-located with a NATS
transport call:
grep -rl 'request_sync\|\.request(\|authenticated_request(\|\.publish(\|js_publish(' \ projects --include=*.hpp --include=*.cpp \ | xargs grep -l 'rfl::json::write\|rfl::json::read'
Then classify each hit per the two categories above before touching it – see the "Migrate remaining service-to-service hard-coded rfl::json call sites to wire_codec" task for a worked example of this triage across ~25 files.
3. See also
- Message Queue — the structure note that orders this cluster, and where to read this page in it.
- Polymorphic types over NATS – a related but distinct NATS serialisation convention (type-specific subjects), orthogonal to the JSON/MessagePack payload choice this document covers.