Task: Write up the wire-format architecture analysis and design decision
Table of Contents
This page documents a task in the Make NATS wire format configurable: JSON/MessagePack, decided once at startup story. It captures the goal, current status, acceptance, and any notes or results.
Goal
Produce the design document this story's remaining tasks execute against, covering:
- Blast-radius audit: every NATS-relevant
rfl::json::write=/ =rfl::json::readcall site, by component –ores.service(handler_helpers.hppplus any stragglers bypassing it),ores.qt(ClientManagerplus any stragglers bypassing it),ores.shell(the copy-pasteddo_request=/=do_auth_requesthelper plus any call sites using some other direct pattern). Confirmores.cli'srfl::jsonhits are config-file I/O, not NATS calls, and drop it from scope (or file a small follow-up if any genuine NATS call sites turn up). The verified counts and per-file breakdown live in* Planbelow, not here, so this section doesn't go stale if the audit is later corrected. - Architecture: a concrete, non-polymorphic
wire_codectype, holding awire_format(jsonormsgpack) decided once at construction from a.env-driven config value, threaded through every consumer via dependency injection. No per-message format negotiation, no content-type header, no runtime auto-detection – the decision is made once at process startup and used consistently for that process's lifetime. - Rationale versus the rejected alternative: a self-describing,
per-message header (mirroring the already-shipped
X-Content-Encodingcompression header) was considered and rejected – it would allow safe rolling/mixed-format deploys, but introduces per-message runtime dispatch and a class of misconfiguration risk the single-startup-decision design avoids entirely, at the cost of requiring a whole environment's.envfiles to agree.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Make NATS wire format configurable: JSON/MessagePack, decided once at startup |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-28 |
Acceptance
[X]Blast-radius table (component, file, call site, current behaviour) coveringores.service,ores.qt,ores.shell; scope decision recorded forores.cli.[X]Architecture section specifying thewire_codectype's shape (constructor, templateencode=/=decodemethods), where it's constructed, and how it's threaded to each consumer.[X]Rationale section comparing the chosen design against per- message header negotiation, explaining why the simpler one was picked.
Plan
1. Blast-radius audit
Every NATS-relevant rfl::json::write=/=rfl::json::read call site,
found by grepping for direct calls and manually excluding unrelated
usages (HTTP routes, DB entity JSON columns, config-file parsing –
none of which are NATS wire format and are out of scope for this
story).
ores.service (server) – one choke point plus real stragglers
| File | Line | Call |
|---|---|---|
ores.service/messaging/handler_helpers.hpp |
175 | reply(): rfl::json::write(resp) |
ores.service/messaging/handler_helpers.hpp |
251 | decode(): rfl::json::read<Req>(sv) |
ores.service/service/heartbeat_publisher.hpp |
113 | rfl::json::write(hb) then nats_.publish(...) |
ores.service/messaging/workflow_helpers.hpp |
178 | rfl::json::write(event) -> nats->js_publish(...) |
ores.service/messaging/workflow_helpers.hpp |
251 | rfl::json::write(req) -> nats.request_sync(...) |
ores.service/messaging/workflow_helpers.hpp |
260 | rfl::json::read<get_step_result_response>(sv) on the reply |
handler_helpers.hpp's reply()=/=decode() are the single choke
point almost every service handler in the codebase goes through
(mirrors why the compression task only needed to touch ores.nats's
two choke points), but they are not the whole story: the heartbeat
publisher and the workflow-engine's step-request/event-publish path
both bypass handler_helpers.hpp entirely and hard-code
rfl::json directly against the NATS client. All four of these
extra call sites need to move onto the injected wire_codec too –
the migration task must not assume handler_helpers.hpp alone
closes out ores.service.
ores.qt – ~10 ClientManager call sites plus stragglers, needs consolidation first
| File | Lines | Call |
|---|---|---|
ClientManager.hpp |
443, 447 | process_authenticated_request (subject overload) |
ClientManager.hpp |
496-497 | process_authenticated_request (request overload) |
ClientManager.hpp |
652, 655 | process_authenticated_request (workspace-id overload) |
ClientManager.hpp |
692, 696 | process_authenticated_request (workspace-ctx overload) |
ClientManager.cpp |
609, 615 | login (send_authenticated_request + decode) |
ClientManager.cpp |
645, 651 | signup (same shape) |
ClientManager.cpp |
681 | event-cache decode (entity_change_event) |
ClientManagerExportPortfolio.cpp |
46-47 | exportPortfolio (concrete, non-template) |
ClientManagerTradeInstrument.cpp |
36 | trade_instrument write |
Every process_authenticated_request overload independently calls
rfl::json::write(request) then, after the NATS round trip,
rfl::json::read<ResponseType>(raw). exportPortfolio=/
=trade_instrument are deliberately concrete (non-template) wrappers
– per their own doc comments, to keep template instantiation out of
callers with heavy accumulated context (avoiding an MSVC C1202 limit)
– so the migration must preserve that property, not templatise them
away.
ClientManager is not the only ores.qt consumer, though. Two more
call sites bypass it entirely:
| File | Lines | Call |
|---|---|---|
synthetic/MarketSimulatorWindow.cpp |
2505, 2507, 2575, 2631 | rfl::json::read<...>(payload) decoding fx_spot_tick=/=ir_curve_tick directly off a raw NATS subscription payload |
application/ShellMdiWindow.cpp |
260, 265 | rfl::json::write(req)=/=rfl::json::read<login_response> for a login path via shell_session_.request(...), separate from ClientManager's own login |
Both need to route through the injected wire_codec as well.
One near-miss investigated and excluded: trading/OreImportWizard.cpp:862
(req.import_choices_json = rfl::json::write(choices)) encodes a
domain field – an embedded JSON string carried inside
ore_import_request – not the NATS wire-format serialization
itself. The req object as a whole still goes through
cm->process_authenticated_request(req, ...), which the
ClientManager audit above already covers, so this is not an
additional call site.
ores.shell – 101 occurrences across 22 files, only some of them do_request
do_request=/=do_auth_request is not a shared helper – it is
independently copy-pasted, in full, into the anonymous namespace of
each command .cpp file (confirmed via
projects/ores.shell/src/app/commands/currencies_commands.cpp:40-59
as a representative sample; the same two ~15-line templates recur
verbatim). The actual breakdown, verified by grep rather than
estimated:
- 13 files define their own copy of
do_request=/=do_auth_request(account_parties_commands.cpp,accounts_commands.cpp,countries_commands.cpp,currencies_commands.cpp,change_reason_categories_commands.cpp,change_reasons_commands.cpp,variability_commands.cpp,tenants_commands.cpp,rbac_commands.cpp, plusbundles_commands.cpp,crm_commands.cpp,marketdata_commands.cpp, which each independently copy-paste their owndo_auth_request), 18 definitions total (some files define both templates).provision_commands.cpphas a third, differently-shapeddo_request– it takes aRequestobject plus a defaulttimeoutandauthenticatedbool and does its own internalrfl::json::write(req)– so the eventual shared helper needs an overload covering that shape too, not just a drop-in replacement for the other 12 files' identical pattern. - 60 actual call sites invoke these templates (
do_request<...>=/ =do_auth_request<...>) across the files above.
This duplication – 18 copies of essentially the same ~15-line
template pattern (plus one differently-shaped variant) across 13
files – is a pre-existing debt, independent of this story, that the
migration should fix as a side effect (one shared helper, with an
overload for provision_commands.cpp's shape, instead of 13 copies)
rather than propagate.
That is not the full picture, though: 7 more command files have
rfl::json call sites that don't go through do_request=/
=do_auth_request at all, using some other/direct pattern against
nats_client::request()=/=authenticated_request() not yet
characterised in this doc – reports_commands.cpp,
lei_commands.cpp, parties_commands.cpp,
connection_commands.cpp, history_diff_renderer.cpp,
synthetic_commands.cpp, workflow_commands.cpp (15 occurrences
between them), plus the previously-known stragglers below:
| File | Lines | Call |
|---|---|---|
app/application.cpp |
70, 73 | bootstrap-status request/decode |
app/application.cpp |
93, 96 | login request/decode |
app/repl.cpp |
139 | logout request |
Honest grand total (excluding config/options.cpp and
config/login_options.cpp, which write a JSON options value to a
stream with no nats_client=/=session.request reference nearby –
config-file I/O, not NATS wire format, same exclusion rationale as
ores.cli below): 101 rfl::json occurrences across 22 files.
The migration task needs to consolidate all of it, not just the 13
files that already use the named template pattern.
ores.cli – confirmed out of scope
ores.cli's rfl::json hits (config/import_options.cpp,
config/delete_options.cpp, config/list_options.cpp,
config/export_options.cpp, config/options.cpp, config/ore_roundtrip_options.cpp)
are all config-file/CLI-option I/O – reading/writing a JSON options
file, not building or parsing a NATS message body. Confirmed by
inspecting the surrounding code: none of these call sites reference
nats_client=/=client::request_sync=/=publish. Dropped from this
story's scope entirely; no follow-up needed.
2. Architecture
The wire_codec type
A concrete, non-polymorphic value type in ores.nats (new module,
architecturally parallel to the already-shipped ores.nats::compression):
namespace ores::nats { enum class wire_format { json, msgpack }; class wire_codec { public: explicit wire_codec(wire_format format) : format_(format) {} // Illustrative: the real implementation needs a default/unreachable // arm (or equivalent exhaustiveness guard) so this doesn't trip // -Wreturn-type; omitted here for brevity. template <typename T> std::vector<std::byte> encode(const T& obj) const { switch (format_) { case wire_format::json: return to_bytes(rfl::json::write(obj)); case wire_format::msgpack: return to_bytes(rfl::msgpack::write(obj)); } std::unreachable(); } template <typename T> rfl::Result<T> decode(std::span<const std::byte> data) const { switch (format_) { case wire_format::json: return rfl::json::read<T>(as_string_view(data)); case wire_format::msgpack: return rfl::msgpack::read<T>(as_char_span(data)); } std::unreachable(); } private: wire_format format_; }; }
Deliberately not polymorphic (no virtual functions): the format is
decided once, at construction, and never changes for the instance's
lifetime – there is nothing here that benefits from indirection.
wire_format is a plain 1-byte enum and wire_codec holds nothing
else, so it is trivially copyable and cheap to pass by value,
reference, or hold as a member wherever a consumer needs it. The
switch inside encode=/=decode is not "runtime format switching"
in the sense this story rejects (see below) – it is a single branch
on a value fixed at process startup, not a per-message inspection of
incoming data; the branch predictor sees the same outcome on every
call for the process's entire lifetime.
Confirmed viable: reflect-cpp's rfl::msgpack::Writer already
special-cases any MutableContiguousByteContainer (which
std::vector<std::uint8_t> satisfies) via msgpack_pack_bin=/
=msgpack_pack_bin_body – native MessagePack binary framing, not an
array of small integers and not base64. No custom serialization code
is needed for any existing reflectable type; swapping wire_format
is sufficient on its own.
Config and construction
One new .env value, e.g. ORES_NATS_WIRE_FORMAT=json|msgpack
(default json, preserving today's behaviour for any process that
doesn't set it), read once at process startup alongside existing NATS
connection config (nats_options=/=nats_configuration). A single
wire_codec instance is constructed from the resolved value and
threaded to every consumer via dependency injection:
- Server (
ores.service::messaging):reply()=/=decode()take (or close over) the codec; since these are free template functions already parameterised on the message type, the natural shape is an extraconst wire_codec¶meter, or a codec held on theores::nats::service::clientinstance already passed to every handler (avoiding a signature change to every handler). - Qt client (
ClientManager): onewire_codecmember, constructed once when the client is constructed/connects, used by the consolidated request helpers (see theClientManagermigration task). - Shell client: one
wire_codec, constructed once atores.shell's application startup, passed into the consolidateddo_request=/=do_auth_requesthelper (see the shell migration task).
3. Rationale versus the rejected alternative
Considered: mirror the compression feature's design exactly – a
self-describing X-Content-Type NATS header (application/json /
application/msgpack) stamped by the sender, auto-detected and
dispatched-on by the receiver regardless of its own configured
default. This would allow a genuinely mixed fleet: any service could
switch formats independently, with zero coordination, and safely
interoperate with services still on the old format mid-rollout.
Rejected, per explicit instruction: this story wants a single decision made once at startup, not per-message runtime format detection. The header approach's extra flexibility comes at a real cost this story chooses not to pay:
- Every
decode()call becomes conditional on message content (a header lookup plus a branch) rather than on a value fixed for the process's whole lifetime – a qualitatively different, "instantiate a strategy chosen once and use it everywhere" pattern being asked for here. - It reintroduces a small surface for misconfiguration/ambiguity (a
message with no header, a header naming an unsupported format) that
a whole-environment
.envconvention doesn't have. - The claimed benefit (safe incremental rollout across a mixed fleet)
is not a requirement this story was asked to satisfy –
.envis explicitly the intended control surface, implying environments are configured (and rolled out) as a unit, matching how this codebase's other.env-driven settings already work.
The compression feature's header-based design remains correct for
that feature specifically, because compression's own trade-off is
different: whether to compress is a per-message, size-dependent
decision (small messages skip it entirely) that has no equivalent
"whole environment agrees" framing – there is no size threshold
.env could sensibly encode. Wire format, by contrast, is a binary
choice with no natural per-message trigger, making the simpler
single-decision design both sufficient and preferable here.
Notes
Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario, run
through the QA Validation Runner panel) that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
PRs
| PR | Title |
|---|---|
| #1722 | [agile] Write up NATS wire-format architecture and design decision |
Review
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | ores.service audit missed heartbeat_publisher.hpp and workflow_helpers.hpp call sites, "already centralised" framing wrong | task_write-up-wire-format-architecture.org | Fixed | Added the 4 missed call sites and corrected the framing |
| 2 | ores.qt audit missed MarketSimulatorWindow.cpp and ShellMdiWindow.cpp call sites | task_write-up-wire-format-architecture.org | Fixed | Added both, plus an explicit note that OreImportWizard.cpp:862 was checked and is a false positive (embedded JSON domain field, not wire format) |
| 3 | ores.shell count ("26 across ~13 files") doesn't match reality; task-count in story.org also stale | task_write-up-wire-format-architecture.org, story.org | Fixed | Corrected to the verified breakdown (101 occurrences across 22 files); story.org's "Now" field corrected from 4 to 5 remaining tasks |
| Minor nits (typo, missing default/unreachable arm in wire_codec snippet) | task_write-up-wire-format-architecture.org | Fixed | "suffient" -> "sufficient"; added std::unreachable() note to the snippet | |
| 4 | ores.shell definition count still wrong after round 3 fix: 14/9 claimed, actually 18/13, plus provision_commands.cpp is a differently-shaped third variant | task_write-up-wire-format-architecture.org | Fixed | Corrected to 18 definitions across 13 files, noted provision_commands.cpp's distinct Request-object shape |
| 5 | Stale "9 files" leftover survived the round-4 fix in one sentence | task_write-up-wire-format-architecture.org | Fixed | Updated to "13 files" for consistency with the rest of the section |
| 6 | Goal section and story.org's Tasks table still stated the original wrong audit numbers (2 sites/already centralised; 26 occurrences/~13 files) | task_write-up-wire-format-architecture.org, story.org, task_consolidate-migrate-shell-wire-codec.org | Fixed | Reworded Goal to avoid restating counts that go stale; corrected story.org's Tasks row and, while at it, the sibling shell-migration task's stale description/Goal too |
| 7 | Sibling task's reworded Goal listed application.cpp/repl.cpp alongside the 7-file/15-occurrence group, implying 9 files behind that count | task_consolidate-migrate-shell-wire-codec.org | Fixed | Separated application.cpp/repl.cpp as distinct previously-known stragglers, matching the write-up task's Plan presentation |
Result
Design doc complete in * Plan above. Key outputs for the
implementation tasks that follow:
- Blast-radius audit: 6 call sites in
ores.service(thehandler_helpers.hppchoke point plus 4 stragglers inheartbeat_publisher.hpp=/=workflow_helpers.hpp), ~10 inores.qt::ClientManagerplus 2 more stragglers (needs consolidation first), 101 occurrences across 22 files inores.shell(only 60 of which go through the copy-pasteddo_request=/=do_auth_requestpattern across 13 defining files (18 definitions, one of them –provision_commands.cpp– a differently-shaped variant); the rest use other direct patterns), all needing de-duplication into shared helpers.ores.cliconfirmed genuinely out of scope – itsrfl::jsonhits are config-file I/O, no NATS call sites found. (Corrected post-review: the original pass under-countedores.serviceandores.qtcall sites and significantly under-countedores.shell's; see* Reviewabove.) - Architecture: a non-polymorphic
wire_codecvalue type (1-byte enum member, templateencode=/=decodemethods), constructed once at process startup from a new.envvalue (ORES_NATS_WIRE_FORMAT, defaultjson), threaded to each consumer via dependency injection. Confirmed viable:reflect-cpp'srfl::msgpack::Writeralready packsstd::vector<uint8_t>as native binary viamsgpack_pack_bin, no custom serialization code needed. - Rationale recorded for rejecting the header-based per-message negotiation alternative (which the already-shipped compression feature uses) in favour of the simpler single-startup-decision design, per explicit direction: wire format is a binary, environment-wide choice with no natural per-message trigger, unlike compression's genuinely per-message, size-dependent decision.
No code changed – this task is the spec the remaining four implementation tasks execute against.