Commit Graph
4 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 480f40ff63 feat(abi): publish the module-impl export list as data, for backends to check against (#66)
* feat(abi): publish the module-impl export list as data, for backends to check against

logos-protocol DECLARES the module-impl C ABI; every language backend
(logos-cpp-sdk, logos-rust-sdk, and the Nim path now in flight) must
DEFINE every entry. Those are independent facts, and the gap between them
has shipped twice — grant_host_services at 0.3, the teardown pair at 0.5.
Each time it surfaced three repos downstream as an "undefined symbol" at
dlopen, on Linux only, and each time the runtime still reported the module
as LOADED, so what anyone actually saw was other modules timing out on a
replica that never appeared.

Both breakages happened at PERFECT version agreement between the caller
and the module. Version agreement is necessary and not sufficient: it says
nothing about which symbols a given backend's emitter happens to write.

So derive the list once, here, in the repo that owns the ABI, and ship it
as a build output:

  packages.<sys>.module-impl-abi
    exports.txt  — the declared names
    version      — the protocol version that header belongs to
    bin/logos-module-impl-diff — the assertion, and the explanation

Two properties follow from putting it here rather than in each backend.
There is ONE parser to keep working, rather than one regex per language
that can each silently stop matching. And the list is version-correct with
no version arithmetic anywhere: the header is itself versioned — at 0.4 it
declared eight exports, at 0.5 it declares ten — so "what this protocol
requires" is just "what this header declares". A backend pinning 0.4 reads
eight and is right to define eight. No @since tags, no MINOR comparisons,
nothing for a backend to get wrong.

The extractor parses LOGICAL declarations rather than lines (a reflowed
header must not silently drop one) and refuses to emit a list it is unsure
of: under-reporting is the dangerous direction, because a short list makes
every consumer's diff pass over an ABI nobody checked. The floor it checks
against is asserted rather than derived, so a broken parse cannot satisfy
it. logos-protocol failing to build is the right consequence of
logos-protocol being unable to state its own ABI.

checks.<sys>.module-impl-abi-tests proves all of that can still fail: empty
header, renamed macro, a founding export removed, an empty defined-set, and
a reflowed declaration — eleven cases, each a way this could have decayed
into a green check over nothing.

Also corrects the compatibility note above logos_module_about_to_unload.
It argued the pair was safe because "the glue is generated alongside the
module". That does not follow, and is the reasoning the 0.5 break rested on.

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

* ci: run the ABI manifest check — `nix build .#tests` never reaches `checks`

The step added here is not incidental. `nix build '.#tests'` builds the
PACKAGE; nothing in this workflow evaluated the `checks` attrset at all, so
the manifest self-test added in the previous commit would have sat there
green-by-absence — which is precisely the failure mode it exists to catch.

`ws test` is not a substitute either: it evaluates exactly one check per
repo (scripts/ws truncates the checks JSON at the first comma), so a green
`ws test logos-protocol` says nothing about whether this ran.

builtins.currentSystem rather than a literal, so one line is correct on both
matrix runners.

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

* fix(abi): the helper must not depend on the consumer's PATH — and the suite must notice

Two bugs, and the second is the interesting one.

1. The installed helper carried `#!/usr/bin/env bash`. Consumers execute it
   from inside their own nix builds, whose PATH is whatever THEIR
   nativeBuildInputs provide. It resolved on macOS and not in the Linux
   sandbox, so the helper simply did not run there. patchShebangs pins an
   absolute interpreter.

2. The self-test did not notice, and the reason is worth keeping. expect_fail
   accepted ANY non-zero exit as a correct refusal — but a script that cannot
   be executed exits 126/127, so all five refusal cases reported PASS while
   proving nothing at all. Only the two POSITIVE cases failed, which is the
   only reason this surfaced.

   That is precisely the failure this whole change exists to prevent, one
   level up: a check that reports green over something it never examined. So
   expect_fail now asserts a deliberate refusal and rejects 126/127 by name.

Caught by CI on ubuntu-latest while macOS was green — the same
platform-asymmetry that let the original ABI break through.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:06:48 -03:00
Dario LipicarandClaude Opus 5 9664ae219d feat(abi): an optional teardown pair on the module C ABI (#62)
A module has no way to finish work before it is torn down. The host stops it,
its destructors run, and anything mid-flight is simply gone. This adds the two
symbols that let a module say "not yet" and then "done":

    int  logos_module_about_to_unload(void);
    void logos_module_set_unload_done_callback(cb, user_data);

Returning 1 buys a BOUNDED 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 -- said in the header, because the
alternative is authors discovering it from a shutdown that got slower.

OPTIONAL, and that is load-bearing rather than politeness. The glue that calls
these is generated alongside the module, so a cdylib built before they existed
exports neither and its glue emits no calls: an older module keeps exactly the
teardown it always had, with no version negotiation and no new failure mode.

Header-only; no implementation and no behaviour change in this repo. The
emitters live in logos-cpp-sdk (C++ exports) and logos-plugin-qt (the Qt glue
that reaches them), and the host-side wait in logos-module-loader-qt.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:17:58 -03:00
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 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