6 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 988e0ba906 feat: per-client token store, the host-services C ABI, and a container shape-check (#59)
* feat: the host-services C ABI a trust-root module needs

capability_module is the last legacy Qt Q_INVOKABLE provider, and it cannot
become an ordinary `interface: universal` module while the two things it does
have no C entry point: reading the token store, and pushing a token to an
ARBITRARY target. This adds both, plus the grant that gates them. Purely
additive — no existing symbol changes behaviour.

  lp_token_keys()               the module names THIS image's TokenManager
                                holds. NULL means REFUSED, never "empty" — a
                                granted call with no tokens answers "[]", and a
                                known-caller gate needs to tell those apart.
  lp_inform_module_token_to()   routes to LogosAPIClient::informModuleToken_module,
                                the 5-arg form. Note the existing
                                lp_inform_module_token is the WRONG DIRECTION
                                for this: it reaches a consumer path that
                                hardcodes requestObject("capability_module"),
                                i.e. core -> capability, not capability ->
                                target. That 5-arg method had no C entry point.
  lp_grant_host_services()      sets the in-image grant over the closed set
                                {token_registry, token_delivery}. Replaces
                                rather than merges; NULL/""/"[]" clears. An
                                unknown name is rejected wholesale and leaves
                                the existing grant untouched, so a typo can
                                never silently drop a service.

Why the gate is per-IMAGE, which looks like an odd choice until it doesn't:
the host binary and a module's cdylib each link their own copy of this library,
so they have separate process-global state. A gate "simplified" into the host
would be checked against state the calling image can never set, and would read
as ungranted forever. The grant therefore crosses the module-impl C ABI the
same way the auth token already does — hence the logos_module_grant_host_services
declaration added to logos_module_impl.h, whose generated body and host-side
call land in logos-cpp-sdk and logos-module-loader-qt respectively.

MINOR 2 -> 3; MAJOR unchanged, so the equal-MAJOR compatibility rule is
unaffected. 387/387 tests pass, including 6 new ones covering both gates
closed, both opened, clearing re-closing them, and the unknown-name rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tokens): a per-CLIENT token store, selected by origin

TokenManager::instance() is the IMAGE's store, and in a host that loads
plugins in-process it is also an ambient ring: the host writes
`name -> that module's root auth token` for EVERY module it loads. On the
hot path a client asserts no identity at all — invokeRemoteMethod reads
the store first and only mints on a miss — so a plugin asking for target X
finds X's own root token sitting there and presents it. The provider
accepts any token in its image's store, so the call authorizes and no
requestModule is ever logged. Every plugin in that image holds every other
module's authority, and giving a plugin its own ORIGIN STRING changes none
of it, because origin was never consulted on the path taken.

This makes origin SELECT THE STORE rather than merely label the caller.

  TokenManager::forIdentity(x)      the store to present tokens from when I am x
  TokenManager::isolateIdentity(x)  give x a private store (host opt-in)
  isIsolated / isolatedIdentities / bootstrapKeys / seedBootstrapTokens /
  resetIdentity

ADDITIVE BY CONSTRUCTION, not by promise: forIdentity() returns the SAME
OBJECT instance() returns — pointer-identical — for every name until
someone isolates that exact name, so a host that knows nothing about this
is byte-for-byte unchanged. All seven are static member FUNCTIONS: no data
member, no virtual, nothing moc sees. Measured, not asserted: the exported
symbol table of liblogos_protocol.dylib gains exactly 12 names (7 statics +
5 lp_*) and LOSES NONE (736 -> 748). Neither ABI-sensitive private layout
(LogosAPIClient, LogosAPIConsumer) was touched at all.

Construction paths in this repo:
  * LogosAPIClient / LogosAPIConsumer: an explicit store still wins; a NULL
    store now resolves to forIdentity(origin) instead of being a guaranteed
    crash on the first getToken().
  * lp_client_create: &TokenManager::forIdentity(origin), not instance().
    This is the whole answer to that function's frozen signature — the store
    cannot be handed to it, so the origin it already takes must select it.

Bootstrap (constraint 4) survives because a private store is created seeded
with "core" and "capability_module" copied from instance(), and with
NOTHING else — the two keys the first requestModule authenticates with, not
a copy of the ring. resetIdentity() is the unload hook: it clears the
contents and re-seeds, while the store OBJECT stays immortal because a
client holds it by raw pointer from continuations that outlive their caller.

The trust root (constraint 3) is unaffected, and it is checked rather than
argued: lp_token_keys() still reads instance(), isolation only ADDS stores,
and the one thing that moves — an isolated identity's consumer-side CACHE
write — is keyed by TARGET while the known-caller gate consults ORIGIN
names, which the HOST writes and this change never touches.

C ABI grows five additive symbols, each carrying LP_API:
lp_token_isolate_identity, lp_token_identity_is_isolated, lp_token_get_for,
lp_token_save_for, lp_token_reset_identity. Protocol version 0.3.0 -> 0.4.0
(MINOR: additive).

Tests: 439/439 before, 469/469 after. The 30 new cases were validated as
DETECTORS the way this suite requires — against a throwaway build with
forIdentity()'s isolation branch neutered to `if (true)`, i.e. origin as a
label again. 15 go RED there (the walled identity holds the target's root
token; the handshake count is 0 instead of 2; lp_token_keys() lists the
identity's private mint), and the other 15 are pins of behaviour that must
be identical either way. Every escalation case carries an ambient CONTROL
asserting the token IS reachable without isolation.

Hosts are deliberately NOT changed here.

* feat(codec): shape-check the untyped containers

`[any]` and `{tstr:any}` both spell `nlohmann::json` in C++ — LogosList and
LogosMap are aliases of it — so no Codec<T> specialization can tell them apart
and fromJson<T> has nothing to dispatch on. Their SHAPE is still declared,
though, and array-ness / object-ness is the whole of the declared type at that
layer.

jsonRequireArray / jsonRequireObject check exactly that and hand the value on
UNCHANGED, throwing through the codec's own detail::typeError so the message is
the one every other surface already produces ("expected array at arg0, got
string"). The value is not rebuilt from JSON: that would retype nested elements
for no validation gain, which is the same reasoning logos_qt_arg_decode.h gives
for the Qt surface.

This is what logos_codec.h:36 already promised and these two types quietly did
not honour — "shape mismatches throw CodecError … rather than silently
substituting a default, silent defaults are how a mangled value reaches business
logic."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:58:56 -03:00
Dario LipicarandClaude Opus 5 72754ab9b2 feat(codec): Codec<std::optional<T>> — the ?T slot, two-state and canonicalising (#37)
`?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>
2026-07-31 01:05:28 -03:00
Dario LipicarandClaude Opus 5 43595575a3 feat(codec): thread the path through the bstr decoder (#33)
Prerequisite for deleting the codec copy that the cdylib generator emits.

That copy's Codec<std::vector<uint8_t>>::from reported a path ("[0].payload");
the canonical one discarded it and said "at value". Swapping one for the other
without this would have lost the diagnostic exactly where it matters most — a
bad bstr buried in a container.

bytesFromJsonLenient takes the path as a defaulted argument, so every existing
caller and every existing test compiles unchanged.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:40:11 -03:00
Dario LipicarandClaude Opus 5 3da8de93df fix(codec): a whole-valued float still decodes as an integer (#32)
The signedness/range check in #31 went one step too far: it rejected 3.0 for an
`int`, not just 3.7. That broke four long-standing test_basic_module_cpp cases
(`addInts(3.0, 4.0)`, `echoInt(42.0)`, `isPositive(5.0)`, `twoArgs(hi, 3.0)`)
which pass a whole-valued double where the contract declares an integer.

They are right and the check was wrong. JSON does not distinguish 3 from 3.0,
and this codec already says so in the other direction — Codec<double> accepts an
integral number because "2 and 2.0 are the same value to JSON, and every encoder
that sees a whole double may emit either". The two directions have to agree.

It also matters in practice rather than in principle: logoscore's CLI types its
arguments by parsing, so `logoscore call m addInts 3.0 4.0` produces JSON floats.
Refusing them rejects a caller over a spelling of the same number.

So a float decodes as an integer when it has no fractional part and fits;
3.7 is still refused, which is what the original change was actually for. Bounds
are strict on the upper end for the same reason as the QJsonValue guard:
double(int64max) rounds UP to 2^63, so `<=` would admit a value the cast cannot
represent.

verified: test-modules 176/176 with the four cases green again, and the
conformance matrix unchanged at 170 pass / 2 xfail — hostile/int/fractional
still expects dispatch_failed and gets it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:52:04 -03:00
Dario LipicarandClaude Opus 5 c0df466172 fix: integer signedness in the codec, and a shape check on the pending-call sentinel (#31)
* fix(codec): signedness and range are part of the integer type

Codec<T>::from accepted any integral JSON number and handed it to .get<T>().
That is silent in both directions:

  .get<uint64_t>() on -1   -> 18446744073709551615   (a sign flip)
  .get<int32_t>()  on 2^40 -> truncated

Both now reject with the usual path-carrying CodecError instead. Rejecting is the
codec's existing contract — a value the declared type cannot represent must not
reach business logic wearing a different one — this just extends it to the half
of the integer domain it was skipping.

Note the check is on the JSON category, not the value: a negative literal parses
as number_integer and never as number_unsigned, so `is_number_unsigned()` is the
reliable discriminator rather than a comparison after conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(async): the pending-call sentinel is matched by shape, not by key presence

All four detection sites tested `m.contains(pendingCallKey())` and nothing else,
so ANY user map carrying that key was taken for a deferred call: the consumer
extracted a call id, found no completion, and waited out a nested event loop.
The measured outcome is a ~20s HANG, not a fast failure. An `any` slot is enough
to reach it — anything a user can put in a map.

logos::isPendingCallSentinel now requires the canonical shape: exactly one entry,
under the sentinel key, holding a non-empty string. Shape and signature are
mirrored from isUnauthorizedSentinel (logos_rpc_status.h), QJsonObject arm
included — the two are the same kind of in-band marker and there was no reason
for them to be guarded differently. That guard, and isTaggedBytes's, both already
existed in this repo; the difference was chronology, not principle.

Behaviour-preserving: the generated glue builds this map with exactly one entry
whose value is a QString call id, so no real sender changes. The concurrent
dispatch tests pass unchanged.

NARROWS, DOES NOT CLOSE — and the tests say so out loud. A one-key, string-valued
forgery IS the sentinel; no predicate can separate them. It still hangs, and
because call ids are a per-object counter from 0, a forged "lc-0" can collide
with a genuine in-flight completion and steal its result. Closing that needs an
out-of-band channel for "deferred", which the single-QVariant dispatch slot
cannot express without an ABI break — the constraint is stated at
logos_rpc_status.h:24-27 and is real.

tests: 10 new, including one asserting the forgery still matches, so a future
reader cannot mistake the green cells for "the sentinel is safe". 236/236.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:50:18 -03:00
Dario LipicarandClaude Opus 5 362b03fb1e feat(codec): one canonical LIDL ↔ JSON codec, generic over composition (#29)
* feat(codec): one canonical LIDL <-> JSON codec, generic over composition

The tagged-bytes encoding {"_bytes": "<base64url, unpadded>"} was implemented
SIX times — the Qt conversion here, the plain wire's json_mapping, the lp helper
in logos-cpp-sdk, a copy emitted into every generated cdylib module, the Rust
SDK and the Python client — and they disagreed on which inputs they accept:

  - {"_bytes":"AA","x":1} decoded as BYTES on the lp path (no size()==1 check)
    but as a MAP on the plain wire and in the glue.
  - Padded "AH-A_w==" gave correct bytes in one copy, empty in another, None in
    Rust.
  - A plain string / number / number-array argument was accepted by C++
    providers (Qt and CLI parity) and rejected by Rust ones.

logos_codec.h is the single implementation. Leaves: tstr, bstr, every signed and
unsigned integral spelling, every floating spelling, bool, any (recursion stops).
Composition is GENERIC — std::vector<T> and std::map/unordered_map<std::string,T>
for any supported T, at any depth — so [bstr], [[bstr]], {tstr: [bstr]} and bytes
nested in a map all encode canonically without anything enumerating combinations.

Codec<T> is a trait, so an unsupported T is an incomplete type: a compile error
naming the type, never a silent fallback. Decode throws CodecError carrying the
path ("[0][1]", ".k") instead of substituting a default — a mangled value must
not reach business logic. bstr keeps a documented lenient form for provider-side
arguments, because the Qt consumer path and the logoscore CLI both produce plain
strings and number arrays for byte parameters.

JsonArg exists for generated dispatch: it converts itself into whatever the
callee's parameter type is. Naming the type instead is a trap — spelling [uint]
as std::vector<uint64_t> (the LIDL mapping) does not bind to an author's
std::vector<uint32_t>, since distinct vector instantiations do not convert.

logos_codec.h joins the installed header set; nix/include.nix already globs
cpp/*.h.

Tests: 198/198. 15 new ones pin the contract rather than the happy path —
[[bstr]] tagged at depth, map-of-bytes, empty elements surviving as elements,
uint64 past 2^63, an integral JSON number decoding as float64, padded base64,
the multi-key {"_bytes":...} case being a map, and path-carrying failures.

Not yet converged onto this header (follow-ups): the Qt conversion in
logos_json_convert.cpp, and the plain wire's copy in json_mapping.cpp — the
latter needs a strict variant first, because it THROWS on malformed base64
(via its own logos::plain::CodecError) where every other copy is tolerant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(codec): fold the Qt and plain-wire copies into the shared codec

The two remaining in-repo implementations now delegate:

  - logos_json_convert.cpp (the Qt CONSUMER path — argument encoding and return
    decoding) dropped Qt's toBase64/fromBase64 and its own tagged-bytes
    predicate. Only the QByteArray <-> std::vector<uint8_t> hop stays local, so
    the Qt path cannot drift from the wire or from providers: same alphabet, same
    padding rule, same single-key shape.
  - implementations/plain/json_mapping.cpp dropped its anonymous-namespace
    b64url_encode/decode.

The wire needed something the tolerant decode does not give it: it REJECTS a
corrupt frame rather than silently decoding fewer bytes. Hence
b64UrlDecodeChecked — strict about the alphabet and the length, tolerant of '='
padding — which json_mapping uses to keep throwing its own
logos::plain::CodecError. Consumer-facing decodes stay tolerant. Both behaviours
now come from one implementation instead of four that disagreed.

Also removed the local isTaggedBytes wrapper, which shadowed the shared one and
made unqualified calls ambiguous.

Tests: 199/199, with the strict decode's accept/reject set pinned (padding
tolerated, stray character rejected, impossible length rejected).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:01:49 -03:00