mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-31 14:01:14 +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>
258 lines
14 KiB
C
258 lines
14 KiB
C
#ifndef LOGOS_MODULE_IMPL_H
|
|
#define LOGOS_MODULE_IMPL_H
|
|
|
|
/* ===========================================================================
|
|
* logos_module_impl.h — the COMMON module-impl C ABI.
|
|
*
|
|
* ONE contract for module implementations in every language: a Logos module
|
|
* compiles to a cdylib exporting exactly these symbols. The C++ SDK emits
|
|
* this wrapper around a universal C++ impl class; the Rust SDK emits it
|
|
* around a Rust impl. The uniform generated Qt-plugin glue (and, later, a
|
|
* no-Qt host) talks to the cdylib ONLY through this ABI — the glue is
|
|
* identical regardless of the module's source language, which is what makes
|
|
* the eventual Qt-glue removal a host swap instead of a per-language change.
|
|
*
|
|
* Data model mirrors the lp_* consumer ABI (logos_protocol.h):
|
|
* - method args / event payloads: JSON array (UTF-8 const char*)
|
|
* - results: JSON value
|
|
* - bytes: the canonical {"_bytes":"<base64url>"} tagged form
|
|
* - errors from dispatch: NULL return, or a canonical error object
|
|
* {"code","message","origin"} returned as the result of a failed call
|
|
* when the implementation prefers structured errors.
|
|
*
|
|
* Ownership: every char* RETURNED by the module is heap-allocated and the
|
|
* CALLER frees it with logos_module_string_free (exported by the module so
|
|
* allocator domains never mix). Every const char* passed IN is borrowed.
|
|
*
|
|
* Threading: the host serializes dispatch calls (one at a time) unless a
|
|
* future capability negotiates otherwise. The emit callback may be invoked
|
|
* from any module thread; the host marshals.
|
|
*
|
|
* Versioning: logos_module_get_protocol_version() returns the
|
|
* logos-protocol semver the module was COMPILED against (forwarded from
|
|
* LOGOS_PROTOCOL_VERSION_STRING, never minted). Hosts apply the same rule
|
|
* as the metadata stamp: equal MAJOR ⇔ compatible. This runtime handshake
|
|
* complements the build-time metadata stamp and is what a no-Qt host (no
|
|
* Qt plugin metadata) negotiates with.
|
|
* =========================================================================== */
|
|
|
|
#ifdef __cplusplus
|
|
extern "C" {
|
|
#endif
|
|
|
|
#if defined(_WIN32)
|
|
#define LOGOS_MODULE_IMPL_EXPORT __declspec(dllexport)
|
|
#else
|
|
#define LOGOS_MODULE_IMPL_EXPORT __attribute__((visibility("default")))
|
|
#endif
|
|
|
|
/* Event-emission callback installed by the host/glue. `data_json` is a JSON
|
|
* array payload, borrowed for the duration of the call. */
|
|
typedef void (*logos_module_emit_cb)(const char* event_name,
|
|
const char* data_json,
|
|
void* user_data);
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* Exported by every module cdylib (generated by the SDK of the module's
|
|
* language; module authors never write these by hand).
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/* Dispatch a method call. Returns the result JSON value as a heap string
|
|
* (free with logos_module_string_free), or NULL when the method is unknown
|
|
* or dispatch failed structurally. */
|
|
LOGOS_MODULE_IMPL_EXPORT char* logos_module_dispatch(const char* method,
|
|
const char* args_json);
|
|
|
|
/* The module's method/event metadata as a JSON array — same shape as
|
|
* LogosProviderObject::getMethods() (entries tagged "method"/"event"). */
|
|
LOGOS_MODULE_IMPL_EXPORT char* logos_module_get_methods(void);
|
|
|
|
/* Module identity/context, stamped by the host before the first dispatch:
|
|
* module path, instance id, per-instance persistence path. Mirrors
|
|
* LogosModuleContext / RustModuleContext. Any argument may be NULL. */
|
|
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_context(
|
|
const char* module_path,
|
|
const char* instance_id,
|
|
const char* instance_persistence_path);
|
|
|
|
/* Install the host's event-emission callback. The module keeps (cb,
|
|
* user_data) and invokes cb once per emitted event. Passing NULL clears it;
|
|
* after the clearing call returns, the module must not invoke the old cb. */
|
|
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_emit_callback(
|
|
logos_module_emit_cb cb, void* user_data);
|
|
|
|
/* Deliver an auth token for `module_name` (the provider-side
|
|
* informModuleToken). Returns 0 on acceptance. */
|
|
LOGOS_MODULE_IMPL_EXPORT int logos_module_accept_token(const char* module_name,
|
|
const char* token);
|
|
|
|
/* Grant the module the privileged host services named in `services_json` (a
|
|
* JSON array from the closed set lp_grant_host_services documents). Returns 0
|
|
* on acceptance; the generated implementation simply forwards to
|
|
* lp_grant_host_services.
|
|
*
|
|
* Called by the host AFTER it has verified the module's identity, and only for
|
|
* the modules its policy designates as a trust root — nothing about this ABI
|
|
* decides who deserves the grant.
|
|
*
|
|
* It has to travel this way, and that is the subtle part: the host binary and
|
|
* the module cdylib each link their own copy of logos-protocol, so each has its
|
|
* own process-global grant state, exactly as each has its own TokenManager. A
|
|
* grant the host records for itself is invisible to the gate the cdylib checks.
|
|
* Pushing it in over this ABI — the same route the auth token above already
|
|
* takes — is what puts the grant in the image whose gates it must open. */
|
|
LOGOS_MODULE_IMPL_EXPORT int logos_module_grant_host_services(
|
|
const char* services_json);
|
|
|
|
/* Teardown completion callback, installed by the glue before it asks the module
|
|
* to unload. May be invoked from any module thread. */
|
|
typedef void (*logos_module_unload_done_cb)(void* user_data);
|
|
|
|
/* Install the teardown-completion callback. Called before
|
|
* logos_module_about_to_unload(); a NULL cb clears it. */
|
|
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_unload_done_callback(
|
|
logos_module_unload_done_cb cb, void* user_data);
|
|
|
|
/* Ask the module to prepare for teardown. Returns 0 when it is already
|
|
* quiescent, 1 when it has work to finish and will invoke the callback
|
|
* installed above exactly once when done.
|
|
*
|
|
* The wait is BOUNDED by the host: returning 1 buys a grace period, not a veto.
|
|
* A module that never signals delays every teardown by that period and is torn
|
|
* down anyway, so the deadline is real rather than a courtesy.
|
|
*
|
|
* CONDITIONAL on the protocol version, which is not the same as optional at
|
|
* load time. The glue emits a DIRECT call — no dlsym, no null check — so a
|
|
* module generated for >= 0.5 whose backend omits the definition links cleanly
|
|
* and then fails at dlopen(), on ELF, with "undefined symbol". The pair is
|
|
* skippable only where the CALLER was generated below 0.5 and emitted no call.
|
|
*
|
|
* An earlier version of this comment argued the arrangement was safe because
|
|
* "the glue is generated alongside the module". That does not follow, and the
|
|
* ABI has now been broken twice on the strength of it. Being generated in the
|
|
* same build makes the two agree on the protocol VERSION; it says nothing about
|
|
* which SYMBOLS a given language backend's emitter writes for that version,
|
|
* because each backend implements this ABI independently. Both breakages —
|
|
* grant_host_services at 0.3 and this pair at 0.5 — happened at perfect version
|
|
* agreement, and both were invisible on macOS (plugins link
|
|
* -undefined dynamic_lookup) and fatal on Linux (nixpkgs' -Wl,-z,now binds
|
|
* eagerly).
|
|
*
|
|
* So every backend owes a build-time check that its generated scaffold defines
|
|
* everything declared here. nix/module-impl-abi.nix publishes this file's
|
|
* export list as data for exactly that purpose. */
|
|
LOGOS_MODULE_IMPL_EXPORT int logos_module_about_to_unload(void);
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* THE CALLER OF A DISPATCH.
|
|
*
|
|
* Push the identity of whoever is making the call that is about to run
|
|
* (`caller_json` non-NULL), or pop it (`caller_json` NULL). The glue wraps
|
|
* exactly one logos_module_dispatch() in one push/pop pair, on the thread that
|
|
* dispatch runs on; the module's language binding surfaces the innermost value
|
|
* to the handler as an AMBIENT accessor — logos::currentCaller() in C++,
|
|
* logos_rust_sdk::current_caller() in Rust.
|
|
*
|
|
* NOT A LIDL PARAMETER, and that is a requirement rather than an implementation
|
|
* detail. Who is calling is not part of a module's interface: the callee already
|
|
* possesses that identity, because the caller had to present a token this module
|
|
* itself issued in order to get here at all. This ABI only surfaces what
|
|
* authorization already established. Nothing about it is per-method, opt-in, or
|
|
* visible in a .lidl file.
|
|
*
|
|
* A PER-THREAD STACK, not a single slot. A handler that makes an outbound call
|
|
* spins a nested event loop, and a second inbound call can be delivered on the
|
|
* same thread inside it; a plain set/clear pair would have the inner call's pop
|
|
* erase the outer call's caller. So: non-NULL pushes, NULL pops the innermost,
|
|
* a pop with nothing pushed is a no-op, and each thread has its own stack. One
|
|
* symbol, because one symbol is what the export list checks for.
|
|
*
|
|
* VALID ONLY DURING A DISPATCH, ON THE DISPATCHING THREAD. A worker the module
|
|
* spawned, a timer callback, a context-ready hook and an event emission all read
|
|
* Unknown — correctly, since none of them has a caller. A handler that needs the
|
|
* identity beyond its own frame copies it at the top.
|
|
*
|
|
* ── THE DOCUMENT ────────────────────────────────────────────────────────────
|
|
*
|
|
* `caller_json` is a JSON OBJECT. This is its normative definition; the C++ and
|
|
* Rust types that parse it are per-language, exactly as the {"_bytes":...} form
|
|
* and the {"code","message","origin"} error object are.
|
|
*
|
|
* {"kind":"unknown"}
|
|
* {"kind":"host"}
|
|
* {"kind":"module","name":"chat_module"}
|
|
* {"kind":"module","name":"chat_module","instance":"a41f"}
|
|
* {"kind":"derived","parent":"wallet_module","leaf":"wallet_ui"}
|
|
* {"kind":"operator","name":"ops-readonly"}
|
|
*
|
|
* Rules, in the order a reader applies them:
|
|
*
|
|
* 1. "kind" is MANDATORY. Missing, non-string, an unparseable document, or
|
|
* empty input ⇒ unknown.
|
|
* 2. An UNRECOGNISED "kind" ⇒ unknown. Never a closest match, never dropped.
|
|
* This is the only safe direction: adding an arm can turn an old reader's
|
|
* is_module(x) from true to false, never the reverse. A permissive fallback
|
|
* would do the opposite and silently WIDEN a predicate that sits next to
|
|
* authorization decisions.
|
|
* 3. Unrecognised FIELDS inside a known arm are ignored, so an arm can gain a
|
|
* field without a version bump.
|
|
* 4. A known arm missing a required field ⇒ unknown, not a partial value.
|
|
* 5. "host" carries NO name and must not gain one. "core" and
|
|
* "capability_module" hold the same token VALUE under two keys by
|
|
* construction, so a name there would be a coin flip presented as a fact.
|
|
* 6. "instance" is optional FROM DAY ONE and is_module(name) deliberately
|
|
* ignores it. The draft spec's authenticated invoker is a module instance
|
|
* address rather than a bare name; shipping the field later would have
|
|
* silently reinterpreted every call site the moment instance addressing
|
|
* arrived. A caller that must distinguish instances compares the full
|
|
* identity.
|
|
*
|
|
* An "instance" that is PRESENT but not a string is DROPPED, and the
|
|
* module is still identified by its name. This is normative because the
|
|
* two backends disagreed about it — C++ dropped it, Rust returned
|
|
* "unknown" — each with a passing test pinning its own answer, so neither
|
|
* suite could see the divergence. The rule is decided here rather than in
|
|
* either of them.
|
|
*
|
|
* Dropping is the correct arm on two grounds. "name" comes from the host's
|
|
* own token resolution and is not made less trustworthy by a malformed
|
|
* sibling field; and is_module(name), which is the path essentially every
|
|
* caller takes, ignores "instance" entirely. Failing the whole identity
|
|
* would turn a cosmetic wire defect into a fleet-wide authorization change,
|
|
* and would mean a later protocol emitting a richer "instance" silently
|
|
* stops one language's modules recognising callers the other still does.
|
|
* A backend that needs the distinction reads the field itself.
|
|
*
|
|
* Nothing here is spelled "verified". capability_module checks only that an
|
|
* asserted name EXISTS as a key, so the strongest honest word for a named module
|
|
* is token-bound.
|
|
*
|
|
* PRODUCERS TODAY: "unknown", "host", and "module" WITHOUT "instance". The
|
|
* "derived" and "operator" arms are specified and must be parsed, but nothing
|
|
* emits them yet — said here rather than left for someone to discover.
|
|
* "derived" is reserved for the isolated per-plugin identity
|
|
* (LogosAPI::forIdentity); "operator" needs ModuleProxy::TokenValidator widened
|
|
* from bool to also yield a name, which is a logos-logoscore-cli change.
|
|
*
|
|
* CONDITIONAL on protocol >= 0.6, with the same teeth as the teardown pair
|
|
* above: the glue emits a DIRECT call, so a module generated for >= 0.6 whose
|
|
* backend omits this definition links cleanly and then fails at dlopen() on ELF
|
|
* with "undefined symbol". Both backends owe it in the same wave as this
|
|
* declaration; nix/module-impl-abi.nix is what makes that fail loudly instead
|
|
* of at a user's dlopen.
|
|
* ------------------------------------------------------------------------- */
|
|
LOGOS_MODULE_IMPL_EXPORT void logos_module_set_call_caller(const char* caller_json);
|
|
|
|
/* The logos-protocol semver this module was compiled against. Static
|
|
* string — do NOT free. */
|
|
LOGOS_MODULE_IMPL_EXPORT const char* logos_module_get_protocol_version(void);
|
|
|
|
/* Free a string returned by this module. Safe on NULL. */
|
|
LOGOS_MODULE_IMPL_EXPORT void logos_module_string_free(char* s);
|
|
|
|
#ifdef __cplusplus
|
|
}
|
|
#endif
|
|
|
|
#endif /* LOGOS_MODULE_IMPL_H */
|