`? maybe: tstr` and `maybe: ?tstr` are the same declaration. logos-lidl's
docs/spec.md says so and requires them to produce byte-identical code, and
logos-lidl#7 added fieldIsOptional()/fieldValueType() precisely so no backend
re-derives the answer. The cdylib, client-stub and both Rust backends honour
that. The LEGACY consumer path — the one every real C++ module builds through,
`logos-cpp-generator --general-only --dep <name>=<lidl>` from
logos-plugin-qt's buildPlugin.nix — did not:
? maybe: tstr -> QString maybe{}; __m.value("maybe").toString()
maybe: ?tstr -> QVariant maybe{}; __m.value("maybe")
and on `--api-style lp`, `std::string maybe{}` vs `LogosMap maybe{}` — neither
of them std::optional. The flag spelling is the one production contracts use:
logos-chat-module writes all five of its optionals that way, so every one of
them landed on the branch that silently defaults. A `QString` has no empty
inhabitant at all; an absent `nickname` and an empty-string one were the same
value by the time the consumer saw them.
ROOT CAUSE. legacy/main.cpp's moduleRecordsToJson / moduleMethodsToJson /
moduleEventsToJson flatten every TypeExpr into a single Qt TYPE-NAME STRING.
Answering "is this optional" from a name means answering it from the verbatim
spelling, which is the one thing the accessors exist to stop.
WHAT THIS CHANGES. The record-field half of that boundary, and only it. A field
object now carries `optional` alongside `type`, where `type` is the VALUE type
(fieldValueType) and `optional` is true for either spelling (fieldIsOptional).
Both spellings arrive at the emitter as the same object, so they leave as the
same code. Per surface, matching the sibling backend that already serves it:
Qt — QVariant, as lidl_gen_client.cpp already emits. Qt has no optional
template; an invalid QVariant is its single empty inhabitant. Two-state,
untyped.
Lp — std::optional<T>, as lidl_gen_cdylib.cpp already emits, encoded by
logos-protocol's Codec<std::optional<T>>. Keeps the value type.
`?any` / `?{K:V}` / `?[any]` collapse onto the bare LogosMap/LogosList:
nlohmann::json already carries null, so wrapping it would give the slot
two empty spellings — three states, which the two-state rule forbids.
Encode omits the key when empty (a record field is a NAMED slot); decode treats
an absent key and an explicit null as the same state, so neither turns empty
into "" or 0. The round trip is canonicalising, as the spec requires.
The three functions move to legacy/lidl_to_json.{h,cpp}. Not cosmetic: the rule
is a property of frontend -> JSON -> emitter, and while they sat inside a TU
with main() no test could observe it. tests/generator/test_optional_spellings.cpp
now runs that composition end to end.
WHAT THIS DOES NOT CHANGE, and why. POSITIONAL slots — method parameters,
return types, event parameters — are still flattened to QVariant (Qt) /
LogosMap (Lp). They have no name to hang a flag on, so they only ever had the
type-kind spelling and there is no divergence there to fix; what they lose is
the value type. Closing that changes generated method SIGNATURES, i.e. a source
break for every existing call site, for a defect this commit is not about.
`OptionalSpellings.PositionalSlotsAreStillFlattened` pins the current behaviour
so closing it later is deliberate, and the generator still prints a `Note:`
naming every slot it flattens. Nesting, map key types and descriptions still do
not cross the boundary either — the flag is per-field, not a general widening.
VERIFIED BY RUNNING, each check shown to fail when the property does not hold:
- Two contracts identical but for the spelling, generated with `--dep` on both
`--api-style qt` and `--api-style lp`: byte-identical. The SAME comparison on
a generator built from this base without the fix reports a difference on
both styles.
- Harness sensitivity: two contracts differing only in `count: uint` ->
`count: int` are reported as different, so the compare is not vacuous.
- A contract with no optional field generates byte-identically to the
unpatched generator, both styles — and the same compare reports a difference
when given genuinely different output.
- The 5 new assertions FAIL on this base with only the extraction grafted in
(generator_lib.cpp pristine) and pass with the fix; the 3 control assertions
pass on both, which is what makes them controls.
- Generated wrappers compile on both surfaces, for the probe contracts and for
the real logos-chat-module and test_fullapi_ext_rust contracts.
- Emitted lp record codec exercised at runtime: empty omits the key, an
explicit null decodes to nullopt rather than "", uint64 above 2^63 survives,
bstr stays canonically tagged, and `{"maybe": null}` re-encodes omitted.
- `nix build .#tests` green (107 tests), `nix build .#cpp-generator` green.
logos-qt-sdk's qt-generator has the same defect in lidl_gen_qt_consumer.cpp
(record fields read `f.type` directly); it is a separate repo and not touched
here.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
23 KiB
Logos Code Generator — Project Description
Project Structure
cpp-generator/
├── main.cpp # Entry point — dispatches to legacy or experimental
├── CMakeLists.txt # Build config
├── compile.sh # Standalone build script
├── metadata_dependencies.h # What a metadata.json `dependencies[]` array declares
├── legacy/ # Original generator (unchanged from master)
│ ├── main.cpp # legacy_main() — plugin/metadata/provider-header modes
│ ├── generator_lib.h/cpp # Shared utilities, type mapping, header parser, umbrella emission
│ ├── lidl_to_json.h/cpp # ModuleDecl → the JSON surface generator_lib consumes
│ └── legacy_main.h # Forward declaration
├── experimental/ # C++/Qt-specific generator backends
│ ├── lidl_compat.h # Bridges the backends onto logos-lidl's std AST
│ ├── lidl_emit_common.h/cpp # LIDL type → Qt/std type-name mapping
│ ├── lidl_gen_client.h/cpp # Typed client stub generation (+ Doxygen /// docs)
│ ├── lidl_gen_cdylib.h/cpp # cdylib module-impl C-ABI export generation
│ └── impl_header_parser.h/cpp # C++ impl header → lidl::ModuleDecl
│ # The lexer/parser/AST/serializer/validator now live in the standalone
│ # logos-lidl repo (linked via find_package(logos-lidl)); the Qt glue
│ # emitters live in logos-qt-sdk's logos-qt-generator.
└── docs/ # This documentation
Components
Entry Point (main.cpp)
Checks for --from-header or --lidl flags before creating QCoreApplication. If neither is present, falls through to legacy_main().
LIDL frontend — logos-lidl (consumed as a library)
The lexer, parser, AST, serializer, and validator are no longer embedded here — they live in the standalone logos-lidl repo, the language-neutral (Qt-free) common frontend every Logos SDK shares (C++ here, Rust in logos-rust-sdk, …). cpp-generator links it via find_package(logos-lidl) and reaches it through experimental/lidl_compat.h.
logos-lidl exposes (namespace lidl):
lidl::parse(std::string) → ParseResult(ModuleDecl+ error/line/column)lidl::serialize(ModuleDecl) → std::stringlidl::validate(ModuleDecl) → ValidationResult- the AST:
TypeExpr(Kind: Primitive/Array/Map/Optional/Named,name,elements),ParamDecl,FieldDecl,MethodDecl(name, params, returnType,description,jsonReturn,resultReturn),EventDecl(name, params,description),TypeDecl,ModuleDecl. (logos-lidl also exposes an AST↔JSON bridge and a C ABI that the Rust SDK consumes over FFI — not used by this generator.)
The .lidl grammar (defined in logos-lidl):
module = "module" IDENT "{" body "}"
body = (metadata | type_def | method_def | event_def)*
metadata = "version" STRING | "description" STRING | "category" STRING
| "depends" "[" (IDENT ("," IDENT)*)? "]"
type_def = "type" IDENT "{" field* "}"
field = "?"? IDENT ":" type_expr
method_def = "method" IDENT "(" params ")" "->" type_expr ("description" STRING)?
event_def = "event" IDENT "(" params ")" ("description" STRING)?
params = (IDENT ":" type_expr ("," IDENT ":" type_expr)*)?
type_expr = IDENT | "[" type_expr "]" | "{" type_expr ":" type_expr "}"
| "?" type_expr
Validation (in logos-lidl) checks: empty module name, duplicate type/method/event names, builtin type shadowing, unknown named type references, duplicate parameter names. Serialization round-trips ModuleDecl back to .lidl text (incl. the trailing description "…" clause).
Compat shim (lidl_compat.h)
Bridges the existing Qt-flavored backends onto logos-lidl's std AST so they compile unchanged:
- brings the
lidl::AST types into the global scope the backends use (viausing) qs(std::string) → QStringplus aQTextStream << std::stringoverload, so emission of AST string fields just works- name-compatible shims
lidlParse/lidlSerialize/lidlValidateoverlidl::parse/serialize/validate
Type Mapping (lidl_emit_common.h/cpp)
lidlTypeToQt(TypeExpr)/lidlTypeToStd(TypeExpr)— LIDL type → Qt / std type-name stringslidlIsStdConvertible(TypeExpr)— whether a type has a pure-C++ (Qt-free) representationlidlToPascalCase(name)— convertssnake_casetoPascalCase
Optionality
?T is two-state: a value of T, or empty. Never three-state — "one LIDL type ↔ one
type per language" leaves nowhere for a third state, because every target has exactly one
empty inhabitant.
Two spellings, one meaning. A record field may be written ? name: T (the flag) or
name: ?T (the type kind); the spec binds them to the same declaration, so they MUST emit
identical code. Backends never answer this themselves — logos-lidl's fieldIsOptional(f) /
fieldValueType(f) (re-exported by lidl_compat.h) are the one place the two are
reconciled. Reading f.optional or f.type.kind == Optional on its own is a bug.
Wire rule. Absent and explicit null are the same state on decode and different on encode:
| empty is spelled | |
|---|---|
| decode, optional slot | absent or null → empty |
| decode, required slot | absent and null are both still errors |
| encode, named slot (a record field) | the key is omitted |
| encode, positional slot (argument, return, event param) | null — there is no key to omit, and arity must never change |
A round trip therefore canonicalises: a peer that sent "f": null gets the key back
omitted. A present-but-wrong-typed value is still an error — optional widens the domain by
exactly one inhabitant, it does not switch type checking off.
Per surface:
| Surface | ?T |
Notes |
|---|---|---|
cdylib / std (lidlTypeToStd, lidl_gen_cdylib) |
std::optional<T> |
encoded by logos-protocol's Codec<std::optional<T>>; key omission is the record emitter's job (a codec never sees the slot) |
?any / ?{tstr: any} / ?[any] |
LogosMap / LogosList |
collapses: nlohmann::json already carries null, so wrapping it would make the slot three-state |
Qt (lidlTypeToQt, lidl_gen_client) |
QVariant |
two-state (an invalid QVariant is Qt's empty inhabitant) but untyped — Qt has no optional template |
legacy consumer, record field (lidl_to_json + generator_lib) |
QVariant (Qt) / std::optional<T> (Lp) |
same answer as the client-stub and cdylib backends respectively; the alias collapse above applies on Lp |
| legacy consumer, positional slot (param, return, event param) | QVariant (Qt) / LogosMap (Lp) |
still flattened — see Known Limitations |
header-first (impl_header_parser) |
std::optional<T> ↔ ?T |
std::optional<std::optional<T>> has no LIDL type; it collapses to ?T and is reported on stderr |
Client stubs (lidl_gen_client.h/cpp)
lidlMakeHeader(ModuleDecl)/lidlMakeSource(ModuleDecl)— typed<Module>client wrapper; each method (and its…Asynctwin) carries a Doxygen///comment generated from the method'sdescriptionlidlGenerateMetadataJson(ModuleDecl)— generates metadata.json content
cdylib backend (lidl_gen_cdylib.h/cpp)
Emits the Qt-free half of a universal C++ cdylib module:
lidlCdylibSupported(ModuleDecl)— gate to the std-convertible (Qt-free) type subsetlidlMakeModuleImplExports(...)— thelogos_module_impl.hC-ABI export wrapper around the universal impl class (compiled into the module's cdylib; dispatches via nlohmann::json)lidlMakeEventsSourceCdylib(...)— typedlogos_events:bodies marshalling into nlohmann::json
Per-build API-style choice (legacy/generator_lib.{h,cpp})
The codegen exposes one wrapper class per module — <Module> — with signatures that match the API style picked at the consumer's build time. The two styles are mutually exclusive (no composite output):
--api-style |
Wrapper signatures |
|---|---|
qt (default) |
QString / QStringList / QVariantList / QVariantMap / int / LogosResult |
lp |
std::string / std::vector<std::string> / LogosMap / LogosList / int64_t / StdLogosResult, over the Qt-free logos-protocol C ABI |
(A third value, std — std signatures over a QVariant / LogosAPIClient body — was retired; the generator now rejects --api-style=std instead of aliasing it.)
Both styles emit:
- A
<Module>client class with sync method shapes + matching<method>Async(...)overloads. - The std variant additionally inlines Qt↔std conversion in its
.cppso the caller's translation unit needs zero Qt headers.
The umbrella logos_sdk.h is also generated per-build and aggregates every dep into a flat LogosModules struct — no nested view:
struct LogosModules {
LogosAPI* api;
SomeDep some_dep; // one accessor per `metadata.json#dependencies` entry
// ...
};
Only the modules explicitly listed as dependencies are exposed. The runtime's core_manager is intentionally NOT in LogosModules — apps that need to manage the core do so via liblogos' C API, not via a typed RPC wrapper.
A dependencies[] element is either a bare name or an object carrying that name alongside the constraints an installer resolves it by — the two declare the same dependency and generate the same code:
"dependencies": [
"dep_a",
{ "name": "dep_b", "version": "=1.2.3" },
{ "name": "dep_c", "version": "^2.0", "signer": "did:jwk:abc" }
]
Read the array through dependencyNames() (metadata_dependencies.h) rather than element by element. The umbrella is emitted by several passes over the same array — includes, constructor initialisers, members — and a pass that decides on its own what an element names can decide differently from its neighbours, yielding a member whose type was never included. That aggregate no longer compiles, and nothing catches it until a module builds against it. One reader, one answer.
ApiStyle enum + new helpers in generator_lib:
enum class ApiStyle { Qt, Lp }— passed to every wrapper-emitting function.- File-local
mapParamTypeStd/mapReturnTypeStd— the std-side type-mapping table thelpsurface exposes. Hidden fromgenerator_lib.h(not part of the public surface). makeHeader(moduleName, className, methods, apiStyle, events)/makeSource(moduleName, className, headerBaseName, methods, apiStyle, events)— single entry points that branch onapiStyleinternally to emit the right include block, signature shape, and conversion bridges.eventsis loaded from a<name>.lidlsidecar via--events-from; when non-empty, the wrapper also gets one typedon<EventName>(callback)adapter per declared event (callback arg types followapiStyle).makeUmbrellaHeaderFromDeps(deps, interfaceNames, apiStyle, originName)/makeUmbrellaSourceFromDeps(deps, interfaceNames)— thelogos_sdk.{h,cpp}aggregate above. They return the text;legacy/main.cpp'swriteUmbrella*FromDepswrite it. That split is what lets the aggregate be asserted on directly, without a filesystem.
Flag plumbing:
metadata.json#interface == "universal"(or"cdylib") →mkLogosModule.nixadds-DLOGOS_API_STYLE=lptoextraCmakeFlags. Anything else ("legacy","provider", absent) leaves the defaultqt.LogosModule.cmakereads${LOGOS_API_STYLE}(defaultqt) and forwards--api-style=${LOGOS_API_STYLE}to thelogos-cpp-generator --general-onlyinvocation that writes the umbrella. Each module's Nix build emits two header derivations (<name>.headers-qtand<name>.headers-lp) viabuildHeaders.nix— onelogos-cpp-generator --api-style=…run per style, at the dep's build time. A consumer'sbuildPlugin.nixpicksdep.headers-${apiStyle}and copies itsinclude/straight into the build sandbox; no codegen runs at consume time. Nix's laziness means only the variant a downstream actually depends on is realised.legacy/main.cppparses--api-styleonce (rejecting the retiredstd) and threads the resultingApiStylethroughgenerateFromPlugin,writeUmbrellaHeader{,FromDeps}. No per-style filenames are ever emitted; each module gets a single<name>_api.h+<name>_api.cpppair regardless of style.
Provider Generation (logos-qt-generator)
The Qt provider glue (
lidl_gen_provider.{h,cpp}) is emitted by logos-qt-sdk'slogos-qt-generator, not this binary — it consumes the samelogos-lidlfrontend (+ the sharedlidl_emit_common/impl_header_parser/lidl_compat.h/metadata_dependencies.hhelpers, distributed undershare/lidl-frontend). Documented here for reference. Adding a header to whatimpl_header_parser.cppincludes means adding it to that install list too (nix/bin.nix) — the qt-generator compiles that source out of the installed directory, so a header left behind breaks its build, not ours.
lidlMakeProviderHeader(ModuleDecl, implClass, implHeader)— generates Qt glue header- Emits
nlohmannToQVariant()helper when any method hasjsonReturn = true - Always emits an
onInit(LogosAPI*) overridethat, via SFINAE'd helpers inlogos_module_context.h, (a) copies the three runtime-injected properties (modulePath,instanceId,instancePersistencePath) into the impl, (b) constructs a per-moduleLogosModulesaggregate and threads its pointer through the same base, and (c) installs the typed-event callback (maybeSetEmitEvent) consumed by<name>_events.cppmethod bodies. Impls that don't inheritLogosModuleContextcompile unchanged — the helper overloads collapse to no-ops. The fullLogosAPIis never exposed past the provider boundary. - Always emits
#include "logos_sdk.h"and astd::unique_ptr<LogosModules> m_logosModulesmember; ownership lives on the provider, the context base sees only a non-owningvoid*reinterpreted inLogosModuleContext::modules()(which depends on the impl's TU having includedlogos_sdk.h).
- Emits
lidlMakeProviderDispatch(ModuleDecl)— generates callMethod/getMethods dispatch.getMethods()emits the full interface: each method taggedtype: "method", then eachmodule.eventsentry taggedtype: "event"(name, signature, parameters, escapeddescription; no returnType/isInvokable). There is no separategetEvents()— folding events intogetMethods()keeps the provider vtable ABI-stable.lidlMakeEventsSource(ModuleDecl, implClass, implHeader)— generates<name>_events.cpp: Qt-MOC-style method bodies for prototypes declared in the impl'slogos_events:block. Each body marshals typed args into aQVariantListand callsthis->emitEventImpl_("<name>", &args)on the LogosModuleContext base.
Impl Header Parser (impl_header_parser.h/cpp)
parseImplHeader(headerPath, className, metadataPath, err)— parses C++ header + metadata.json into ModuleDecl- State machine:
LookingForClass→InClass→InPublic/InPrivate/InLogosEvents - The literal
logos_events:token (defined inlogos_module_context.has#define logos_events public) opens an events section; bare prototypes inside becomeEventDecl{name, params, description}entries appended toModuleDecl.events(thedescriptionis the doc comment immediately above the declaration, captured viajoinDocLinesexactly as for methods) - Skips: constructors, destructors, typedefs, using, friend, enum, struct,
std::functiondeclarations - Recognizes
LogosMapandLogosListreturn types (nlohmann::json aliases) and setsMethodDecl.jsonReturn = true - Recognizes
std::optional<T>→?T(see Optionality). Anything it does not recognize still falls back to the opaqueany, silently — that fallback is why an optional was unexpressible header-first until it was named explicitly - Template-aware parameter splitting (handles
std::vector<std::string>correctly)
CLI Usage
From C++ impl header (primary use case for universal modules)
logos-cpp-generator --from-header src/my_module_impl.h \
--backend qt \
--impl-class MyModuleImpl \
--impl-header my_module_impl.h \
--metadata metadata.json \
--output-dir ./generated_code
Generates: my_module_qt_glue.h, my_module_dispatch.cpp
From LIDL file — provider glue
logos-cpp-generator --lidl my_module.lidl \
--backend qt \
--impl-class MyModuleImpl \
--impl-header my_module_impl.h \
--output-dir ./generated_code
From LIDL file — client stubs
logos-cpp-generator --lidl my_module.lidl \
--output-dir ./generated_code \
--module-only
Legacy modes (unchanged)
logos-cpp-generator /path/to/plugin.so --output-dir ./generated
logos-cpp-generator --metadata metadata.json --general-only --output-dir ./generated
logos-cpp-generator --provider-header src/provider.h --output-dir ./generated
Consumer wrapper with typed event accessors
The --events-from <path> flag points the legacy <plugin>.dylib --module-only codegen at a LIDL sidecar shipped alongside the dep's pre-built headers. When set, the generated <name>_api.{h,cpp} gains one typed on<EventName>(callback) accessor per declared event (callback arg types match --api-style):
logos-cpp-generator /path/to/plugin.dylib \
--module-only --api-style lp \
--events-from /path/to/dep/share/logos/my_module.lidl \
--output-dir ./generated
In Nix builds this is wired automatically: buildHeaders.nix looks for <pluginLib>/share/logos/<name>.lidl (which buildPlugin.nix's installPhase placed there) and threads it through.
Building
The generator is built as part of logos-cpp-sdk:
ws build logos-cpp-sdk # builds everything including the generator
The generator binary is available as logos-cpp-generator in module build environments (provided by logos-module-builder's nativeBuildInputs).
Testing
The backends are tested in tests/experimental/, the legacy emitters in tests/generator/:
ws test logos-cpp-sdk # runs all tests including experimental
The frontend tests (lexer/parser/validator/serializer) moved to the logos-lidl repo along with the code; only the C++/Qt-specific backends are tested here:
| Test file | What it tests |
|---|---|
test_lidl_type_mapping.cpp |
lidlTypeToQt, lidlTypeToStd, lidlIsStdConvertible, lidlToPascalCase, optionality on both surfaces |
test_lidl_gen_client.cpp |
Client stub generation: sync/async methods, events, metadata JSON, edge cases, both optional spellings agreeing |
test_lidl_gen_cdylib.cpp |
cdylib eligibility + emission: bytes at depth, records, typed maps, optionality (key omission, arity, ?any collapse) |
test_impl_header_parser.cpp |
Header parsing: type mapping, access specifiers, skipping private/protected, error cases, std::optional<T> |
(The lexer/parser/AST/serializer/validator round-trip + description tests live in logos-lidl's own tests/test_lidl.cpp.)
In tests/generator/, alongside the wrapper-emitter tests:
| Test file | What it tests |
|---|---|
test_make_umbrella.cpp |
The LogosModules aggregate: both dependency forms on both API styles, that every member's type is included, dropped nameless entries, empty deps |
Fixture files in tests/experimental/fixtures/:
sample_impl.h— module with all supported type variationssample_metadata.json— metadata with dependenciesobject_deps_metadata.json— dependencies declared in both forms, with resolution constraintscomplex_impl.h— module with multiple access specifier sectionsempty_class_impl.h— class with no public methodsempty_metadata.json— minimal metadataoptional_impl.h/optional_metadata.json—std::optional<T>header-first, incl. an optional over a declared record
Known Limitations
- The impl header parser is lightweight (regex + state machine). It does not handle:
- Multi-line method declarations
- Default parameter values
- Method definitions in the header (only declarations ending with
;) - Nested classes
- Template methods
std::functionmembers are silently skipped (never treated as methods)
- LIDL does not support generic/parameterized types or inheritance
--from-headeremits the cdylib backend here (theqtglue backend moved to logos-qt-generator); the Rust backend lives in logos-rust-sdk'slidl-gen, generating over logos-lidl's C ABI- Client stub generation (
lidlMakeHeader/lidlMakeSource) is only available from LIDL files, not from--from-header - Optionality is still untyped in positional slots on the legacy consumer path. The
consumer wrappers real modules get come from
legacy/main.cpp→generateInterfaceWrappers→lidl_to_json→generator_lib, and that JSON boundary carries a single Qt type-name string per slot. Record fields now carry anoptionalflag alongside the value type, so both LIDL spellings emit identical, typed code (QVarianton Qt,std::optional<T>on Lp). A method parameter, a return type and an event parameter still do not: they arrive asQVariant(Qt) /LogosMap(Lp) — the right shape (an invalid QVariant / a JSONnullis the empty inhabitant) with no type, so a caller gets no compile-time check and cannot tell?tstrfrom?uintor recover a?Record's struct. There is no spelling divergence there — a positional slot has no name to hang a flag on, so it only ever had the type-kind form — and closing it changes generated method signatures, i.e. a source break for every existing call site. Until then the generator prints aNote:naming every still-flattened slot, so an affected build is never silent.OptionalSpellings.PositionalSlotsAreStillFlattenedpins the current behaviour so closing the gap is a deliberate change. - Nesting, map key types and descriptions still do not cross that boundary either — the flag added for optionality is per-field, not a general widening.
lidlRecordCollidesWithBytesTagreads through an optional (viafieldValueType), so a single-_bytes-field record is refused under both spellings. It used to readf.type, which refused? _bytes: tstrand let_bytes: ?tstrthrough — the same declaration, two answers.?bstris unaffected either way: the tag lives in the value, not the slot.- A provider REJECTION reaches an async consumer callback only as a log line. A
provider that refuses a call answers the canonical
{"code":"dispatch_failed", "message":…, "origin":…}object as its RESULT, not as a transport error, and the Qt return table would convert it like any other value — erasing it (_result.toList()on that map is[]). The Qt consumer emitter therefore detects it and folds it into thelogos::CallErrorout-parameter the sync wrapper already carries, somod.echoUintList(v, &err)can tell a rejection from an empty return. The generated…Asyncoverload has no such channel — its callback isstd::function<void(T)>, and adding an error parameter would change the generated public surface (which logos-qt-sdk'sqt-generator --backend consumerveneer mirrors 1:1) — so an async rejection is reported withqWarningand the callback still receives the default-converted value. Giving async an error channel is an API change, not a code-generation fix.