mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 21:41:10 +00:00
`?T` had no C++ codec, so an optional slot could not cross the canonical JSON
wire at all: every spelling of "empty" landed on Codec<T>, which correctly
refuses null, and the value became a type error instead of an absence.
The contract this implements:
* TWO-state, never three. Every target has exactly ONE empty inhabitant (Rust
None, std::nullopt, an invalid QVariant, JS undefined), so "one LIDL type <->
one type per language" leaves nowhere to put a third state. std::nullopt is
that inhabitant.
* DECODE IS LIBERAL. Absent and explicit null are the SAME state coming in.
They cannot be told apart even in principle here — the record decoder
materialises a missing field as a null json (`j.contains(f) ? j.at(f) :
nlohmann::json()`) before a Codec ever sees it.
* ENCODE IS CANONICAL. Empty has one spelling out: null. A round trip
therefore CANONICALISES rather than reproducing its input.
* A PRESENT VALUE IS STILL TYPE-CHECKED. Optional widens the domain by exactly
one inhabitant; it does not switch checking off. Anything non-null goes
through Codec<T> unchanged and throws with the same path it would have in a
required slot. A required slot is untouched — null there still means "wrong
type", which is the only reason absent-means-empty is safe to allow here.
KEY OMISSION IS NOT IN THIS LAYER, and the comment says so at the definition.
Empty is spelled by omitting the key where the slot is NAMED (a record field)
and by null where it is POSITIONAL (argument, return, event parameter — no key
to omit, and arity must never change). A Codec is handed a VALUE and cannot see
the slot it sits in, so it emits the positional spelling; skipping the key for a
nullopt field belongs to the record emitter in logos-cpp-sdk, the only code that
knows there IS a key. It is also unimplementable one level down: an optional
inside a [T] must still occupy its array position.
Ten tests: absent, explicit null, present, present-but-wrong-typed (including
the path inside a container), null still rejected in a required slot, ?bstr
(tagged at depth, and present-but-EMPTY bytes staying present), ?[T] / ?{tstr:T}
separating `[]` from missing, [?T] / {tstr:?T} keeping position and key, and ??T
collapsing.
The tenth pins a trap rather than a feature: JsonArg cannot deliver an optional.
std::optional's converting constructor optional(U&&) binds an rvalue reference
to the proxy prvalue, which out-ranks JsonArg's const-qualified conversion
function before partial ordering is consulted, so the compiler decodes X instead
of std::optional<X> and null throws. Both alternatives were tried and measured:
an rvalue-qualified conversion operator ties with the constructor (ambiguity
error), and one written specifically for std::optional still loses. There is no
signature that wins, so optional parameters must NAME the type —
fromJson<std::optional<X>>(j, path), which is what the cdylib backend already
emits. A present value survives the proxy by accident, which is exactly why the
empty case is pinned.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>