morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
wire.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <array>
6#include <cstddef>
7#include <cstdint>
8#include <expected>
9#include <functional>
10#include <glaze/glaze.hpp>
11#include <limits>
12#include <optional>
13#include <span>
14#include <stdexcept>
15#include <string>
16#include <string_view>
17#include <vector>
18
19#include "../session/session.hpp"
20#include "change_types.hpp"
21#include "profiler.hpp"
22
23namespace morph::wire {
24
25/// @brief Maximum accepted serialized size, in bytes, of a single wire envelope.
26///
27/// `decode` rejects any input longer than this before handing it to the JSON
28/// parser. The cap is a wire-layer denial-of-service backstop: the outer decode
29/// treats `body` as one opaque string, so a deeply-nested or enormous JSON
30/// payload smuggled *inside* `body` is invisible to any structural/depth check
31/// and would only detonate when the action codec re-parses it later (the "body
32/// double-parse"). Bounding the total message length caps that cost up
33/// front. The default of 8 MiB is generous for legitimate action payloads while
34/// keeping a single message's peak allocation bounded; transports that want a
35/// tighter bound should enforce it before calling `decode`.
36inline constexpr std::size_t kMaxEnvelopeBytes = 8u * 1024u * 1024u;
37
38/// @brief Protocol version this build of `morph` speaks.
39///
40/// Carried in `Envelope::protocolVersion` and announced by `makeHello()`.
41/// Bumped only on a **breaking** wire change (a field removal/retype, or a
42/// control-flow semantic change) — see "Action-evolution policy" in
43/// docs/spec/core/wire.md. A purely additive change (a new optional field on
44/// the envelope or on an action/result struct) does not bump this constant.
45inline constexpr std::uint32_t kProtocolVersion = 1;
46
47/// @brief JSON wire envelope used between any client and `RemoteServer`.
48///
49/// Supersedes the legacy pipe-delimited protocol: a single struct carries all
50/// request and reply variants, with the `kind` field acting as a discriminator.
51/// Empty/zero fields are tolerated — callers populate only what their kind needs.
52///
53/// @par Discriminator values
54/// - `"register"` — client requests model creation. Uses `typeId`, and
55/// optionally `contextKey` (the new instance's stable
56/// identity, e.g. an account id — see `ServerConfig::logProvider`).
57/// With `shared` set, additionally uses `primary` and becomes
58/// a register-or-attach against the shared directory.
59/// - `"attach"` — client re-points at a different `primary` of `typeId`,
60/// releasing `modelId` if non-zero. Replies `ok` with the
61/// target instance's id in `modelId`.
62/// - `"assign"` — client files live `modelId` under `primary` of `typeId`.
63/// - `"instances"` — client asks for the live shared primary keys of `typeId`.
64/// Replies `ok` with a JSON array of key strings in `body`.
65/// - `"schemas"` — client asks for the action descriptions of `typeId`.
66/// Replies `ok` with a `{actionType: schema}` JSON object in
67/// `body` — see "Serving action schemas" in
68/// docs/spec/core/wire.md.
69/// - `"deregister"` — client destroys an instance. Uses `modelId`.
70/// - `"execute"` — client dispatches an action. Uses `callId`, `modelId`,
71/// `modelType`, `actionType`, `body`, and optionally `session`.
72/// - `"ok"` — server success reply. Uses `callId` if present and carries
73/// the serialized result in `body`. For `register` replies,
74/// `modelId` carries the new id.
75/// - `"err"` — server failure reply. Uses `callId` if present and `message`.
76/// - `"hello"` — client opens protocol-version negotiation, once per
77/// connection before any other kind. Uses `protocolVersion`;
78/// see `makeHello()` and `interpretHelloReply()`. A peer
79/// predating negotiation answers `err "unknown envelope kind:
80/// hello"`, which is how a legacy server is detected.
81/// - `"event"` — server-initiated notification, sent unprompted on a
82/// connection that negotiated events. `callId` is `0`; `body`
83/// is an `EventBody`. A client routes it by kind before reply
84/// matching; see `makeEvent()`.
85/// - `"cancel"` — client asks the server to stop one of its own in-flight
86/// `execute` calls. Uses `cancelCallId` (the execute's
87/// `callId`) and `session`; its own `callId` correlates the
88/// `ok` reply, which is the same whatever became of the call.
89/// Sent only to a server whose `hello` reply advertised
90/// `kCapabilityCancel`; see `makeCancel()`.
91struct Envelope {
92 /// @brief Discriminator — see class docstring for valid values.
93 std::string kind;
94
95 /// @brief Correlation id for matching async replies to their requests.
96 uint64_t callId = 0;
97
98 /// @brief Model type id for `register`.
99 std::string typeId;
100
101 /// @brief Stable identity of the model instance being registered or
102 /// attached to (e.g. an account id). Empty means "no identity" —
103 /// the server-side holder gets no action log attached even if a
104 /// `LogProvider` is configured. Carried on `register` and
105 /// `attach`; ignored on every other kind.
106 std::string contextKey;
107
108 /// @brief Primary key of the instance being registered or attached to.
109 ///
110 /// Carried on `register` (when `shared` is set) and on `attach`, always as
111 /// the key's canonical string encoding (`morph::model::keyToString`)
112 /// whatever its C++ type, so the server's directory needs one map type
113 /// rather than one per key type. Empty means "no primary" — the instance is
114 /// anonymous and cannot be shared. Ignored on every other kind.
115 ///
116 /// Distinct from `contextKey`, which continues to mean only "entity key for
117 /// the action log". A keyed model will normally set both to the same value,
118 /// but conflating the fields would change behaviour for callers already
119 /// setting `contextKey` for journal purposes.
120 std::string primary;
121
122 /// @brief Whether a `register` should join the shared instance directory.
123 ///
124 /// `true` makes the request a *register-or-attach*: the server returns the
125 /// existing instance for `(typeId, primary)` if one is live, otherwise
126 /// creates it and enters it in the directory. `false` (the default, and the
127 /// value every pre-existing client sends) is today's behaviour exactly — a
128 /// private instance, invisible to the directory.
129 bool shared = false;
130
131 /// @brief Existing model instance id for `deregister`, `execute`, `ok(register)`.
132 uint64_t modelId = 0;
133
134 /// @brief Model type id for `execute` (routing key in `ActionDispatcher`).
135 std::string modelType;
136
137 /// @brief Action type id for `execute` (the second routing key).
138 std::string actionType;
139
140 /// @brief Serialized JSON of the action payload (`execute`) or of the
141 /// deserializable result (`ok`).
142 std::string body;
143
144 /// @brief Free-text error message on `err`. Empty on success.
145 std::string message;
146
147 /// @brief Session context for authorization and routing. Populated on
148 /// `execute`; ignored on every other kind.
150
151 /// @brief Protocol version the sender speaks.
152 ///
153 /// `0` means "unspecified / legacy peer" — the value on every envelope an
154 /// old encoder (unaware of this field) produces, and the value an old
155 /// decoder (unaware of this field) leaves untouched when it receives one
156 /// from a newer peer (ignored via `decode`'s `error_on_unknown_keys =
157 /// false`). Populated by `makeHello()` on `"hello"`; not otherwise
158 /// inspected by other `kind`s today.
159 std::uint32_t protocolVersion = 0;
160
161 /// @brief The `callId` of the `execute` a `"cancel"` asks the server to stop.
162 ///
163 /// Carried in its own field rather than in `callId` because a `cancel` is
164 /// a request with a reply of its own: were the target's id in `callId`, the
165 /// server's `ok` to the cancel would be matched by the client against the
166 /// still-pending `execute` and settle it with an empty result. `0` names no
167 /// call. Ignored on every other kind.
168 std::uint64_t cancelCallId = 0;
169};
170
171/// @brief The capability a server's `"hello"` reply lists when it honours
172/// `"cancel"` envelopes.
173///
174/// A client sends `"cancel"` only to a server that advertised this; a server
175/// that predates it never sees one. See "Cancelling a call" in
176/// docs/spec/core/wire.md.
177inline constexpr std::string_view kCapabilityCancel = "cancel";
178
179/// @brief Inclusive protocol-version range a server advertises in reply to
180/// `"hello"`.
181///
182/// Serialized as the `"ok"` reply's `body` (JSON) when a server accepts a
183/// `"hello"` — see `ServerConfig::minProtocolVersion`/`maxProtocolVersion`.
185 /// @brief Oldest protocol version the server accepts.
186 std::uint32_t min = kProtocolVersion;
187
188 /// @brief Newest protocol version the server accepts.
189 std::uint32_t max = kProtocolVersion;
190
191 /// @brief Optional envelope kinds the server honours, beyond those every
192 /// server of this protocol version does (e.g. `kCapabilityCancel`).
193 ///
194 /// Additive: a reply from a server that predates the list has no such key,
195 /// and decodes to an empty list — "nothing optional is supported".
196 std::vector<std::string> capabilities;
197};
198
199/// @brief Builds a `register` envelope.
200/// @param typeId Model type id to register.
201/// @param contextKey Optional stable identity for the new instance (default: none).
202inline Envelope makeRegister(std::string typeId, std::string contextKey = {}) {
203 Envelope env;
204 env.kind = "register";
205 env.typeId = std::move(typeId);
206 env.contextKey = std::move(contextKey);
207 return env;
208}
209
210/// @brief Builds a shared (register-or-attach) `register` envelope.
211///
212/// The server returns the live instance for `(typeId, primary)` if one exists,
213/// otherwise creates it and enters it in the shared directory. A shared instance
214/// is recorded with no owner principal, so `IAuthorizer::authorizeInstance`'s
215/// documented `ownerPrincipal == ctx.principal` policy does not lock the second
216/// client out of an instance the first created — see
217/// docs/spec/core/shared_instances.md.
218///
219/// @param typeId Model type id to register or attach to.
220/// @param primary Canonical string encoding of the instance's primary key.
221/// @param contextKey Optional entity key for the action log (default: none).
222inline Envelope makeRegisterShared(std::string typeId, std::string primary, std::string contextKey = {}) {
224 env.kind = "register";
225 env.typeId = std::move(typeId);
226 env.primary = std::move(primary);
227 env.contextKey = std::move(contextKey);
228 env.shared = true;
229 return env;
230}
231
232/// @brief Builds an `attach` envelope — re-points a client at a different primary.
233///
234/// Semantically a `deregister` + shared `register` pair, made a single request
235/// so a re-pointing handler cannot lose its slot to `LimitPolicy::maxLiveModels`
236/// between releasing the old instance and acquiring the new one.
237///
238/// @param typeId Model type id.
239/// @param primary Canonical string encoding of the primary key to attach to.
240/// @param modelId Instance the client is currently attached to; `0` if none.
241/// @param contextKey Optional entity key for the action log, carried the same
242/// way `register` carries it, so an instance created via
243/// its *first* `attach` (rather than a shared `register`)
244/// still gets a configured `LogProvider`'s log attached.
245inline Envelope makeAttach(std::string typeId, std::string primary, uint64_t modelId = 0,
246 std::string contextKey = {}) {
248 env.kind = "attach";
249 env.typeId = std::move(typeId);
250 env.primary = std::move(primary);
251 env.modelId = modelId;
252 env.contextKey = std::move(contextKey);
253 env.shared = true;
254 return env;
255}
256
257/// @brief Builds an `assign` envelope — files a live instance under a primary key.
258///
259/// The promotion half of keyed instances: an action that creates its own entity
260/// runs on a not-yet-keyed instance, and only the reply carries the generated
261/// key. Assigning promotes that same instance in place, so nothing the create
262/// did is stranded on a throwaway.
263/// @param typeId Model type id.
264/// @param primary Canonical string encoding of the key to file the instance under.
265/// @param modelId Live instance to promote.
266inline Envelope makeAssign(std::string typeId, std::string primary, uint64_t modelId) {
268 env.kind = "assign";
269 env.typeId = std::move(typeId);
270 env.primary = std::move(primary);
271 env.modelId = modelId;
272 env.shared = true;
273 return env;
274}
275
276/// @brief Builds an `instances` envelope — asks for the live shared keys of a type.
277///
278/// The reply's `body` is a JSON array of canonical key strings. Only instances
279/// created through a shared `register`/`attach` are listed; a private instance
280/// is invisible to the directory by construction.
281/// @param typeId Model type id to enumerate.
282inline Envelope makeInstances(std::string typeId) {
284 env.kind = "instances";
285 env.typeId = std::move(typeId);
286 return env;
287}
288
289/// @brief Builds a `schemas` envelope — asks for a model type's action descriptions.
290///
291/// The reply's `body` is a `{actionType: schema}` JSON object: one JSON Schema
292/// document per action registered for @p typeId, each carrying that action's
293/// `x-payloadFingerprint` and `x-payloadShape`
294/// (`morph::model::payloadFingerprint` / `payloadShapeString`) alongside the
295/// usual form metadata. A client not linked against the model's C++ has no
296/// other way to learn an action's shape — see "Serving action schemas" in
297/// docs/spec/core/wire.md.
298/// @param typeId Model type id whose action descriptions are wanted.
299inline Envelope makeSchemas(std::string typeId) {
301 env.kind = "schemas";
302 env.typeId = std::move(typeId);
303 return env;
304}
305
306/// @brief Builds a `cancel` envelope asking the server to stop the in-flight
307/// `execute` filed under @p targetCallId on the sender's connection.
308///
309/// The caller still assigns the envelope's own `callId` and `session` — the
310/// same session the `execute` carried, since the server honours a cancel only
311/// from the principal and connection that made the call.
312/// @param targetCallId `callId` of the `execute` to stop.
313/// @return The envelope, with `cancelCallId` set.
314inline Envelope makeCancel(uint64_t targetCallId) {
316 env.kind = "cancel";
317 env.cancelCallId = targetCallId;
318 return env;
319}
320
321/// @brief Builds a `deregister` envelope.
322inline Envelope makeDeregister(uint64_t modelId) {
324 env.kind = "deregister";
325 env.modelId = modelId;
326 return env;
327}
328
329/// @brief Builds an `ok` reply envelope. `body` carries the result for executes,
330/// `modelId` carries the new id for register-replies.
331inline Envelope makeOk(uint64_t callId = 0, std::string body = {}, uint64_t modelId = 0) {
333 env.kind = "ok";
334 env.callId = callId;
335 env.body = std::move(body);
336 env.modelId = modelId;
337 return env;
338}
339
340namespace detail {
341
342/// @brief Replaces every ASCII control byte (0x00-0x1F, 0x7F) in @p text with a
343/// printable `\xHH` textual placeholder.
344///
345/// Applied to `makeErr`'s `message` only, and no longer for JSON validity —
346/// `detail::EscapingWriteOpts` now handles that for every field. What remains is
347/// an output-sanitization concern specific to this one field: an `err` message
348/// echoes untrusted content back (an unrecognized `Envelope::kind`, a caught
349/// exception's `what()`) and is overwhelmingly destined for a log or a console,
350/// where a raw 0x1B would carry an ANSI escape sequence into the reader's
351/// terminal. `message` is diagnostic text rather than data that must round-trip
352/// byte-for-byte, so replacing these bytes with a printable transcription costs
353/// nothing and keeps them inert.
354///
355/// Originally added for the invalid-JSON failure found by the
356/// `fuzz_dispatch_execute` harness via an `err` reply echoing an unrecognized
357/// `kind` verbatim (see docs/spec/testing_strategy.md, "Known findings"); that
358/// finding is now covered at the writer instead.
359/// @param text Untrusted text that may contain raw control bytes.
360/// @return @p text with every control byte replaced by `\xHH`.
361[[nodiscard]] inline std::string sanitizeControlChars(std::string_view text) {
362 std::string out;
363 out.reserve(text.size());
364 for (char c : text) {
365 auto byte = static_cast<unsigned char>(c);
366 if (byte < 0x20 || byte == 0x7F) {
367 static constexpr char kHex[] = "0123456789abcdef";
368 out += "\\x";
369 out += kHex[(byte >> 4) & 0xF];
370 out += kHex[byte & 0xF];
371 } else {
372 out += c;
373 }
374 }
375 return out;
376}
377
378/// @brief Write options that escape ASCII control bytes as `\\uXXXX` sequences.
379///
380/// glaze 7.4 leaves control bytes unescaped by default, which breaks `encode`
381/// two different ways depending on where the byte lands:
382///
383/// - **Invalid output.** RFC 8259 requires U+0000 through U+001F to be escaped,
384/// and glaze's own reader enforces that, so an envelope carrying a raw 0x0B
385/// anywhere serializes to JSON the peer's `decode` then throws on.
386/// - **Silent corruption.** Worse, the writer's chunked fast path mangles such a
387/// byte outright once the string also contains an escaped character: with a
388/// backslash or double-quote character earlier in the same string, a 0x0B at certain offsets is written
389/// as *two* 0x00 bytes. The payload is destroyed before it reaches the wire,
390/// so no amount of post-processing on the serialized form can recover it —
391/// the escaping has to happen inside the writer.
392///
393/// Enabling the option is glaze's documented remedy for exactly this (see its
394/// README, "escape control characters"); it is off by default there only because
395/// embedded nulls are hazardous for C APIs, which does not apply to a
396/// `std::string` wire field. Escaping is lossless in both directions, so any
397/// such byte round-trips unchanged rather than being replaced.
398///
399/// Applies to writing only. `decode` needs no counterpart: glaze's reader
400/// already accepts `\\uXXXX` and turns it back into the original byte.
401struct EscapingWriteOpts : glz::opts {
402 /// @brief Emit control bytes as `\\uXXXX` rather than raw.
403 // NOLINTNEXTLINE(readability-identifier-naming) — the name is glaze's, not ours; the option is matched by name.
405};
406
407/// @brief The `Envelope` keys `encode` omits when the member holds its default.
408///
409/// `Envelope` is a union-of-all-kinds struct: an `ok` reply uses three of its
410/// thirteen members and a `deregister` request uses two, but glaze writes every
411/// member of a struct it is handed, so without this a minimal reply carrying an
412/// 8-byte payload costs 255 bytes on the wire, 213 of them fields the kind does
413/// not use.
414///
415/// Omitting a member that holds its default is **not** a protocol change. The
416/// decoder default-initialises the `Envelope` and reads with
417/// `error_on_unknown_keys = false`; a key that is absent therefore leaves the
418/// member at exactly the value the omitted key would have written. The
419/// `kProtocolVersion` bump that `docs/spec/core/wire.md` requires for a field
420/// removal or retype does not apply, because nothing is removed or retyped —
421/// see "Omitted default fields" there, and `test_wire_omitted_fields.cpp`,
422/// which decodes both forms and requires the results to be equal.
423///
424/// **Two fields are deliberately absent from this list, and for the same
425/// reason: their default is not "unset".** The rule the rest of the list obeys
426/// is that omitting a member is safe when its default means *nothing* — when
427/// "absent" and "present, holding the default" are the same statement. That
428/// holds for the payload and addressing fields: an empty `typeId` means no type
429/// id, a `modelId` of `0` means no model id (`remote.hpp` tests exactly that),
430/// and a `protocolVersion` of `0` is documented on the member as "unspecified /
431/// legacy peer" — precisely what an encoder that had never heard of the field
432/// would produce. For these two it does not hold:
433///
434/// - **`kind`** is the discriminator; an envelope without one is malformed.
435/// Keeping it unconditional also means the first key of every message is
436/// still `"kind"`.
437/// - **`callId` is a correlation field, and `0` is a value rather than an
438/// absence.** Both transports route on it: a reply is matched against the
439/// pending request filed under its `callId`, and `0` names none, so such a
440/// reply is dropped (`QtWebSocketBackend::onTextMessage`,
441/// `SocketBackend::dispatchIncomingEnvelope`). A peer therefore cannot treat
442/// an absent `callId` as "no information"; it has to *reconstruct* the
443/// value before it can route the frame at all. morph's own `decode` does
444/// that for free by default-initialising, so omitting it round-trips cleanly
445/// in C++ and still breaks the scenario driver, a second decoder that reads
446/// absence as `None`. Emitting correlation
447/// fields unconditionally costs eleven bytes on the one message shape that
448/// carries a zero id, and keeps "how do I route this frame" answerable from
449/// the frame.
450///
451/// The general form of the rule, for anyone adding a member: **elide payload,
452/// never elide identity.** If a peer has to know the field's value to decide
453/// where the message goes, it is written even at its default.
454inline constexpr std::array<std::string_view, 12> kOmittableEnvelopeKeys{
455 "typeId", "contextKey", "primary", "shared", "modelId", "modelType",
456 "actionType", "body", "message", "session", "protocolVersion", "cancelCallId"};
457
458/// @brief Whether a session context carries nothing worth sending.
459///
460/// @param ctx Session context to inspect.
461/// @return `true` when every member is empty, so the whole `session` object can
462/// be left out of the envelope.
463[[nodiscard]] inline bool isDefaultSession(const ::morph::session::Context& ctx) noexcept {
464 // A field-by-field test rather than `ctx == Context{}`, because `Context` is
465 // a plain aggregate with no `operator==` and giving it one would reach
466 // outside this header. The assertion is what stops the two drifting: a new
467 // member would otherwise be dropped from every envelope whose other session
468 // fields happen to be empty, silently and only on the wire.
469 static_assert(glz::reflect<::morph::session::Context>::size == 5,
470 "session::Context gained or lost a member: update isDefaultSession, or the new "
471 "member is silently dropped from every envelope with an otherwise-empty session.");
472 return ctx.principal.empty() && ctx.token.empty() && ctx.requestId.empty() && ctx.locale.empty() &&
473 ctx.metadata.empty();
474}
475
476/// @brief Collects the keys `encode` should leave out of @p env.
477///
478/// @param env Envelope about to be serialised.
479/// @param out Storage for the key list; only the returned prefix is meaningful.
480/// @return The keys to exclude, as a view into @p out.
481[[nodiscard]] inline std::span<const std::string_view> defaultValuedKeys(
482 const Envelope& env, std::array<std::string_view, kOmittableEnvelopeKeys.size()>& out) noexcept {
483 // Every name below must be a real reflected key of `Envelope`: glaze's
484 // exclude-write reports `unknown_key` for one that is not, which would turn
485 // a member rename into a throw from *every* `encode` call at runtime. This
486 // makes it a compile error instead.
487 static_assert(glz::reflect<Envelope>::size == 14,
488 "Envelope gained or lost a member: decide whether it is omittable when default "
489 "and update kOmittableEnvelopeKeys. A new member left off that list is always "
490 "written, which is merely the old behaviour; one added with a wrong spelling is "
491 "caught by the assertion below.");
492 static_assert(std::ranges::all_of(kOmittableEnvelopeKeys,
493 [](std::string_view key) {
494 return std::ranges::find(glz::reflect<Envelope>::keys, key) !=
495 std::ranges::end(glz::reflect<Envelope>::keys);
496 }),
497 "kOmittableEnvelopeKeys names a key Envelope does not have — a rename, or a typo.");
498
499 std::size_t count = 0;
500 auto omit = [&out, &count](std::string_view key) noexcept { out.at(count++) = key; };
501
502 // No `callId` arm, deliberately: see `kOmittableEnvelopeKeys`. It is a
503 // correlation field whose zero is a routing sentinel, so it is written
504 // unconditionally, exactly like `kind`.
505 if (env.typeId.empty()) {
506 omit("typeId");
507 }
508 if (env.contextKey.empty()) {
509 omit("contextKey");
510 }
511 if (env.primary.empty()) {
512 omit("primary");
513 }
514 if (!env.shared) {
515 omit("shared");
516 }
517 if (env.modelId == 0) {
518 omit("modelId");
519 }
520 if (env.modelType.empty()) {
521 omit("modelType");
522 }
523 if (env.actionType.empty()) {
524 omit("actionType");
525 }
526 if (env.body.empty()) {
527 omit("body");
528 }
529 if (env.message.empty()) {
530 omit("message");
531 }
532 if (isDefaultSession(env.session)) {
533 omit("session");
534 }
535 if (env.protocolVersion == 0) {
536 omit("protocolVersion");
537 }
538 if (env.cancelCallId == 0) {
539 omit("cancelCallId");
540 }
541 return std::span<const std::string_view>{out.data(), count};
542}
543
544/// @brief Best-effort recovery of an envelope's `callId` without decoding it.
545///
546/// For the one case where a transport must answer a message it has decided
547/// *not* to parse — a frame rejected for exceeding a transport-level size cap,
548/// before `decode` is ever called. Such a reply still has to be addressed: a
549/// client files every request under its `callId` and matches the reply to it,
550/// and a reply carrying `0` matches nothing, so the client drops it and the
551/// request it was meant for stays pending until the connection ends.
552///
553/// Scans at most @p maxScanBytes, so the size cap it serves keeps its value as
554/// a cost bound; `callId` is the second field `encode` writes and is never
555/// omitted (see `kOmittableEnvelopeKeys`), so for any envelope morph encoded it
556/// lands well inside even a small window. A `"callId":` sequence cannot be
557/// forged from within an earlier string field, because `encode` escapes any
558/// embedded quote (yielding `\"callId\":`, which does not match).
559///
560/// The `0`-on-absence return is load-bearing: the input here is by definition a frame that was *not* produced under
561/// morph's own invariants — it is whatever a peer sent, at a size this side already refused to parse — so "no `callId`
562/// in the first KiB" remains a case that has to have an answer.
563///
564/// @param json Raw, undecoded envelope text.
565/// @param maxScanBytes Prefix length to search. Default 1 KiB.
566/// @return The parsed `callId`, or `0` if absent, unparseable, or out of range —
567/// an id that matches no pending call.
568[[nodiscard]] inline std::uint64_t peekCallId(std::string_view json, std::size_t maxScanBytes = 1024) noexcept {
569 static constexpr std::string_view kKey = "\"callId\":";
570 const std::string_view window = json.substr(0, std::min(json.size(), maxScanBytes));
571 const auto keyPos = window.find(kKey);
572 if (keyPos == std::string_view::npos) {
573 return 0;
574 }
575 std::size_t idx = keyPos + kKey.size();
576 // idx is bounded by the same condition that guards the access.
577 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
578 while (idx < window.size() && (window[idx] == ' ' || window[idx] == '\t')) {
579 ++idx;
580 }
581 // No digits leaves `value` at 0, which is already the "no id" answer.
582 std::uint64_t value = 0;
583 for (; idx < window.size(); ++idx) {
584 // idx is bounded by the loop condition.
585 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access)
586 const char chr = window[idx];
587 if (chr < '0' || chr > '9') {
588 break;
589 }
590 const auto digit = static_cast<std::uint64_t>(chr - '0');
591 if (value > (std::numeric_limits<std::uint64_t>::max() - digit) / 10U) {
592 return 0; // out of range — treat as unrecoverable rather than wrap
593 }
594 value = (value * 10U) + digit;
595 }
596 return value;
597}
598
599} // namespace detail
600
601/// @brief Builds an `err` reply envelope.
602///
603/// @p message may embed untrusted content (e.g. an unrecognized `Envelope::kind`
604/// or a caught exception's `what()`) — any raw control byte it contains is
605/// replaced with a `\xHH` placeholder. This is *output sanitization*, not JSON
606/// validity: `encode`'s `detail::EscapingWriteOpts` already guarantees validity
607/// for every field. What this stops is a raw control byte reaching a terminal or
608/// log that interprets it (see `detail::sanitizeControlChars`).
609inline Envelope makeErr(std::string message, uint64_t callId = 0) {
611 env.kind = "err";
612 env.callId = callId;
613 env.message = detail::sanitizeControlChars(message);
614 return env;
615}
616
617/// @brief The envelope kind of a server-initiated event.
618inline constexpr std::string_view kEventKind = "event";
619
620/// @brief The decoded `body` of an `event` envelope.
621struct EventBody {
622 /// @brief What the event is about (`changes`, `resync`).
623 std::string topic;
624 /// @brief The topic's payload: a JSON value, carried verbatim.
625 glz::raw_json payload;
626};
627
628/// @brief Builds an `event` envelope: `callId` 0, `body` = `{"topic":…,"payload":…}`.
629///
630/// @param topic Event topic.
631/// @param payloadJson The payload as a JSON value; embedded verbatim, so it
632/// must be valid JSON (an empty string is sent as `null`).
633/// @return The event envelope.
634[[nodiscard]] inline Envelope makeEvent(std::string_view topic, std::string payloadJson) {
635 Envelope env;
636 env.kind = std::string{kEventKind};
637 env.callId = 0;
638 if (payloadJson.empty()) {
639 payloadJson = "null";
640 }
641 EventBody body{.topic = std::string{topic}, .payload = glz::raw_json{std::move(payloadJson)}};
642 env.body = glz::write_json(body).value_or(std::string{});
643 return env;
644}
645
646/// @brief The `changes` event topic: a `ChangesBody` payload naming what changed.
647inline constexpr std::string_view kChangesTopic = "changes";
648
649/// @brief The `resync` event topic: events were withheld, so a client re-reads what it shows.
650inline constexpr std::string_view kResyncTopic = "resync";
651
652/// @brief The envelope kind that adds topics to a connection's watch set (or replaces it).
653inline constexpr std::string_view kWatchKind = "watch";
654
655/// @brief The envelope kind that removes topics from a connection's watch set.
656inline constexpr std::string_view kUnwatchKind = "unwatch";
657
658/// @brief The most topics (records or instances) one `watch`, `unwatch` or `changes` body may list, and
659/// the most topics, records and instances together, one connection may watch.
660///
661/// A watch set is one entry per record type and open instance of a screen, so a list past this is a
662/// malformed or hostile peer rather than a large application, and the bound keeps its allocation finite.
663inline constexpr std::size_t kMaxWatchTopics = 4096;
664
665/// @brief The longest record name, model type or primary key text in a watch or changes body.
666///
667/// The most keys one record entry of a `changes` body may list is `effects::kMaxChangeKeys`.
668inline constexpr std::size_t kMaxTopicNameBytes = 1024;
669
670/// @brief One instance topic: a model type, its directory key text and its instance id.
672 /// @brief The model's registered type name.
673 std::string modelType;
674 /// @brief The directory key's text for a keyed shared instance; empty for a private one.
675 std::string primary;
676 /// @brief The instance id; 0 where the topic is named by type and primary alone (a request).
677 std::uint64_t instance = 0;
678};
679
680/// @brief The body of a `watch` or `unwatch` request.
681struct WatchBody {
682 /// @brief Record wire names to watch (or stop watching).
683 std::vector<std::string> records;
684 /// @brief Instance topics to watch (or stop watching).
685 std::vector<InstanceTopic> instances;
686 /// @brief For `watch`: replace the connection's whole watch set instead of adding to it.
687 bool replace = false;
688};
689
690/// @brief The body of the `ok` reply to a `watch`: the topics the server refused.
692 /// @brief Record names the server does not publish or the caller may not read.
693 std::vector<std::string> refusedRecords;
694 /// @brief Instance topics the server refused.
695 std::vector<InstanceTopic> refusedInstances;
696};
697
698/// @brief The payload of a `changes` event.
700 /// @brief Record changes; a record with no `keys` changed as a whole.
701 std::vector<effects::RecordChange> records;
702 /// @brief Instance changes.
703 std::vector<InstanceTopic> instances;
704};
705
706namespace detail {
707
708template <class T>
709[[nodiscard]] inline std::expected<T, std::string> readBody(std::string_view json, std::string_view what) {
710 if (json.size() > kMaxEnvelopeBytes) {
711 return std::unexpected(std::string{what} + " exceeds " + std::to_string(kMaxEnvelopeBytes) + " bytes");
712 }
713 T out{};
714 static constexpr glz::opts kLenient{.null_terminated = false, .error_on_unknown_keys = false};
715 if (auto const error = glz::read<kLenient>(out, json)) {
716 return std::unexpected(std::string{what} + ": " + glz::format_error(error, json));
717 }
718 return out;
719}
720
721// NOLINTNEXTLINE(bugprone-easily-swappable-parameters): a name, then how a refusal describes it; swapped, only the message reads oddly.
722[[nodiscard]] inline std::optional<std::string> checkTopicName(std::string_view name, std::string_view what) {
723 if (name.size() > kMaxTopicNameBytes) {
724 return std::string{what} + " is longer than " + std::to_string(kMaxTopicNameBytes) + " bytes";
725 }
726 return std::nullopt;
727}
728
729[[nodiscard]] inline std::optional<std::string> checkNames(const std::vector<std::string>& names,
730 std::string_view what) {
731 if (names.size() > kMaxWatchTopics) {
732 return std::string{what} + " list more than " + std::to_string(kMaxWatchTopics) + " topics";
733 }
734 for (const auto& name : names) {
735 if (auto reason = checkTopicName(name, what)) {
736 return reason;
737 }
738 }
739 return std::nullopt;
740}
741
742[[nodiscard]] inline std::optional<std::string> checkInstances(const std::vector<InstanceTopic>& topics,
743 std::string_view what) {
744 if (topics.size() > kMaxWatchTopics) {
745 return std::string{what} + " list more than " + std::to_string(kMaxWatchTopics) + " topics";
746 }
747 for (const auto& topic : topics) {
748 if (auto reason = checkTopicName(topic.modelType, "a model type")) {
749 return reason;
750 }
751 if (auto reason = checkTopicName(topic.primary, "a primary key")) {
752 return reason;
753 }
754 }
755 return std::nullopt;
756}
757
758} // namespace detail
759
760/// @brief Reads the body of a `watch` or `unwatch` request.
761///
762/// Refused: input longer than `kMaxEnvelopeBytes`, text that is not a `WatchBody`, more than
763/// `kMaxWatchTopics` records or instances, or a name longer than `kMaxTopicNameBytes`. Unknown keys are
764/// ignored.
765/// @param json The envelope `body`.
766/// @return The body, or the reason it was refused.
767[[nodiscard]] inline std::expected<WatchBody, std::string> readWatchBody(std::string_view json) {
768 auto body = detail::readBody<WatchBody>(json, "the watch body");
769 if (!body) {
770 return body;
771 }
772 if (auto reason = detail::checkNames(body->records, "a record name")) {
773 return std::unexpected(*reason);
774 }
775 if (auto reason = detail::checkInstances(body->instances, "an instance topic")) {
776 return std::unexpected(*reason);
777 }
778 return body;
779}
780
781/// @brief Reads the body of the `ok` reply to a `watch`.
782///
783/// Bounded as `readWatchBody` is.
784/// @param json The envelope `body`.
785/// @return The reply, or the reason it was refused.
786[[nodiscard]] inline std::expected<WatchReply, std::string> readWatchReply(std::string_view json) {
787 auto body = detail::readBody<WatchReply>(json, "the watch reply");
788 if (!body) {
789 return body;
790 }
791 if (auto reason = detail::checkNames(body->refusedRecords, "a record name")) {
792 return std::unexpected(*reason);
793 }
794 if (auto reason = detail::checkInstances(body->refusedInstances, "an instance topic")) {
795 return std::unexpected(*reason);
796 }
797 return body;
798}
799
800/// @brief Reads the payload of a `changes` event.
801///
802/// Refused: input longer than `kMaxEnvelopeBytes`, text that is not a `ChangesBody`, more than
803/// `kMaxWatchTopics` records or instances, more than `effects::kMaxChangeKeys` keys in one record, or a name longer
804/// than `kMaxTopicNameBytes`.
805/// @param json The event's `payload`.
806/// @return The payload, or the reason it was refused.
807[[nodiscard]] inline std::expected<ChangesBody, std::string> readChangesBody(std::string_view json) {
808 auto body = detail::readBody<ChangesBody>(json, "the changes payload");
809 if (!body) {
810 return body;
811 }
812 if (body->records.size() > kMaxWatchTopics) {
813 return std::unexpected("a record list more than " + std::to_string(kMaxWatchTopics) + " topics");
814 }
815 for (const auto& change : body->records) {
816 if (auto reason = detail::checkTopicName(change.record, "a record name")) {
817 return std::unexpected(*reason);
818 }
819 if (change.keys && change.keys->size() > effects::kMaxChangeKeys) {
820 return std::unexpected("a record's keys list more than " + std::to_string(effects::kMaxChangeKeys) +
821 " keys");
822 }
823 }
824 if (auto reason = detail::checkInstances(body->instances, "an instance topic")) {
825 return std::unexpected(*reason);
826 }
827 return body;
828}
829
830/// @brief The `err` reply message `RemoteServer` sends when
831/// `LimitPolicy::executeTimeout` fires server-side (see
832/// `RemoteServer::dispatchExecute`'s `_executeTimeouts` path).
833///
834/// `"timeout"` exactly is the documented wire contract (`docs/spec/core/
835/// backend.md`'s `executeTimeout` row, `docs/spec/core/completion.md`'s
836/// `TimeoutError` row, `docs/spec/error_handling.md`'s `"timeout"` row) —
837/// real clients on the wire, not just this codebase's own backends, match
838/// against this literal, so it cannot change. Callers matching a reply
839/// against it should still use this constant rather than a hand-typed
840/// literal, purely to keep the one producer and its consumers from drifting
841/// apart by typo; it does not, on its own, rule out an application
842/// exception whose `what()` happens to read exactly "timeout" being
843/// misclassified the same way (see `makeErr`'s docs above on `message`
844/// carrying caught exception text) — that is a narrow, pre-existing
845/// ambiguity inherent to a free-text wire field, not something this
846/// constant can close without breaking the documented contract.
847inline constexpr std::string_view kExecuteTimeoutMessage = "timeout";
848
849/// @brief Builds a `hello` envelope announcing the sender's protocol version.
850///
851/// Exchanged once per connection, before any `register`/`execute`. See
852/// "Protocol version negotiation" in docs/spec/core/wire.md.
853/// @param protocolVersion Protocol version the sender speaks (default: `kProtocolVersion`).
854inline Envelope makeHello(std::uint32_t protocolVersion = kProtocolVersion) {
856 env.kind = "hello";
857 env.protocolVersion = protocolVersion;
858 return env;
859}
860
861/// @brief The serialisation primitive `encode` calls, as an injectable strategy.
862///
863/// Same shape and rationale as `morph::core::FileIoOps`: the member defaults
864/// to the real call it stands in for, so a default-constructed `WireCodecOps`
865/// is byte-for-byte what `encode` did before this seam existed.
866///
867/// @par Why this exists
868/// `encode`'s write-failure arm is unreachable through any `Envelope` value.
869/// Glaze only sets a write-time error for `invalid_partial_key`/`unknown_key`
870/// (its partial-write-by-key-list feature, which `encode` does not use) or
871/// `invalid_variant_object` (a `std::variant` member, which `Envelope` does
872/// not have). Confirmed by experiment as well as by reading glaze: five
873/// flavours of invalid UTF-8, an embedded NUL, a raw control byte and an 8 MiB
874/// payload all encode successfully.
875///
876/// That leaves a branch guarding a real invariant with no way to reach it.
877/// Rather than reshape `Envelope` to make a test possible, or wait on a
878/// fault-injection hook glaze does not offer, this puts the seam on morph's
879/// side of the boundary — where the repository already puts it for the
880/// identical problem with file I/O.
882 /// @brief Serialises @p env into @p out. Mirrors the `glz::write` call
883 /// `encode` would otherwise make directly.
884 /// @return Glaze's error context; falsy on success.
885 std::function<glz::error_ctx(const Envelope& env, std::string& out)> writeEnvelope = [](const Envelope& env,
886 std::string& out) {
887 std::array<std::string_view, detail::kOmittableEnvelopeKeys.size()> omitted{};
888 return glz::write_json_exclude<detail::EscapingWriteOpts{}>(env, detail::defaultValuedKeys(env, omitted), out);
889 };
890};
891
892/// @brief The shared default `WireCodecOps`.
893///
894/// A function-local static, not a default-constructed temporary in `encode`'s
895/// signature: `encode` runs on every outbound message, and building a fresh
896/// `std::function` per call would put an allocation on that path purely to
897/// support a test seam.
898/// @return Reference to the process-wide default operations.
899[[nodiscard]] inline const WireCodecOps& defaultWireCodecOps() {
900 static const WireCodecOps ops;
901 return ops;
902}
903
904/// @brief Encodes @p env as a single JSON line.
905///
906/// Writes with `detail::EscapingWriteOpts`, so raw ASCII control bytes in any
907/// string field become `\uXXXX` escapes. Without it, a single 0x0B anywhere in
908/// `body`, `modelType`, `contextKey`, `typeId`, `actionType`, or the session's
909/// `principal`/`token` yields an envelope the peer's `decode` throws on — and,
910/// where the same string also contains a `\` or `"`, one glaze silently
911/// corrupts before it is ever sent. Both are payload-triggered transport
912/// failures, not caller errors. The escape is lossless, so such bytes still
913/// round-trip byte-for-byte.
914///
915/// @param env Envelope to serialize.
916/// @param ops Serialisation strategy; defaults to the real `glz::write` call.
917/// A test injects a failing one to exercise the throw path, which no
918/// `Envelope` value can reach — see `WireCodecOps`.
919/// @return The serialized envelope as valid, re-decodable JSON.
920/// @throws std::runtime_error on serialisation failure (should never happen for valid input).
921inline std::string encode(const Envelope& env, const WireCodecOps& ops = defaultWireCodecOps()) {
922 MORPH_ZONE("wire::encode");
923 MORPH_ZONE_TEXT(env.session.requestId);
924 std::string out;
925 if (auto errCode = ops.writeEnvelope(env, out)) {
926 // Unreachable through any `Envelope` value -- see `WireCodecOps` for
927 // why, and for how a test covers this arm anyway.
928 throw std::runtime_error("envelope encode failed: " + glz::format_error(errCode, out));
929 }
930 return out;
931}
932
933/// @brief Decodes @p json into an `Envelope`.
934///
935/// Enforces a wire-layer size cap: inputs longer than `kMaxEnvelopeBytes` are
936/// rejected before any parsing, bounding the cost of an oversized or
937/// deeply-nested payload smuggled inside the opaque `body` string (see
938/// `kMaxEnvelopeBytes`). This guard does **not** cap the *inner* re-parse of
939/// `body` performed later by the action codec — that codec must impose its own
940/// limits — but it does bound the total message a single `decode` will accept.
941///
942/// glaze 7.4 runs with its default options here. Note the parser does **not**
943/// reject duplicate JSON object keys: a duplicated key is accepted and the last
944/// occurrence wins. There is no glaze option to change this, so `decode` cannot
945/// enforce rejection; callers must not treat duplicate-key rejection as a
946/// security boundary (see docs/spec/core/wire.md).
947///
948/// @param json Serialized envelope JSON to decode.
949/// @return The decoded `Envelope`.
950/// @throws std::runtime_error if @p json exceeds `kMaxEnvelopeBytes` or is not a
951/// valid envelope.
952inline Envelope decode(std::string_view json) {
953 MORPH_ZONE("wire::decode");
954 if (json.size() > kMaxEnvelopeBytes) {
955 throw std::runtime_error("envelope decode failed: input exceeds maximum size (" + std::to_string(json.size()) +
956 " > " + std::to_string(kMaxEnvelopeBytes) + " bytes)");
957 }
958 Envelope env{};
959 // Ignore unknown keys so the envelope is forward-compatible: a newer peer may
960 // add fields a older peer does not know, and vice versa, without a hard parse
961 // failure. Duplicate keys are still accepted last-wins (glaze offers no reject
962 // option) — see docs/spec/core/wire.md "Parsing guarantees and hardening".
963 // `null_terminated = false` is required, not cosmetic: `json` is a caller-supplied
964 // `string_view` with no guarantee of a trailing '\0' (e.g. a raw socket read).
965 // Left at glaze's default (true), `skip_ws` assumes it can scan past `end`
966 // looking for a terminator that may not exist — a heap-buffer-overflow on
967 // adversarial input (found by the wire_decode fuzz harness, see
968 // docs/spec/testing_strategy.md). This costs nothing: it only disables an
969 // optimization that never applied to us.
970 static constexpr glz::opts kLenient{.null_terminated = false, .error_on_unknown_keys = false};
971 if (auto errCode = glz::read<kLenient>(env, json)) {
972 throw std::runtime_error("envelope decode failed: " + glz::format_error(errCode, json));
973 }
974 MORPH_ZONE_TEXT(env.session.requestId);
975 return env;
976}
977
978/// @brief Outcome of a `"hello"` protocol-version negotiation attempt.
979enum class ProtocolNegotiationResult : std::uint8_t {
980 /// @brief The peer understood `"hello"` and accepted the announced version.
981 Negotiated,
982 /// @brief The peer does not understand `"hello"` (a pre-negotiation legacy peer).
983 LegacyPeer,
984};
985
986/// @brief Classifies a decoded reply to a `"hello"` envelope.
987///
988/// @param reply Decoded reply envelope — the result of `decode()` on the
989/// response to a `"hello"` round-trip.
990/// @return `Negotiated` if @p reply's `kind` is `"ok"`; `LegacyPeer` if it is
991/// an envelope whose `message` is exactly `"unknown envelope kind:
992/// hello"` — the generic unrecognised-`kind` message a pre-negotiation
993/// `RemoteServer` produces for a `kind` it does not switch on.
994/// @throws std::runtime_error if @p reply is any other `"err"` (e.g.
995/// `"protocol version unsupported"`), so the caller refuses to
996/// proceed instead of silently treating an explicit rejection as a
997/// legacy peer.
998inline ProtocolNegotiationResult interpretHelloReply(const Envelope& reply) {
999 if (reply.kind == "ok") {
1000 return ProtocolNegotiationResult::Negotiated;
1001 }
1002 // Matched on `message` alone: `kind` is deliberately not inspected, because a
1003 // peer old enough to not know `hello` is also a peer whose error shape we do
1004 // not want to depend on. Any envelope carrying exactly this message is
1005 // treated as a legacy peer.
1006 if (reply.message == "unknown envelope kind: hello") {
1007 return ProtocolNegotiationResult::LegacyPeer;
1008 }
1009 throw std::runtime_error("protocol negotiation failed: " +
1010 (reply.message.empty() ? std::string{"malformed reply"} : reply.message));
1011}
1012
1013/// @brief Whether a reply to `"hello"` advertises @p capability.
1014///
1015/// Reads the `ProtocolRange` in an `"ok"` reply's `body`, ignoring keys it does
1016/// not know. Anything else — an `"err"`, a legacy peer's refusal, a body that
1017/// does not parse, a server that predates the capability list — advertises
1018/// nothing, so a client falls back to not using the capability rather than to
1019/// sending a kind the server may not understand.
1020/// @param reply Decoded reply to a `"hello"` envelope.
1021/// @param capability Capability to look for, e.g. `kCapabilityCancel`.
1022/// @return `true` only when the reply is `"ok"` and its capability list names
1023/// @p capability.
1024[[nodiscard]] inline bool helloAdvertises(const Envelope& reply, std::string_view capability) {
1025 if (reply.kind != "ok") {
1026 return false;
1027 }
1028 ProtocolRange range;
1029 static constexpr glz::opts kLenient{.null_terminated = false, .error_on_unknown_keys = false};
1030 if (glz::read<kLenient>(range, reply.body)) {
1031 return false;
1032 }
1033 return std::ranges::find(range.capabilities, capability) != range.capabilities.end();
1034}
1035
1036} // namespace morph::wire
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
AnyHandle env(std::string const &name)
A client environment value, {"env": name}: widthClass, locale, platform, online, backend.
Definition author.hpp:1209
bool names(DocNode const &node, std::string_view key)
Whether a node has a property that names it: present, and not the constant "" or null.
Definition author.hpp:4373
Handle< std::int64_t > count(AnyHandle const &list)
count: a list's length.
Definition author.hpp:1000
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_ZONE_TEXT(text)
Attaches text to the enclosing scope's MORPH_ZONE; an empty text attaches nothing.
Definition profiler.hpp:126
#define MORPH_ZONE(name)
Opens a profiler zone named name that ends with the enclosing scope. At most one per scope.
Definition profiler.hpp:118
Per-call session context carried through the bridge to the model.
Definition session.hpp:26
The payload of a changes event.
Definition wire.hpp:699
std::vector< effects::RecordChange > records
Record changes; a record with no keys changed as a whole.
Definition wire.hpp:701
std::vector< InstanceTopic > instances
Instance changes.
Definition wire.hpp:703
JSON wire envelope used between any client and RemoteServer.
Definition wire.hpp:91
std::uint64_t cancelCallId
The callId of the execute a "cancel" asks the server to stop.
Definition wire.hpp:168
std::string modelType
Model type id for execute (routing key in ActionDispatcher).
Definition wire.hpp:135
std::string message
Free-text error message on err. Empty on success.
Definition wire.hpp:145
std::string actionType
Action type id for execute (the second routing key).
Definition wire.hpp:138
uint64_t modelId
Existing model instance id for deregister, execute, ok(register).
Definition wire.hpp:132
bool shared
Whether a register should join the shared instance directory.
Definition wire.hpp:129
std::uint32_t protocolVersion
Protocol version the sender speaks.
Definition wire.hpp:159
std::string kind
Discriminator — see class docstring for valid values.
Definition wire.hpp:93
uint64_t callId
Correlation id for matching async replies to their requests.
Definition wire.hpp:96
::morph::session::Context session
Session context for authorization and routing. Populated on execute; ignored on every other kind.
Definition wire.hpp:149
std::string contextKey
Stable identity of the model instance being registered or attached to (e.g. an account id)....
Definition wire.hpp:106
std::string body
Serialized JSON of the action payload (execute) or of the deserializable result (ok).
Definition wire.hpp:142
std::string primary
Primary key of the instance being registered or attached to.
Definition wire.hpp:120
std::string typeId
Model type id for register.
Definition wire.hpp:99
The decoded body of an event envelope.
Definition wire.hpp:621
std::string topic
What the event is about (changes, resync).
Definition wire.hpp:623
glz::raw_json payload
The topic's payload: a JSON value, carried verbatim.
Definition wire.hpp:625
One instance topic: a model type, its directory key text and its instance id.
Definition wire.hpp:671
std::string primary
The directory key's text for a keyed shared instance; empty for a private one.
Definition wire.hpp:675
std::uint64_t instance
The instance id; 0 where the topic is named by type and primary alone (a request).
Definition wire.hpp:677
std::string modelType
The model's registered type name.
Definition wire.hpp:673
Inclusive protocol-version range a server advertises in reply to "hello".
Definition wire.hpp:184
std::uint32_t max
Newest protocol version the server accepts.
Definition wire.hpp:189
std::vector< std::string > capabilities
Optional envelope kinds the server honours, beyond those every server of this protocol version does (...
Definition wire.hpp:196
std::uint32_t min
Oldest protocol version the server accepts.
Definition wire.hpp:186
The body of a watch or unwatch request.
Definition wire.hpp:681
std::vector< InstanceTopic > instances
Instance topics to watch (or stop watching).
Definition wire.hpp:685
bool replace
For watch: replace the connection's whole watch set instead of adding to it.
Definition wire.hpp:687
std::vector< std::string > records
Record wire names to watch (or stop watching).
Definition wire.hpp:683
The body of the ok reply to a watch: the topics the server refused.
Definition wire.hpp:691
std::vector< InstanceTopic > refusedInstances
Instance topics the server refused.
Definition wire.hpp:695
std::vector< std::string > refusedRecords
Record names the server does not publish or the caller may not read.
Definition wire.hpp:693
The serialisation primitive encode calls, as an injectable strategy.
Definition wire.hpp:881
std::function< glz::error_ctx(const Envelope &env, std::string &out)> writeEnvelope
Serialises env into out. Mirrors the glz::write call encode would otherwise make directly.
Definition wire.hpp:885
Write options that escape ASCII control bytes as \\uXXXX sequences.
Definition wire.hpp:401
bool escape_control_characters
Emit control bytes as \\uXXXX rather than raw.
Definition wire.hpp:404