mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 12:01:15 +00:00
A module can now learn which module is calling it. Not via the LIDL — this
is not part of any module's interface, and the callee already has the
identity from the token the call carried; the only question was surfacing
it. So it is ambient: logos::currentCaller(), no declared parameter, no
contract change, no per-method opt-in.
WHAT THIS PR CONTAINS
* LogosCaller — Unknown | HostAnchor | Module{name, instance?} |
Derived{parent, leaf} | Operator{name} — std-typed and Qt-free.
* CallerScope, an RAII save/restore around a thread-local STACK. Not a
slot: A calling B calling back into A on one thread must nest, and an
exception thrown from a handler must still pop.
* resolveCaller, replacing the bool fold in ModuleProxy. It reads the
INBOUND store #69 made direction-pure — the only store that may
legitimately name a caller.
* logos_module_set_call_caller DECLARED, and MINOR 5 -> 6.
WHY AMBIENT, AND WHY IT MUST CROSS AN IMAGE BOUNDARY
LogosProviderObject::callMethod is a vtable slot, and this codebase avoids
vtable changes on purpose. But the deeper reason is measured, not stylistic:
nm on real binaries shows the host and the module plugin EACH define
ModuleProxy::callRemoteMethod and TokenManager::instance, each with its own
function-local static at a distinct address, and neither with a single
undefined reference to the other's. Mach-O is TWOLEVEL; PE has no
interposition. A thread_local opened host-side is NOT the one a handler
reads. Since --backend qt is now refused outright, every module is a cdylib
and the C ABI push is the only path, not a fallback.
The pull is only safe through QMetaObject::invokeMethod on the host's
LogosAPI, because metaObject()/qt_metacall are virtual and the vptr was
written by the host's constructor — LogosAPI is duplicated across images
too, meta-object included, so a direct call would bind to the plugin's copy
and read the plugin's TLS, silently empty forever. A dynamic property
cannot carry it either: one process-global slot, so two overlapping
concurrency:"multi" calls from different callers would clobber each other.
Nothing here is spelled "verified". capability_module checks only that an
asserted name EXISTS as a key, so the strongest honest word is token-bound.
HostAnchor carries no name because core and capability_module hold one
token VALUE under two keys by construction. Unknown is the fail-closed
value and is always in-band, never spelled by absence.
The constant-time fold survives: the matched key is accumulated into a
fixed-width buffer with no data-dependent branch, verified at the
instruction level (csel, not a branch) with the comparison count invariant.
THE BUMP IS SAFE BECAUSE THE BACKENDS WENT FIRST
logos-protocol only DECLARES this ABI; every backend owes the definition,
and that gap shipped twice. logos-cpp-sdk#147 and logos-rust-sdk#47 already
define logos_module_set_call_caller, gated on >= 0.6 and therefore inert
until this lands. Verified on x86_64-linux: with this tree as the protocol,
BOTH backends at master pass their ABI checks and define the export;
manifest reports 0.6.0 with 11 exports. No repo is red at any point.
Rule 6 is now normative on a point the two backends had silently diverged
on — a present-but-unreadable "instance" is dropped and the module still
identified — each having pinned its own answer with a passing test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
73 lines
1.9 KiB
C++
73 lines
1.9 KiB
C++
#include "logos_caller_scope.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
namespace logos {
|
|
|
|
namespace {
|
|
|
|
// The per-thread slot. One std::string, holding the innermost open scope's
|
|
// document.
|
|
//
|
|
// A plain thread_local rather than a std::vector stack: CallerScope already
|
|
// keeps the enclosing document in its own frame, so the "stack" is the C++ call
|
|
// stack. That is both cheaper and harder to desynchronize — there is no
|
|
// container whose depth could disagree with the number of live scopes.
|
|
std::string& slot()
|
|
{
|
|
thread_local std::string current;
|
|
return current;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string callerUnknownJson()
|
|
{
|
|
return R"({"kind":"unknown"})";
|
|
}
|
|
|
|
std::string callerHostAnchorJson()
|
|
{
|
|
return R"({"kind":"host"})";
|
|
}
|
|
|
|
std::string callerModuleJson(const std::string& name)
|
|
{
|
|
// A name with no producer is not a module arm. Resolving to Unknown here
|
|
// rather than emitting {"kind":"module","name":""} keeps rule 4 of the wire
|
|
// shape (a known arm missing a required field is Unknown) true at the
|
|
// PRODUCER as well as at every reader.
|
|
if (name.empty()) return callerUnknownJson();
|
|
|
|
nlohmann::json j;
|
|
j["kind"] = "module";
|
|
j["name"] = name;
|
|
// `replace` rather than the default throwing handler. A store key is a
|
|
// module name and is UTF-8 in every path that exists today, but this runs
|
|
// on the authorization path of every inbound call: a throw here would turn
|
|
// a naming problem into a failed dispatch, which is a strictly worse
|
|
// outcome than a name with a replacement character in it.
|
|
return j.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace);
|
|
}
|
|
|
|
std::string currentInboundCallerJson()
|
|
{
|
|
return slot();
|
|
}
|
|
|
|
CallerScope::CallerScope(std::string callerJson)
|
|
: m_previous(std::move(slot()))
|
|
{
|
|
slot() = std::move(callerJson);
|
|
}
|
|
|
|
CallerScope::~CallerScope()
|
|
{
|
|
slot() = std::move(m_previous);
|
|
}
|
|
|
|
} // namespace logos
|