12 Commits
Author SHA1 Message Date
Dario Gabriel LipicarandClaude Opus 5 42460e5b2a fix(tokens): separate INBOUND from OUTBOUND, without moving a single byte
A grant one way was a grant both ways. TokenManager was ONE flat map with no
direction tag, written from both sides of every relationship: LogosAPIClient
stored the token it will PRESENT to a callee under the CALLEE's name, and a
token RECEIVED from a caller was stored under the CALLER's name. Same key
namespace, last write wins.

Measured on the shipped fleet with two ordinary modules doing nothing unusual:
one grant A -> B leaves the SAME token value under both opposite-meaning keys,
and the never-granted B -> A call then succeeds. Silently.

  A.callOther(B, ping)   CALL_OK
  T1 A holds token for B?   val=7685c776-...
  T1 B holds token for A?   val=7685c776-...      <-- one value, two meanings
  B.callOther(A, ping)   CALL_OK                  <-- never granted

WHY THE LAYOUT COULD NOT CHANGE. TokenManager's layout is a cross-package ABI:
the host ALLOCATES the object and module/UI-plugin images MUTATE it through
their own statically-linked accessors — and host and modules ship as separate
.lgx that mix versions at runtime by design. The header's ABI-safety note is
about ALLOCATION ("no consumer allocates one, none needs sizeof"); the hazard
is MUTATION.

Splitting into three members took sizeof 32 -> 64 and moved m_mutex 24 -> 56.
QMutex::fastTryLock() compare-exchanges at this+24, which in that layout is
m_inbound's QHash d-pointer. Empty, the old code silently borrows the hash's
pointer slot as a mutex and puts it back, so it LOOKS fine; non-empty, the
exchange fails and lockInternal() interprets the QHash Data* as a
QMutexPrivate* and futex-waits on it — hung forever, inside a token-store
write, on the module host's Qt main thread. No crash, no log line, no timeout
that recovers. Reproduced by calling the shipped 0.6 plugin's own saveToken on
a 0.7 object: exit=124.

So direction lives in the KEY NAMESPACE instead. Outbound is the bare peer name
(byte-identical to master); inbound is "\x01in\x01" + caller. m_tokens@16,
m_mutex@24, sizeof 32 — measured identical to master in every shipped image,
pinned by a static_assert against a reference struct that fires if a member is
added.

Two things a key namespace forces that separate members did not: every door
REFUSES a key carrying the namespace character, or a wire-supplied caller name
could forge across the direction boundary; and credential() is DERIVED from
bootstrapKeys() rather than cached, because a cached field reads empty on a
store another image wrote and then refuses every push.

AN ANCHOR KEY IS NO LONGER SPELLED AS A MODULE NAME. scanIssuedTokens' m_tokens
loop offered every matched key unconditionally while the m_store loop
deliberately never offers, so "an anchor must never name a caller" was enforced
on one side only. A module announcing itself as "core" — which logos-rust-sdk
did unprompted — therefore authorized as kind:module name:core. The rule
generalises: a store may only name a caller with a key it alone can write.
Implemented as a masked operand, so the comparison count is unchanged;
RefusingToNameAnAnchorKeyCostsNoComparison pins that via
logos::tokenComparisonCount().

lp_token_save / lp_token_save_for now return LP_ERR_INVALID_ARG on a reserved
key instead of LP_OK. Only the return code was wrong; saveToken already refused.

PROTOCOL 0.8: logos_module_accept_inbound_token joins the module-impl C ABI
(12 exports). onInit keeps logos_module_accept_token for the module's own
anchor — that one IS outbound, and merging the two paths is what reintroduces
the bug.

Supersedes the field-split approach; the semantics are unchanged from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:16:33 -03:00
Dario Gabriel LipicarandClaude Opus 5 b37a2e9f1c feat(tokens): a private store is created EMPTY, not seeded with the host anchor
TokenManager::forIdentity seeded every new private store by COPYING the
host's tokens for bootstrapKeys() = {core, capability_module}. Those values
are the HOST's, so an isolated in-process consumer presented basecamp's
anchor and ModuleProxy::resolveCaller answered HostAnchor: a sandboxed view
wearing the host's authority.

Half of that was LIVE, not latent. informModuleToken's trusted-channel gate
compares against the SAME two keys, so anything holding an isolated
LogosAPI* could read getToken("capability_module") and call
informModuleToken on capability_module — three public calls, no glue — and
write into the map that is both its known-caller gate and its moduleToken
source. Reading a caller needs generated glue; writing one did not.

The copy could not simply be deleted. Measured: removing it alone turns 5
of 495 protocol tests red, and two are behavioural — an isolated identity
cannot reach capability_module.requestModule (it dies at ModuleProxy's
`authToken.isEmpty()`), and an isolated PROVIDER can never be told about a
caller. Isolation without a credential is a lockout.

The credential already existed and was being thrown away. All five host
registration sites minted a per-spawn UUID, registered it with
capability_module, and then dropped it: the identity was registered under a
token nobody held, and it worked only because the store presented the
copied anchor. The anchor copy was masking that at every site, which is why
neither could be fixed alone.

So: a private store starts empty, and an identity's store carries THAT
IDENTITY's own host-issued credential under the bootstrap keys —
adoptCredentialFor, which refuses the host anchor by construction. This is
not a new rule. ui-host already does exactly it for the out-of-process half
(saveToken(core/capability_module, its own authToken)), and
LogosAPIProvider::seedHandshakeTrustAnchor does it for a module image. The
in-process private store was the only store in the system seeded with
somebody else's credential.

`core` is not part of it for a CONSUMER: every reader of a store's "core"
entry is provider-side, and in a real host instance() has no "core" key at
all — the host ring is written only under module names, and no module is
named core.

Closing the elevation also makes the consumer NAMEABLE in the same change:
it now resolves as {"kind":"module","name":<identity>} at capability_module
and at ordinary modules, instead of {"kind":"host"}.

NOTE FOR CONSUMERS OF THE C ABI: lp_token_reset_identity changed meaning on
an existing exported symbol — it no longer re-seeds, so an out-of-tree
caller that reset and kept going is now locked out. No in-workspace caller
exists; carried by the MINOR bump to 0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:17:00 -03:00
Dario LipicarandClaude Opus 5 03842db5c1 feat(windows): named pipes, an explicit lp_* ABI, and a cross target (#58)
* feat(windows): port logos_socket_paths and add a cross target

logos_socket_paths.cpp is the only POSIX-bound file in logos-protocol. All of
it is unix-domain-socket machinery, and on Windows the local transport is named
pipes (QLocalServer maps a name to \\.\pipe\<name>), where none of the
assumptions hold: a pipe has no inode to lstat/chown/chmod -- access comes from
a security descriptor set at CreateNamedPipe time -- and a pipe cannot outlive
its last handle, so a hard-killed process leaves nothing behind.

isSocketDead and reapStaleSockets are therefore not merely unimplemented on
Windows, they are vacuous: the state they detect cannot arise. Both return the
fail-closed answer (false / 0), matching the documented contract that an
endpoint is never reported dead unless certain.

applySocketPerms deliberately does NOT no-op. With no policy requested it
returns true, as on POSIX. But when LOGOS_SOCKET_GROUP or LOGOS_SOCKET_MODE
*are* set it fails with an explanatory error, because silently returning true
would leave the endpoint more permissive than the operator asked for -- the one
direction this file is careful never to go (cf. the chgrp-then-chmod ordering
in the POSIX branch). Granting a pipe to a group needs a DACL plus a
group->SID resolver; until that exists, refuse loudly.

Also gates qt6.wrapQtAppsNoGuiHook behind !isWindows and sets dontWrapQtApps.
Both halves are required: the hook does not even evaluate for a mingw host, it
would be inert anyway (wrap-qt-apps-hook.sh skips anything that is not ELF or
Mach-O), and qtbase's setup hook hard-errors in qtPreHook unless
dontWrapQtApps is set.

Header contract updated per function. POSIX branch unchanged and still compiles.

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

* fix: make the Boost.System component optional, not required

find_package(Boost REQUIRED COMPONENTS system) hard-fails on Boost 1.89:

    Could not find a package configuration file provided by "boost_system"

Boost.System has been header-only for years, and 1.89 finally dropped the
compiled boost_system library, so no boost_systemConfig.cmake is installed at
all. The COMPONENTS request was not gratuitous though -- on 1.87 the
Boost::system imported target is only exported when the component is asked
for, which is what the previous comment recorded.

So ask optionally and fall back to Boost::headers, which supplies the same
header-only error_code either way. The choice is by BOOST VERSION, not by
platform: this is not a Windows quirk, it simply surfaced first there because
the Windows target pins a newer nixpkgs (Boost 1.89) than the native one
(Boost 1.87).

Verified both ways -- native aarch64-darwin still selects Boost::system:
    -- Boost.System target: Boost::system (Boost 1.87.0)
and the build completes unchanged.

Also adds QT_HOST_PATH / QT_ADDITIONAL_HOST_PACKAGES_PREFIX_PATH for the
Windows target. Qt6RemoteObjectsDependencies.cmake declares
    set(__qt_RemoteObjects_tool_deps "Qt6RemoteObjectsTools;6.11.1")
and Qt6RemoteObjectsTools holds repc, which must RUN on the build machine --
so under cross it lives in the build-platform Qt, not the mingw one. Without
these, find_package reports the thoroughly misleading "Expected Config file at
<qtbase>/lib/cmake/Qt6RemoteObjects ... does NOT exist": the TARGET config is
found fine; it is the HOST tool package that is missing. Every Qt-consuming
repo will need this, so it should be hoisted into logos-nix rather than
repeated.

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

* refactor: declare the lp_* C ABI explicitly instead of relying on auto-export

Adds LP_API (__declspec(dllexport) when building the shared library, default
visibility elsewhere) to the 21 lp_* entry points, and defines
LOGOS_PROTOCOL_BUILDING_SHARED for the shared target only, so the static
archive leaves LP_API empty and its consumers need no import library.

This is NOT a bug fix, contrary to what the concern in the Windows plan
suggested. Measured on the cross-built DLL, before and after:

    before:  export table 0x2ece (11982 symbols), lp_* present: 21
    after:   export table 0x15   (   21 symbols), lp_* present: 21

GNU ld's PE auto-export was already exporting lp_* -- along with roughly
twelve thousand other symbols. The worry was that logos_module_impl.h's
__declspec(dllexport) would disable auto-export image-wide and silently drop
lp_*; it does not, because no translation unit in logos_protocol includes that
header (it is listed in PROTOCOL_SOURCES for IDE visibility only).

What this does buy is worth having anyway: the exported surface is now the ABI
we actually declare rather than whatever happens to have external linkage, it
stops being contingent on auto-export staying enabled -- which the very next
TU to gain a dllexport would silently end -- and it drops ~12k incidental
symbols from the export table.

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

* fix: relax the Boost.System requirement in the EXPORTED cmake config too

The previous commit fixed cpp/CMakeLists.txt but left
logos-protocolConfig.cmake.in still doing

    find_dependency(Boost REQUIRED COMPONENTS system)

so logos-protocol itself built fine on Boost 1.89 while every CONSUMER of its
installed CMake package failed at configure time -- caught by logos-qt-sdk,
which is the first downstream repo to be cross-built.

Worth noting as a general trap: a package can be internally consistent and
still ship a broken contract, because the exported config is a separate
artifact from the build. Anything changed in one has to be checked in the
other.

Verified both directions: the Windows cross builds of logos-cpp-sdk and
logos-qt-sdk now succeed, and a native aarch64-darwin logos-qt-sdk build --
which consumes this same config against Boost 1.87 -- still succeeds.

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

* feat(windows): mark the types that must exist once per process

PE has no symbol interposition. ELF and Mach-O interpose across the whole
image set, so when liblogos_core exports TokenManager::instance() every
other image binds to that one definition and the function-local
`static TokenManager instance;` is genuinely a singleton. On Windows
every image that links liblogos_protocol.a / liblogos_qt_sdk.a statically
gets its own copy of the code and therefore its own statics -- measured:
NINE images in the Basecamp payload each defined
TokenManager::instance()::instance. The host saved a capability token
into its copy, the UI plugin read its own empty copy, and every
cross-module call was refused (29 "ModuleProxy: rejecting unauthorized
call").

LOGOS_SHARED_API marks the affected types. It expands to
__declspec(dllimport) only for a consumer that opts in with
LOGOS_SHARED_USE_DLL, and to nothing everywhere else -- off Windows, and
inside logos-protocol/logos-qt-sdk/liblogos_core themselves, so the
static archives compile byte-identically to before.

The dllimport is the load-bearing half, not the export: it rewrites the
reference to go through __imp_, so the plain symbol is never undefined
and GNU ld never pulls the archive member that would redefine it. Without
it the link still succeeds, binds to the archive, and gives no diagnostic
at all.

logos_shared_api.h records both wrong answers -- export everything
(collides with the static archive over LogosAPI) and export nothing
(today's silent per-image statics) -- so neither gets reinvented.

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

* fix(windows): let checks and devShells take the arg forAllSystems now passes

The cross-target commit added `inherit system;` to forAllSystems so the Windows
arm could tell which target it was building, but left `checks` and `devShells`
on the strict `({ pkgs }: ...)` pattern.  A Nix attrset pattern without `...` is
exact, so both stopped evaluating:

    error: function 'anonymous lambda' called with unexpected argument 'system'

on EVERY platform, not just Windows -- `nix flake check` and `ws develop
logos-protocol` are dead on this branch while they work on master.  `packages`
was unaffected because it goes through forAllTargets, which is why nothing
caught it.

Measured, same worktree, before and after:
  before: checks.aarch64-darwin -> the error above at flake.nix:52
  after:  checks.aarch64-darwin -> [ "tests" ]
          devShells.aarch64-darwin.default.name -> "nix-shell"
          packages -> [ aarch64-darwin aarch64-linux x86_64-darwin x86_64-linux
                        x86_64-windows ]

* chore(deps): re-pin logos-nix to the merged Windows overlay

The cross overlay landed in logos-nix#2.  This branch was locked to a
pre-merge rev, which has no `lib.forAllTargets` and no `lib.mkWindowsPkgs`,
so it could not evaluate standalone -- only against the unmerged branch.

Level 2 of the Windows chain; L1 (logos-nix) is merged.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:44:25 -03:00
Dario LipicarandClaude Opus 5 0183e8c26a fix: make the caller's timeout bound the token handshake, not just the call (#55)
* test(token): pin that the caller's budget bounds the handshake, not just the call

Written first and on its own commit so the red is on the record: against this
parent it fails at ~20s, because the capability handshake ignores the caller's
budget entirely.

LogosAPIClient::invokeRemoteMethod takes a Timeout, but on an un-tokened target
the handshake runs FIRST and LogosAPIConsumer::requestModule hardcodes 20000
twice -- once for the capability_module acquire, once for the requestModule call
on it. A caller asking for 1500ms could therefore block on the order of 40s
before the part it had actually bounded began. logos-view-module-runtime's
callModule advertises a 1500ms bound on precisely this path.

capability_module is deliberately NOT published, so the acquire runs its budget
out rather than succeeding. Every other test in this file publishes it, which is
how a hardcoded 20s survived alongside them: none of them ever entered the wait.

The assertion is two-sided on purpose. An upper bound alone would pass if
something made the acquire return instantly -- leaving the hardcoded 20s in
place and the test green for the wrong reason, which is the exact shape of two
earlier tests in this change set that passed in both directions. So:

  >= budget-200ms  proves the timeout path actually ran;
  <  4x budget     proves it was the CALLER's budget and not the 20s default.

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

* fix: make the caller's timeout bound the token handshake, not just the call

requestModule gains a timeoutMs parameter (defaulted to today's 20000, so every
existing caller is source-compatible and unchanged), and LogosAPIClient threads
its caller's Timeout through mintAndCacheToken into it.

The budget bounds the WHOLE handshake -- the capability_module acquire plus the
requestModule call on it share one deadline, rather than each getting a fresh
copy. Halving it would be arbitrary; giving each the full amount would make the
worst case twice what the caller asked for. What is left after the acquire is
never allowed to reach 0, because some transports read 0 as "no timeout" and an
exhausted budget must not silently become an unbounded wait.

Also corrects a comment that argued the handshake-refusal fallthrough was safe
because "capability_module passes 3000 ms". It does not: capability_module
reaches informModuleToken_module through its FOUR-argument overload
(capability_module_plugin.cpp:112), so timeoutMs takes the header's 20 s
default. The bound is real, it is just not short -- and the code should say the
true thing about why it is safe.

Not covered here: the ASYNC first-call path still acquires capability_module
through invokeRemoteMethodAsync without threading a budget. It does not block
the caller, so it is a different defect with a different fix.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:12:23 -03:00
Dario LipicarandClaude Opus 5 dcc0f73d1c feat: hold a CALL until its module is reachable (whenObjectAvailable) (#53)
* feat: add whenObjectAvailable(), the call-path counterpart of onEventWhenAvailable()

Event subscribers got a way to ask "tell me when this module is reachable"
without blocking and without giving up on the first no. Callers had no such
thing, so a caller facing the same startup race had only two bad options: fail
fast, which strands a UI that will never retry, or call straight through and sit
in the transport's acquire timeout on whatever thread it was called from — the
GUI thread, in practice.

whenObjectAvailable() reuses the pending-subscription registry that already
exists, as a readiness-only entry: it attaches no subscription, fires its
callback exactly once, and is then forgotten rather than being re-armed on
reconnect, because a one-shot readiness answer that arrives twice is not an
answer. It shares the registry's timer, backoff and diagnostics, so it costs
nothing new on qt_remote and shows up in pendingSubscriptions() as
"<object>::(readiness)" while it waits.

Its first consumer is LogosQmlBridge::callModuleAsync, which can now hold a call
issued before its module exists and dispatch it when the module appears.

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

* test(events): pin whenObjectAvailable on every transport

It shares the pending registry with event subscriptions but answers a different
question, and it had coverage only end-to-end through the QML bridge. Three
cases x 6 params: fires true for a module that is already up; holds rather than
answering "not reachable" for one that is merely not up YET, then fires exactly
once when it appears; and is NOT resurrected by a reconnect.

That last one is the asymmetry worth pinning. Event subscriptions ARE re-armed
across a reconnect on purpose; a readiness answer already delivered is spent,
and re-firing it would re-dispatch whatever call it was gating.

Suite 356 -> 374.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:09:25 -03:00
Dario LipicarandClaude Opus 5 07b0fb1c64 fix: make event subscriptions survive a module that is not reachable yet (#47)
* fix: make isConnected() mean connected, and stop the log claiming it

QRemoteObjectNode::connectToNode() returns false only when the URL
SCHEME is unregistered -- it never contacts the peer. Our registry URLs
are COMPUTED rather than discovered (logos_instance.h:
local:logos_<module>_<instanceId>), so they are identical whether or not
the module exists. Latching m_connected from that return therefore made
isConnected() answer "yes" for modules that were never loaded, which made
every `if (!client->isConnected()) return;` guard in the codebase DEAD
CODE.

Callers then paid a 20 s waitForSource per call, twice over, because the
token handshake tries capability_module first. Measured in Basecamp with
package_manager absent: ~417 s of blocked GUI thread on macOS and 361 s
on Linux before the window appeared, and over 900 s under load. Not a
Windows bug -- the Windows port merely exposed it.

isConnected() now also requires a listener at the endpoint. For `local:`
that is a direct socket / named-pipe probe, which costs microseconds
precisely in the case that used to cost 20 seconds; any other scheme
keeps its previous behaviour.

Two logging changes, because the diagnostics cost more than the defect:
"Successfully connected to registry" asserted a connection that often did
not exist and sent three separate investigations to the wrong place -- it
now says a connect attempt started and makes no claim about the peer.
And requestObject warns BEFORE a doomed wait instead of going silent for
20 s and then reporting failure.

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

* fix: let event subscriptions survive a module that is not reachable yet

requestObject() answers "is the module there RIGHT NOW", and every
subscriber in this codebase asks at the one moment the answer is no: a
module's init(), a UI backend's onContextReady(), a QML view's
Component.onCompleted. All of those run while the dependency's host
process has been spawned but has not called listen() yet. The subscriber
then gave up permanently -- lp_subscribe returned nullptr with no log at
all, and callers turned that into a `false` the documented example
discards. Method calls kept working through the same window because
acquireCachedObject() reaches the replica by a path that never asks, so
the symptom was "events are broken", not "the subscription never
happened".

1238316 (isConnected() means connected) is what made this deterministic
rather than lucky, and it must not be reverted -- it removed ~417 s
(macOS) / 361 s (Linux) of blocked GUI thread at Basecamp startup. So
the subscription becomes deferrable instead.

  - LogosTransportAsyncAcquire: a sibling interface (dynamic_cast, like
    LogosObjectErrorChannel) so LogosTransportConnection's installed
    vtable is unchanged. requestObjectWhenAvailable() registers interest
    and returns; it never blocks and never spins a nested event loop.
  - qt_remote implements it by acquiring a dynamic replica before the
    peer exists -- legal, free, and armed by the node's existing 250 ms
    reconnect loop, so it adds no polling. Delivery is deferred one
    event-loop turn because stateChanged fires from inside onClientRead
    (the refresh_balances re-entrancy SIGSEGV).
  - LogosAPIConsumer::onEventWhenAvailable() holds the pending
    subscriptions, arms them when the object appears, shares ONE handle
    per object (separate from the call cache, so a call re-acquiring a
    stale handle cannot silently kill a live subscription), and re-arms
    them after reconnect(). Unbounded in time on purpose -- a module can
    be installed mid-session -- but bounded in noise: one warning at 3 s,
    one at 60 s, a log line when it arms, and a loud abandon when the
    transport proves it impossible.
  - lp_subscribe routes through it, which fixes the same defect for
    every C++/Nim/Rust module and UI backend without touching qt-sdk or
    any generated code.

tests/protocol/test_deferred_subscription.cpp pins all three layers,
each with a published-first control so a red case cannot be a mis-wired
fixture.

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

* fix: close the remaining silent-failure holes in deferred event subscriptions

The deferred-subscription registry from the previous commit fixed the reported
defect, but review found six ways it could still lose a subscription without
saying so — five in the registry itself, one in the plain transport's host — and
every one of them lived in a cell with no test. All of its tests ran in Remote
mode; three of the four transports had none at all.

Registry (cpp/logos_api_consumer.cpp):

* An already-present module was deferred to the first 250 ms tick on every
  transport without a deferred acquire, and every event emitted in that window
  was dropped. lp_subscribe used to attach synchronously and deliver them, so
  this relocated the silent event loss rather than removing it. startAcquire()
  now reports which of three answers the transport gave, and only an
  Unsupported answer takes the one synchronous requestObject() — which is also
  what keeps that call structurally away from qt_remote, whose requestObject()
  enters waitForSource()'s nested event loop even at timeout 0. Previously that
  invariant lived in a comment, and tick() could reach it whenever
  acquireDynamic() returned null.

* reconnected() put every armed subscription back in the pending set but never
  restarted the timer, which takeMatching() had stopped when they armed. Since
  tick() is the sole driver of both the retry and the watchdog, a reconnect left
  the subscription dead AND silent — quieter than the "not connected" warning it
  replaced.

* armAgainst() released a stale handle while entries were still attached to its
  event helper. Those entries stayed in m_armed, never fired again, and reported
  as healthy. They are now revived and re-armed against the new handle.

* The retry timer ran forever at the 5 s cap with nothing to do. It now stops
  once every pending entry has an acquire in flight and has said everything it
  will say, and restarts when that changes.

* A cancelled subscription had no way to leave the registry, so lp_unsubscribe
  left it holding the timer up and warning about a subscription nobody wanted.
  onEventWhenAvailable() now returns an id; cancelEventSubscription() and
  eventSubscriptionState() are its counterparts, and lp_unsubscribe uses them.

Plain transport (cpp/implementations/plain/plain_transport_host.cpp):

* onSubscribe() dropped a Subscribe for an object that was not published YET —
  which is exactly when consumers subscribe — and the consumer could not know,
  because requestObject() had already succeeded. Publishing also overwrote the
  sink table wholesale, so a republish took every subscriber down with it. The
  sinks now live in a table keyed independently of publication.

Also adds lp_pending_subscriptions() to the C ABI. The Qt consumer has had this
visibility all along and the C ABI had none, which is why a subscription that
silently never armed was undetectable from Rust, Nim or a universal C++ module.

tests/protocol/test_event_delivery_matrix.cpp pins the product rather than a
sample of it: 3 transports x 2 provider kinds (Qt-native and universal/std, which
reach the wire by different conversions) x 2 consumer paths (onEventWhenAvailable
and lp_subscribe) x 6 timings, plus mock and the non-blocking guard. Every
delivery case has a control that is green independently of these fixes.

One thing that is NOT fixed and is now stated in the contract: arming is not
retroactive and no transport buffers, so a module that emits a one-shot "ready"
event synchronously inside its own init() can still be missed. That window is
inherent to the transport — the blocking requestObject() this replaced had it
too — but "subscriptions survive a late module" is not "no event can be missed".

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

* docs: name the QtRO invariant the stale-handle revive rests on

* test(events): state what the non-blocking guard can and cannot catch

The acquireCount assertion catches a retry that polls qt_remote's blocking
requestObject() in the ordinary case. It cannot reach the narrow one -- the
poll is only reachable when the transport declines a deferred acquire while
still reporting connected, which needs acquireDynamic() to return null and is
not forcible from outside. That case is held shut by control flow instead, and
saying so is better than leaving a reader to assume the test covers it.

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

* fix: make the async-acquire contract and lp_subscribe's return honest

Both from review on #47, both real.

The LogosTransportAsyncAcquire contract promised that a true return means
onReady "WILL be invoked exactly once". It will not: RemoteTransportConnection
parents every in-flight PendingAcquire to m_pendingAcquires, which is reset at
the top of the destructor and rebuilt on reconnect, so an accepted request is
cancelled silently with no callback whenever the connection it belongs to goes
away. The contract now says AT MOST once, names both cancellation triggers, and
states what a caller has to do about them — re-issue after a reconnect, or carry
its own deadline. It also records that the layer above already does the first,
which is why a subscription made through onEventWhenAvailable() survives
something the raw transport call does not. That asymmetry is the reason to
prefer the consumer API, and it was previously implicit.

lp_subscribe returned a non-null lp_subscription even when onEventWhenAvailable
refused and returned 0, leaving the caller with a handle that can never fire
while the ABI documents NULL as the one signal that the arguments were refused.
It now checks sub->id and returns nullptr.

That second one is defensive rather than a live bug, and the code says so: the
guard at the top of lp_subscribe already rejects an empty event name and a null
callback, and lp_client_create rejects an empty target, so the three inputs that
make onEventWhenAvailable() return 0 cannot all arrive there today. No test
drives it. The two contracts simply have to agree, and one of them changing is
how they would stop agreeing.

374/374 green.

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

* fix: stop lp_unsubscribe deadlocking, without dereferencing a freed client

lp_unsubscribe took ownerGuard->mutex and, while holding it, called
cancelEventSubscription(), which marshals to the owner thread with a BLOCKING
queued connection. The delivery callback lp_subscribe installs runs ON that
thread and takes subGuard->mutex then clientGuard->mutex — and clientGuard IS
ownerGuard, both assigned from client->guard. Lock-order inversion. It also hung
outright once the owner's event loop had stopped, which is exactly when a
language binding drops its subscription handle.

The first attempt at this dropped the guard entirely and checked `alive` inside
the posted lambda. That was a use-after-free: QMetaObject::invokeMethod
dereferences the target (it reads object->thread()) before the lambda can run,
and lp_client_destroy sets alive=false and deletes the client synchronously —
so the check was unreachable on the exact ordering lp_subscription's own comment
documents as supported. Proven rather than argued: with MallocScribble=1, a test
that destroys the client before unsubscribing segfaulted 6/6 with the guard
removed and passed 6/6 with it restored.

So the guard is held across the POST and not across the cancel. Both halves are
load-bearing, and the distinction is the whole fix: posting never waits on the
owner thread, so holding the mutex across it cannot invert; only the blocking
marshal ever had to move.

Consequence, now stated in the ABI header: un-registration is EVENTUAL. The
callback-will-not-fire guarantee stays synchronous and unconditional, but
lp_pending_subscriptions() may still list a just-cancelled subscription until the
owner thread runs, and if the client is destroyed first the cancellation never
runs at all — correct, since the registry died with it. The matrix test now
pumps for the drain instead of asserting it happened synchronously.

374/374 green.

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

* fix: arm a subscription immediately when the module is already reachable

Deferral introduced a narrower version of the loss it removed. The common
consumer shape is a call followed by a subscription in the same function --
wallet-ui's backend calls get_chains() and subscribes on the next line, the
tutorial's C++ UI backend does the same. Before deferral the generated Qt
wrapper acquired synchronously, so the subscription was live before on()
returned and an event emitted straight after was delivered. Holding it until the
next event-loop turn silently drops that event.

Measured on the generated-wrapper harness: 1/1 delivered pre-migration, 0/1
after, over 3 runs.

LogosTransportAsyncAcquire gains tryAcquireNow(): hand back a handle ONLY if
that costs nothing -- for qt_remote, a replica that is already Valid, which is
exactly the state a prior call leaves behind since QtRO shares one replica
implementation per object name on a node. It must never block, never spin a
nested event loop and never wait on a peer; "not immediately available" is an
answer and the caller falls back to the deferred path. Default returns nullptr,
so a transport that cannot answer cheaply simply does not.

Delivering inline here is safe for the reason the never-synchronous rule exists:
that rule protects against re-entering the transport's READ stack from a
stateChanged callback. tryAcquireNow runs on the subscriber's own stack.

The new matrix case fires ONCE, synchronously, with no pumping in between --
re-firing would hide the exact gap under test -- and states the transport
difference rather than papering over it. Subscription registration is local on
qt_remote (attach to a held replica) and qt_local (connect an in-process
signal), so delivery there must be instant. On plain it is a wire frame to the
host, so instant delivery was never on offer and never was before this change
either; that leg asserts it still arms and delivers.

Also de-flaked EventDeliveryNonBlocking: its heartbeat COUNT over a fixed
wall-clock window measures the machine, not the code. The gap assertion is the
one that means something; the count is now only a floor.

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

* fix: stop tryAcquireNow leaving a dangling facade in QtRO's connect list

e9f82ac introduced a use-after-free. tryAcquireNow() acquired a dynamic replica
and, when it was not already Valid, deleted it. That is not safe: QtRO shares one
replica IMPLEMENTATION per object name per node, and while that implementation is
still waiting for the source's metaobject it records every facade built on it as a
RAW pointer in QConnectedReplicaImplementation::m_parentsNeedingConnect.
~QRemoteObjectReplica is an empty body, so destroying a facade never deregisters
it, and the implementation dereferences the whole list when the class definition
arrives.

So each probe of an unreachable module left one dangling pointer behind.

WHY IT HID. The first probe owns the only implementation and takes it down with
itself, so a single subscription is harmless. It needs a second subscription whose
implementation is pinned by an in-flight PendingAcquire before a freed facade can
outlive its implementation. A consumer subscribing once sees nothing; the QML
plugin shape -- a view registering every event it cares about up front -- dies.

REPRODUCED, 4 runs of 4, serially as well as in parallel, in
logos-view-module-runtime's existing suite (unchanged from master, and green there
against this same protocol checkout):

  LogosQmlBridge: subscription accepted for "echo_module" :: "ev13"
  Received signal 10 (SIGBUS), code 1, for address 0x5a

SIGBUS code 1 is BUS_ADRALN -- a misaligned atomic access on a garbage base read
out of a recycled heap block, in the event loop rather than at the call site,
which is why it reads as a mystery crash rather than as a subscription bug.

PROVEN, before writing this fix, by commenting out that single `delete replica`:
the same suite went 4 failures -> 6/6 with no other change. With this fix: 6/6.

THE FIX IS TO PARK, NOT TO FREE. One probe per object name, parented to
m_pendingAcquires -- which both the destructor and reconnect() already destroy
BEFORE the node, so the implementations die in the same breath and freeing them
there is safe. Ownership transfers out only when the replica reaches Valid, by
which point the implementation is configured and is no longer holding the facade.
It costs one idle replica per name until it goes Valid or the connection dies.

AND REMOVE THE MULTIPLIER: beginAcquire() probed on EVERY add(), ahead of
startAcquire() and therefore ahead of the m_acquiring one-acquire-per-object
guard. tick() already applies that filter; beginAcquire() was the one caller that
did not, which is what turned one probe per module into one per subscription.
While an acquire is in flight its PendingAcquire already holds a replica and will
arm every waiting entry at once, so the probe buys nothing there.

Not QML-specific: lp_subscribe reaches the same entry point.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:42:50 -03:00
Dario LipicarandClaude Opus 5 c1b0a0f554 fix(startup): publish a token-only handshake surface before a module initializes (#42)
* fix(startup): publish a token-only handshake surface before a module initializes

A module's initializer is synchronous and routinely calls out — a Qt module's
initLogos, a cdylib's context-ready hook — including capability_module's
requestModule, which capability answers by pushing a token back to that same
module. The module's business object is published only once the initializer
returns, so that push had nothing to reach: capability waited for a source that
could not appear until the initializer returned, and the initializer could not
return until capability answered. On Linux this wedged UI startup until the
standalone app's 10s ui-host deadline expired and the view never rendered.

Adds a second, deliberately tiny surface — ModuleHandshakeProxy, published
under logos::handshakeObjectName(name) — carrying informModuleToken and nothing
else. It forwards to the ModuleProxy that owns the token store, so a grant
delivered early is the one the business object honours later, with the same
authorization.

The business object's publish timing is UNCHANGED, which is the point: a caller
of a real method still blocks at acquire until the module is genuinely ready,
exactly as it always has. An earlier attempt published the business object early
and refused calls during init; that quietly turned a call that used to wait and
succeed into one that returned empty, which old consumers cannot even detect.

informModuleToken_module now tries the handshake surface first (short probe) and
falls back to the business object, so modules built before this surface existed
are reached exactly as they are today. It also reuses the cached handle instead
of acquiring a fresh replica per grant, and takes a timeout (default unchanged).

No wire change, no ABI change, no reply-shape change.

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

* fix(startup): do not treat a handshake refusal as the final answer

The handshake surface is published before the target's initializer runs, so a
target whose token store is seeded BY that initializer refuses a push that
arrives first. Returning that refusal to the caller handed it an empty grant it
could not distinguish from a real denial: measured on Linux, the first
requestModule for wallet_backend_module came back empty in 29 of 34 runs, and
never once in the pre-surface baseline.

Fall through to the business object instead, which is what the caller got before
this surface existed. The business object is published only once the initializer
has returned, by which point the store is populated. The wait is bounded by the
caller's own budget -- capability_module passes 3000 ms, not the 20 s default
that made the original deadlock fatal -- so this cannot reintroduce the wedge.

The companion change in logos-qt-sdk seeds the trust anchor before publishing,
which removes the refusal at its source; this is the safety net for hosts and
modules that do not.

Also adds the regression test that would have caught this: the existing case
seeds "core" before pushing, which is exactly the state that does NOT hold in
the window the surface covers, so it asserted the surface works under a
precondition production never met.

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

* fix(startup): marshal the token push, and stop re-probing a missing handshake

Two review findings from Copilot, both verified against the code before acting.

1. Thread affinity. informModuleToken_module was one of only two entry points in
   LogosAPIClient that did not wrap in logos::runOnOwnerThread -- requestObject,
   both invokeRemoteMethod forms and onEvent all do. The missing marshal is
   inherited, but THIS change is what made it reachable: the method used to take
   an uncached requestObject() + release() and touch no shared state, and routing
   it through acquireCachedObject put it on m_objectCache, which is declared
   single-threaded and holds thread-affine QtRO handles. Now marshalled, matching
   its four siblings.

   The 3-arg informModuleToken has the same gap but still uses an uncached handle
   and predates this work, so it is deliberately left alone rather than widened
   into this fix; noted at the call site.

2. No negative cache on the handshake probe. acquireCachedObject caches successes
   only, so a module built before the handshake surface existed failed the probe
   on EVERY grant -- and on QtRO that failure is a blocking waitForSource, i.e.
   250 ms of dead time per token, forever. Remember the absence and go straight to
   the business object; cleared by clearObjectCache() so a reconnect, or a module
   reloaded from a build that has the surface, is re-probed rather than written
   off permanently.

   (The review attributed this cost to the Local/Plain adapters rejecting a
   non-ModuleProxy object. Checked per transport: plain is unaffected -- its token
   push is nameless fire-and-forget and it never had the acquire deadlock -- and
   on qt_local requestObject ignores timeoutMs entirely, so the cost there is a
   spurious warning, not 250 ms. The real cost is the missing negative cache, on
   QtRO.)

The same review's ABI-break and name-collision findings were measured and do not
apply: logos_protocol is a static archive with zero undefined imports of these
symbols anywhere in the built stack, and object names are scoped to a per-module
socket rather than a global registry. Both answered in-thread.

290/290 protocol tests.

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

* test(startup): exercise the handshake surface over a real transport

The existing handshake cases call ModuleHandshakeProxy directly, with no
transport underneath. That is what let a whole class of defect through: the
surface is only useful if a transport will PUBLISH a token-only QObject and a
consumer can ACQUIRE it by the derived name, and a direct-call test can see
neither half. The adapter survey prompted by review found qt_local silently
rejects a non-ModuleProxy on acquire while still reporting a successful publish
-- invisible to every test in the suite.

These run on the transport the production stack actually uses (QtRO, the
LogosTransportConfig default), and model the startup window honestly: the
handshake object is published and the business object deliberately is NOT,
because it does not exist until the initializer returns. That window is the
entire reason the surface exists and is the one state the direct-call tests
could never represent.

  TokenReachesAModuleWhoseBusinessObjectIsNotPublishedYet
      the pre-init window end to end: publish -> probe by derived name ->
      acquire -> push lands on the provider.
  AnUnseededAnchorRefusesEvenThoughTheSurfaceIsReachable
      the transport-level twin of the gate test: proves the refusal measured in
      production (29 of 34 app runs) is the gate rejecting the push, not the
      transport failing to deliver it -- the provider is never reached.
  ALegacyModuleFallsBackAndIsNotReProbed
      a module with no handshake surface still gets its token, and the missing
      surface is probed ONCE. Timed rather than functional, so it was falsified
      before being trusted: with the negative cache removed the suite fails on
      exactly this case and no other.

293/293 protocol tests.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:58:41 -03:00
Dario LipicarandClaude Opus 4.8 ef24bd70d9 fix(protocol): re-exchange token on provider rejection (#26)
When a provider rejects a call for a stale/unrecognized token it now returns a
structured "unauthorized" sentinel (logos_rpc_status.h) instead of a bare
QVariant(). LogosAPIClient detects it below the typed wrapper, drops the cached
token, re-runs capability_module.requestModule and retries the call once —
closing the gap where a stale token was reused forever (the consumer-latched-dead
failure mode) and lazily recovering the common provider-reload case.

The return VALUE is the only provider->consumer channel available on every
transport (qt_local/qt_remote/plain) without an ABI break, since the QtRO
dispatch slot returns a single QVariant — hence a value sentinel.

Backward compatible:
- OLD consumers convert the sentinel identically to QVariant() for every
  scalar/string/LogosResult return, so they keep seeing today's empty/failed
  result.
- OLD providers return bare QVariant(); a NEW consumer never matches the
  sentinel and so never re-exchanges against them.
The retry is bounded to one attempt and fires ONLY on the explicit sentinel
(never a legitimately-empty result), so no loops and no misfire.

Downstream note: logos-qt-sdk's test_auth_token_enforcement.cpp asserts
!isValid() on unauthorized calls; those become isUnauthorizedSentinel() when it
re-pins (the security property — no provider dispatch — is unchanged).

Tests: tests/protocol/test_token_reexchange.cpp covers provider-side emission,
sync/async re-exchange+retry, bounded retry (no loop), the false-positive guard
(a legit empty return must not re-exchange), and old-consumer decode.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:18:43 -03:00
Khushboo Mehta ee1c5192da feat: plumb CallError through invokeRemoteMethodAsync 2026-07-15 21:45:48 +02:00
Dario LipicarandClaude Opus 4.8 4ea32a314a Per-module concurrent dispatch: async provider seam + transports (#5)
* feat: per-module concurrent dispatch (concurrency:"multi") — zero ABI change

A "multi" module serves calls concurrently behind the ORDINARY callMethod — no
new provider/host vtable method, so LogosProviderObject's ABI is byte-identical
to before and an old host/daemon loads and forwards a multi module unmodified.

Mechanism: a multi module's generated glue returns a pending sentinel
({"__logos_pending_call__": callId}) from callMethod and pushes the real result
back later as a __logos_call_complete__ event keyed by callId, over the existing
event channel. The consumer transport detects the sentinel and awaits the
completion transparently, so generated clients are unchanged.

- logos_async_dispatch.h: shared wire constants + the contract.
- remote_transport.cpp (QtRO) / plain_logos_object.{h,cpp} (plain): consumer
  sentinel detection + await keyed by callId. The host is a pure forwarder.
- logos_protocol.h + nix/default.nix: protocol 0.2.0 (additive minor; same MAJOR
  stays compatible, so an old host accepts a 0.2 "multi" module).
- rpc_server.cpp: fix a teardown self-deadlock (stop() held m_mu while invoking a
  per-connection error handler that re-locks m_mu) that the new in-process
  subscription path exposed.
- tests/protocol/test_concurrent_dispatch.cpp: proves a multi provider overlaps
  two concurrent calls (peak 2) while single serializes (peak 1), over the plain
  transport, with the host unchanged from master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: coalesce concurrent async requestModule handshakes (+ async fan-out test)

A driver that fans out N async calls to an un-tokened target before any
completes used to fire N separate requestModule handshakes. Each mints a
distinct capability token and informs the target, and the later inform
OVERWRITES the earlier token there (the target stores one token per caller),
so the already-dispatched calls carried a superseded token and the target
rejected them as unauthorized ("auth token not recognized"). The sync path
never hit this — it blocks per call, so handshakes never overlap.

Coalesce in LogosAPIClient::invokeRemoteMethodAsync: the first async call to
an un-tokened target starts ONE handshake; concurrent calls to the same
target queue behind it and all drain with the single minted token when it
resolves. m_pendingHandshakes is touched only on the owner thread, so no lock
(appended last per the class's ABI note). This is what lets a concurrency:
"multi" worker actually run a single-threaded driver's fan-out concurrently —
otherwise the fanned-out calls are rejected before reaching dispatch.

Also add MultiProviderOverlapsAsync / SingleProviderSerializesAsync to the
concurrent-dispatch gtest: they fire N concurrent callMethodAsync() calls (the
fan-out pattern over the async consumer path, which the sync tests don't
exercise) and assert peak overlap 4 for "multi", 1 for "single".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:54:42 -03:00
Dario Lipicar 9de4165ab6 Qt split + module authoring groundwork: LogosProviderPlugin + the common module-impl C ABI (#3)
* Extract the Logos protocol layer from logos-cpp-sdk

Transports (plain TCP/TLS, qt_local, qt_remote/QRO, mock), token manager,
consumer core (LogosAPIClient/LogosAPIConsumer incl. the capability
auto-requestModule flow), ModuleProxy, the abstract LogosProviderObject
interface, and the canonical QVariant<->JSON conversion — now behind the
language-neutral lp_* C ABI (logos_protocol.h) carrying the protocol
semver (LOGOS_PROTOCOL_VERSION_*, lp_protocol_version()).

Bytes crossing the ABI use the lossless {"_bytes": base64url} tagging
(NUL-safe), matching the plain wire encoding.

Provider lp_* surface is compiled groundwork; serving lands with module
authoring.

* Move LogosProviderPlugin into logos_provider_interface.h

Plugin-loading tools (logos-cpp-generator's introspection mode, lm, the
hosts) need only qobject_cast<LogosProviderPlugin*>() + the abstract
LogosProviderObject — both framework-internal. Hosting the detection
interface here keeps those tools off the developer-facing logos-qt-sdk
layer. Same iid (org.logos.LogosProviderPlugin); header-only, ABI-neutral.

* Define the common module-impl C ABI (logos_module_impl.h)

ONE cdylib contract for module implementations in every language:
dispatch / get_methods / set_context / set_emit_callback / accept_token
/ get_protocol_version / string_free. The C++ and Rust SDKs emit these
exports around their respective impls; the uniform generated Qt glue
(and later a no-Qt host) talks to the cdylib only through this ABI.
JSON data model and tagged bytes form match the lp_* consumer ABI; the
protocol-version handshake complements the build-time metadata stamp.

* json convert: integers stay integers across the C ABI

QJsonValue::fromVariant degrades every numeric to double, so Int/UInt/
LongLong/ULongLong QVariants serialized as 5.0 — and a strict consumer on
the other side of the C ABI (a generated dispatch reading an int param)
rejects or zeroes them. Surfaced by the first cdylib-authored module
whose inbound args cross qvariantToNlohmann; the dlopen smoke harness
fed hand-written int JSON and never exercised this edge.

* call-error channel: surface {code,message,origin} for unacquirable targets

invokeRemoteMethod could not distinguish a failed call from a void/null
result — lp_invoke returned LP_OK with a null JSON result even when the
target module was never reached, and generated typed wrappers silently
defaulted (0 / empty string). Additive err-out overloads on
LogosAPIConsumer/LogosAPIClient fill a std-only logos::CallError
(logos_call_error.h, new LogosCallError exception for the generated
wrappers to throw); lp_invoke now honors its documented contract for
this class of failure: LP_ERR_UNAVAILABLE + canonical error JSON.
First detectable code: object_unavailable (requestObject failure) —
the struct is the extension point for transport-level statuses.

* call-error: drop the exception type — the error channel is the out-param

Per review, generated wrappers expose CallError as an optional trailing
out-parameter instead of throwing; the struct is the whole contract.

* ci: build + run the protocol test suite

On every pull request (unfiltered — stacked PRs included), master pushes,
and manual dispatch. The repo shipped without CI; its 111-test suite only
ran locally and through the workspace gate.

* consumer: typed requestModule for the capability flow

Port of logos-cpp-sdk master f5a127dd ('use updated capability module',
cpp-sdk#85, Iuri Matias) — the touched files (logos_api_client.cpp,
logos_api_consumer.{h,cpp}) moved into this repo in the P1 extraction.
The capability auto-requestModule path now calls a typed std::string
helper on the consumer (which acquires the capability object directly)
instead of a stringly invokeRemoteMethod round-trip. 111/111 tests.

* ci: DeterminateSystems nix installer (macOS runners)

cachix/install-nix-action fails on the macOS runners with
eDSRecordAlreadyExists (pre-existing nix build users); the org's
macOS-bearing workflows use the DeterminateSystems installer.
2026-06-12 19:39:57 -03:00
Dario Lipicar 29afbac532 Extract the Logos protocol layer from logos-cpp-sdk (lp_* C ABI + protocol semver) (#2)
* Extract the Logos protocol layer from logos-cpp-sdk

Transports (plain TCP/TLS, qt_local, qt_remote/QRO, mock), token manager,
consumer core (LogosAPIClient/LogosAPIConsumer incl. the capability
auto-requestModule flow), ModuleProxy, the abstract LogosProviderObject
interface, and the canonical QVariant<->JSON conversion — now behind the
language-neutral lp_* C ABI (logos_protocol.h) carrying the protocol
semver (LOGOS_PROTOCOL_VERSION_*, lp_protocol_version()).

Bytes crossing the ABI use the lossless {"_bytes": base64url} tagging
(NUL-safe), matching the plain wire encoding.

Provider lp_* surface is compiled groundwork; serving lands with module
authoring.

* consumer: typed requestModule for the capability flow

Port of logos-cpp-sdk master f5a127dd ('use updated capability module',
cpp-sdk#85, Iuri Matias) — the touched files (logos_api_client.cpp,
logos_api_consumer.{h,cpp}) moved into this repo in the P1 extraction.
The capability auto-requestModule path now calls a typed std::string
helper on the consumer (which acquires the capability object directly)
instead of a stringly invokeRemoteMethod round-trip. 111/111 tests.
2026-06-12 18:59:01 -03:00