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 for Authorization, X-Delegated-Authorization, X-Error, X-Workflow-Step-Id, correlation ids, etc.), and transport-level concerns like timeouts and reply matching.
  • Payload: data, a std::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:

  1. compass_services.py's controller-launch argv (Python),
  2. process_supervisor's default_args_template (C++),
  3. five custom args_template rows in controller_service_definitions_populate.sql,
  4. the Qt client's launch,
  5. compass shell's flag_for mapping 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:

  1. Emit it in env_init.py's shared .env block (projects/ores.compass/src/env_init.py).
  2. 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 let tls_ca_cert resolve while cert/key stay empty for services with no per-app mirror – tripping client.cpp's mTLS gate at connect time. The trio is not in the registered suffix set.
  • ores.shell keeps its own ORES_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_URL outranks the shared ORES_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/consume command_json and result_json as literal JSON text; workflow_engine::publish_command takes a workflow_step entity's command_json field and publishes it raw onto the wire, unconditionally, regardless of wire_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_payload is a DB-persisted field re-published later by the scheduler's own action handlers; building it with rfl::json::write is 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_message itself) is the wire payload and must go through wire_codec; the inner request_json string 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 of wire_format, because it is bootstrap plumbing that may run before a client's own wire_codec is even established. Both the auth_handler::public_key reply and fetch_jwks_public_key's parse hard-code JSON (the latter via raw boost::json, not rfl, 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.

Emacs 29.3 (Org mode 9.6.15)