feat(metadata): codegen.consumer_api_style, gated on how the image gets tokens

Lets a module declare its CONSUMER type surface independently of its provider
packaging. A cdylib-packaged module can now hold Qt-typed dependency wrappers —
the combination that was previously inexpressible, and the only reason
`interface: "provider"` and `--provider-header` are still alive.

The enabling codegen landed first (logos-cpp-sdk 620f2e1, logos-qt-sdk 6b88630):
a default-constructible Qt umbrella and a consumer binding that takes an
explicit origin instead of a LogosAPI. An earlier attempt at this key FAILED
because it was added without that codegen, so its only reachable outcome was a
compile error inside generated code. It is meaningful now.

── The predicate ───────────────────────────────────────────────────────────

    packagedAsCdylib = interface == "cdylib"
                    || (interface == "universal" && type != "ui_qml")

character-for-character what modulePreConfigure.autoCodegen branches on when it
decides to emit the module-impl C ABI, so the two cannot drift.

The distinction is NOT "this image has no LogosAPI" — a cdylib module's plugin
does contain one, in the Qt glue that receives tokens. What separates the two
worlds is where the image's own TokenManager is FILLED:

  cdylib-packaged : logos_module_accept_token -> lp_token_save, same image.
                    An origin-bound wrapper's null sync hook costs nothing.
  Qt plugin       : only LpBridge::syncTokens, installed exclusively by
                    forTarget(api, …). An origin-bound wrapper there would be
                    silently unauthenticated — the exact defect syncTokens
                    exists for.

Measured with `nm -gU`, not argued: `logos_module_accept_token` is defined in
exactly the shapes the predicate calls true, across six real plugins.
`universal` + `ui_qml` is the trap — it looks cdylib-shaped but uiCodegen emits
only view glue, so it is a Qt object holding a LogosAPI. Excluded.

`--binding origin` is not selectable from metadata at all: it is derived as
`isQt && packagedAsCdylib` in the backend, an AND no key can reach from the
wrong side. What a key CAN express wrongly is the mirror move — `lp` on a Qt
plugin — unsafe for the identical reason, and refused at eval naming the key.

Verified additive on seven modules spanning legacy/core, universal/core,
universal/ui_qml and QML-only: `diff -r` clean, narHash identical where
comparable, and `test_qml_only` byte-identical by store path. Note compiled
modules cannot share a store path across any builder edit, since mkLogosModule
bakes LOGOS_MODULE_BUILDER_ROOT — narHash is the honest comparison.

Three mutation controls, each failing loudly: removing the eval throw, nulling
the backend's own assertion, and dropping the packagedAsCdylib conjunct from
`originBound` — the last being the one that pins the token-mirror hazard rather
than just the lp refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-18 01:06:02 -03:00
co-authored by Claude Opus 5
parent c60d4a9cf3
commit 08ae7ac8c1
5 changed files with 277 additions and 3 deletions
+42
View File
@@ -116,6 +116,7 @@ the impl header/class are derived from `name` (e.g. `my_module` →
| `impl_header` | `universal` | Path to the impl header (default `src/<name>_impl.h`) |
| `impl_class` | `universal` | Impl class name (default PascalCase of `<name>` + `Impl`) |
| `rep` | `ui_qml` + `universal` | Path to the `.rep` QtRO contract for a C++ UI backend |
| `consumer_api_style` | `universal` / `cdylib` | Type surface of the generated `modules().<dep>` wrappers: `"lp"` (Qt-free, the default there) or `"qt"` |
```json
"interface": "universal",
@@ -134,6 +135,47 @@ For a universal C++ UI backend (`"type": "ui_qml"` + `"interface": "universal"`)
"codegen": { "rep": "src/my_ui.rep" }
```
#### `codegen.consumer_api_style`
Two independent axes meet in a module: the surface it PROVIDES (`interface`)
and the surface it CONSUMES its dependencies through. This key names the second
one, and only the second one — setting it changes nothing about the module's own
API or its contract.
The default is derived and matches what the build always did:
| Module shape | Default | Wrappers |
|--------------|---------|----------|
| `interface: "universal"` (`type` other than `ui_qml`) | `lp` | `std::string` / `int64_t`, calling the logos-protocol C ABI |
| `interface: "cdylib"` | `lp` | as above |
| `interface: "universal"` + `type: "ui_qml"` | `qt` | `QString` / `qlonglong`, bound through the backend's `LogosAPI` |
| omitted `interface` (hand-written Qt) | `qt` | as above |
Only ONE override is accepted, and it is the reason the key exists: a module
packaged as a cdylib provider may ask for `"qt"`.
```json
"interface": "universal",
"codegen": { "consumer_api_style": "qt" }
```
That module keeps its Qt-free PROVIDER surface — the std-typed impl header, the
derived LIDL contract, the generated `logos_module_impl.h` C ABI — while
`modules().<dep>` hands out Qt-typed wrappers. Those wrappers hold no
`LogosAPI`: the umbrella is default-constructible and bakes `metadata.json#name`
as the call origin, which each wrapper threads into
`logos::qt::LpBridge::forOrigin(origin, target)`.
The reverse — asking a Qt PLUGIN (a hand-written module, or a `ui_qml` backend)
for `"lp"` — is **refused at evaluation**, by name. Both LogosAPI-free wrapper
flavours rely on something else populating the `TokenManager` their lp client
reads. A cdylib image gets that over the C ABI (`logos_module_accept_token`
`lp_token_save`); a Qt plugin image does not — the host writes tokens to the
`TokenManager` in its OWN image, and only `logos::qt::LpBridge::syncTokens`
(installed exclusively by the LogosAPI-taking `forTarget`) mirrors them across.
Without that mirror every outbound call presents an empty auth token and comes
back as a default value with no error raised.
### `category`
**Type:** string
**Default:** `"general"`
+19
View File
@@ -598,8 +598,27 @@ let
# generator default — no flag). Every other interface keeps qt.
# (Only consulted in the source layout; nix builds get apiStyle from
# the backend's --general-only call.)
#
# `config.consumer_api_style` (parseMetadata.nix — the resolved
# `codegen.consumer_api_style`) is what makes this an override rather
# than a pure derivation. It only ever REMOVES the flag: a
# cdylib-packaged module that asks for the Qt consumer surface must
# not have `lp` forced on it here. It is deliberately NOT allowed to
# ADD one — the trigger condition below is character-for-character
# today's, so no module that passes no flag today starts passing one
# (a `cdylib` module never got this flag even though the nix backend
# types it `lp`; unifying that would change every cdylib module's
# derivation for a flag only the legacy source layout reads).
#
# There is no `--binding` counterpart here on purpose: this branch of
# LogosModule.cmake never invokes logos-qt-generator at all, so it
# cannot emit the origin-bound wrapper SET that the origin-bound
# umbrella needs. The Qt consumer surface for a cdylib module is a
# nix-build capability; the source layout keeps the one shape it can
# actually produce.
apiStyleCmakeFlags =
if config.interface == "universal" && (config.type or "core") != "ui_qml"
&& config.consumer_api_style == "lp"
then [ "-DLOGOS_API_STYLE=lp" ]
else [];
# The backend only knows about Qt + logosModule (interface.h).
+110 -2
View File
@@ -10,11 +10,106 @@
raw = builtins.fromJSON jsonContent;
nix = raw.nix or {};
safeList = val: if builtins.isList val then val else [];
# The two fields the consumer axis below turns on, hoisted so the
# attrset's own `interface` / `type` and the derivation of
# `consumer_api_style` cannot drift apart (this set is not `rec`).
moduleName_ = raw.name or "?";
type_ = raw.type or "core";
interface_ = raw.interface or "legacy";
codegen_ = let c = raw.codegen or {}; in if builtins.isAttrs c then c else {};
# ── Is this module's own image a cdylib PROVIDER? ─────────────────────
#
# True exactly when modulePreConfigure.autoCodegen gives the module the
# module-impl C ABI export surface (logos_module_impl.h) in the SAME
# image its generated consumer wrappers are compiled into:
#
# interface "cdylib" -> cdylibCodegen
# interface "universal", type != "ui_qml" -> universalCodegen
# (a header-first cdylib)
#
# and false for the two shapes that are Qt PLUGIN objects holding a
# LogosAPI: `interface: "legacy"` (handcrafted Qt) and
# `interface: "universal"` + `type: "ui_qml"` (a view backend, which is
# not a module — uiCodegen emits only the view glue).
#
# This predicate is deliberately the SAME expression autoCodegen
# branches on, because the thing it decides is where this image's auth
# TOKENS come from:
#
# cdylib — `logos_module_accept_token` -> `lp_token_save`, straight
# into the TokenManager this image's outbound lp client
# reads (logos-cpp-sdk lidl_gen_cdylib.cpp; the Rust
# equivalent in logos-rust-sdk rustgen_provider.rs). A
# consumer wrapper here needs NO LogosAPI.
# Qt plugin — the host writes to the TokenManager in ITS image; the
# plugin links its own copy of the protocol library, so the
# tokens have to be MIRRORED across
# (logos::qt::LpBridge::syncTokens, reached only via
# `forTarget(api, ...)`). A consumer wrapper here that holds
# no LogosAPI authenticates as nobody, and the call comes
# back as a default value with no error raised.
packagedAsCdylib_ =
interface_ == "cdylib"
|| (interface_ == "universal" && type_ != "ui_qml");
# ── codegen.consumer_api_style: "qt" | "lp" ───────────────────────────
#
# Which TYPE SURFACE this module's generated dependency wrappers expose
# (`modules().<dep>` and the LogosModules umbrella). Independent of the
# module's own PROVIDER surface, which `interface` alone decides.
#
# The default is exactly the value the build derived before this key
# existed (logos-plugin-qt buildPlugin.nix's `apiStyle`), so an existing
# metadata.json produces a byte-identical build.
#
# Only ONE override is reachable, and `packagedAsCdylib_` is why:
#
# cdylib + "qt" — ALLOWED, and the point of this key. The wrappers
# come out Qt-typed and origin-bound (`--binding origin`): they hold
# no LogosAPI and state this module's own name as the call origin.
# Correct here precisely because tokens arrive over the C ABI.
# Qt plugin + "lp" — REFUSED below. The lp wrappers hold no LogosAPI
# either, and in a Qt plugin image nothing would ever populate the
# TokenManager they read.
consumerApiStyleDerived_ = if packagedAsCdylib_ then "lp" else "qt";
consumerApiStyleDeclared_ = codegen_.consumer_api_style or null;
consumerApiStyle_ =
if consumerApiStyleDeclared_ == null then consumerApiStyleDerived_
else if !(builtins.isString consumerApiStyleDeclared_)
|| !(builtins.elem consumerApiStyleDeclared_ [ "qt" "lp" ]) then
throw ("metadata.json: module '${moduleName_}' sets codegen.consumer_api_style = "
+ "${builtins.toJSON consumerApiStyleDeclared_}. Valid values are \"qt\" "
+ "(Qt-typed dependency wrappers) and \"lp\" (Qt-free, the logos-protocol "
+ "C ABI). Omit the key to get this module's default (\"${consumerApiStyleDerived_}\").")
else if consumerApiStyleDeclared_ == "lp" && !packagedAsCdylib_ then
throw ''
metadata.json: module '${moduleName_}' sets codegen.consumer_api_style = "lp",
which is only available to a module packaged as a cdylib provider.
This module declares interface "${interface_}" with type "${type_}", so its
generated dependency wrappers are compiled into a Qt PLUGIN object that holds a
LogosAPI and exports no `logos_module_accept_token`. The lp wrappers call the
logos-protocol C ABI directly and hold no LogosAPI, so NOTHING would populate the
TokenManager they read: every outbound call would present an empty auth token,
capability_module would refuse to mint a per-target token, and the call would
return a default value with no error surfaced. That is the exact defect
`logos::qt::LpBridge::syncTokens` exists to prevent which is why the two
consumer surfaces are not freely interchangeable.
Reachable values for this module: "qt" (its default; omit the key).
To get the Qt-free consumer surface, make the module a cdylib provider
`interface: "universal"` (write a plain src/${moduleName_}_impl.h and the contract
is derived from it) or `interface: "cdylib"`.
''
else consumerApiStyleDeclared_;
in {
# Runtime fields
name = raw.name or (throw "metadata.json must specify 'name'");
version = raw.version or "1.0.0";
type = raw.type or "core";
type = type_;
category = raw.category or "general";
description = raw.description or "A Logos module";
main = raw.main or null;
@@ -123,7 +218,20 @@
# Module API style: "legacy" (default), "universal" (pure C++ + generated
# Qt glue), "cdylib" (module-impl C ABI + generated glue).
# "provider" was removed; autoCodegen throws if a module still declares it.
interface = raw.interface or "legacy";
interface = interface_;
# Where this module's generated consumer wrappers get compiled, as a
# boolean the backends can consult without re-deriving it: true = into
# the cdylib provider image (tokens arrive over the module-impl C ABI),
# false = into a Qt plugin object holding a LogosAPI (tokens have to be
# mirrored). Derived in the `let` above, next to the reasoning.
packaged_as_cdylib = packagedAsCdylib_;
# The resolved consumer type surface ("qt" | "lp") — the default derived
# from `packaged_as_cdylib`, or the validated `codegen.consumer_api_style`
# override. See the `let` above for the one override that is reachable
# and why the other is refused there rather than at compile time.
consumer_api_style = consumerApiStyle_;
# Privileged host services this module asks the host to grant it, from a
# CLOSED set. Parsed and validated here, unlike the older decorative
+6 -1
View File
@@ -31,13 +31,18 @@ let
templateTests = import ./test-templates.nix { inherit assertEq assertBool assertHasAttr parseMetadata; builderRoot = ./..; };
collectDepsTests = import ./test-collectAllModuleDeps.nix { inherit assertEq assertBool assertHasAttr common; };
fixtureTests = import ./test-fixtures.nix { inherit assertEq assertBool assertHasAttr parseMetadata fixturesRoot; };
# The consumer axis (codegen.consumer_api_style) and the gate on it. Its own
# file rather than more cases in test-parse-metadata.nix: what it pins is a
# safety boundary (which images may hold a LogosAPI-free consumer wrapper),
# not a parsing default, and the reasoning belongs next to the cases.
consumerApiStyleTests = import ./test-consumer-api-style.nix { inherit assertEq assertBool assertThrows parseMetadata; };
modulePreConfigureTests = import ./test-module-pre-configure.nix {
inherit lib assertBool assertThrows;
modulePreConfigure = import ../lib/modulePreConfigure.nix { inherit lib; };
};
# Collect all test results into a list of bools (all must be true)
allTests = parseMetadataTests ++ commonTests ++ externalLibTests ++ templateTests ++ collectDepsTests ++ fixtureTests ++ modulePreConfigureTests;
allTests = parseMetadataTests ++ commonTests ++ externalLibTests ++ templateTests ++ collectDepsTests ++ fixtureTests ++ modulePreConfigureTests ++ consumerApiStyleTests;
# Force evaluation of all tests
allPassed = builtins.deepSeq allTests (builtins.length allTests);
+100
View File
@@ -0,0 +1,100 @@
# The CONSUMER axis: `codegen.consumer_api_style`, and the gate on it.
#
# What is being pinned here is not "the key parses". It is the boundary the key
# is allowed to cross, which is a SAFETY boundary rather than a typing one:
#
# A generated consumer wrapper that holds no LogosAPI (the lp wrappers, and
# the Qt wrappers emitted under `--binding origin`) can only authenticate its
# outbound calls if something else populates the TokenManager its lp client
# reads. In a cdylib provider image that happens over the module-impl C ABI
# (`logos_module_accept_token` -> `lp_token_save`). In a Qt PLUGIN image it
# does not: the host writes tokens to the TokenManager in its OWN image, and
# the only thing that mirrors them across is logos::qt::LpBridge::syncTokens,
# installed exclusively by `forTarget(api, ...)`.
#
# So the same "no LogosAPI" wrapper is correct in one image and silently
# unauthenticated in the other — calls come back as default values with no
# error raised. `packaged_as_cdylib` is the predicate that separates them, and
# these cases are the record of where it falls.
#
# Every default below must equal what logos-plugin-qt buildPlugin.nix derived
# before this key existed, or every module in the tree rebuilds.
{ assertEq, assertBool, assertThrows, parseMetadata }:
let
parse = parseMetadata.parseModuleConfig;
mk = attrs: parse (builtins.toJSON ({ name = "m"; } // attrs));
# The four shapes, with no key set.
legacyCore = mk { }; # handcrafted Qt plugin
legacyUi = mk { type = "ui"; };
universalCore = mk { interface = "universal"; type = "core"; }; # header-first cdylib
universalUi = mk { interface = "universal"; type = "ui_qml"; }; # view backend, NOT a module
cdylibCore = mk { interface = "cdylib"; type = "core"; };
# Overridden.
cdylibQt = mk { interface = "cdylib"; type = "core";
codegen = { consumer_api_style = "qt"; }; };
universalQt = mk { interface = "universal"; type = "core";
codegen = { consumer_api_style = "qt"; }; };
cdylibLpExplicit = mk { interface = "cdylib"; type = "core";
codegen = { consumer_api_style = "lp"; }; };
legacyQtExplicit = mk { codegen = { consumer_api_style = "qt"; }; };
in [
# ── packaged_as_cdylib: the predicate itself ───────────────────────────────
# Same expression modulePreConfigure.autoCodegen branches on when it decides
# to emit the module-impl C ABI export surface. If these two ever disagree, a
# module gets an origin-bound wrapper in an image with no accept_token.
(assertBool "legacy core is NOT cdylib-packaged" legacyCore.packaged_as_cdylib false)
(assertBool "legacy ui is NOT cdylib-packaged" legacyUi.packaged_as_cdylib false)
(assertBool "universal core IS cdylib-packaged" universalCore.packaged_as_cdylib true)
(assertBool "universal ui_qml is NOT cdylib-packaged" universalUi.packaged_as_cdylib false)
(assertBool "cdylib IS cdylib-packaged" cdylibCore.packaged_as_cdylib true)
# ── Defaults: byte-identity with the pre-key derivation ────────────────────
(assertEq "legacy core defaults to qt" legacyCore.consumer_api_style "qt")
(assertEq "legacy ui defaults to qt" legacyUi.consumer_api_style "qt")
(assertEq "universal core defaults to lp" universalCore.consumer_api_style "lp")
(assertEq "universal ui_qml defaults to qt" universalUi.consumer_api_style "qt")
(assertEq "cdylib defaults to lp" cdylibCore.consumer_api_style "lp")
# ── The one override the key exists for ────────────────────────────────────
# A cdylib provider consuming its dependencies through Qt-typed wrappers.
# Its image has no LogosAPI and takes tokens over the C ABI, so the
# origin-bound wrapper is correct there.
(assertEq "cdylib may ask for the Qt consumer surface" cdylibQt.consumer_api_style "qt")
(assertEq "universal core may ask for the Qt consumer surface" universalQt.consumer_api_style "qt")
# Restating a default is a no-op, not an error.
(assertEq "cdylib may restate lp" cdylibLpExplicit.consumer_api_style "lp")
(assertEq "legacy may restate qt" legacyQtExplicit.consumer_api_style "qt")
# ── THE GATE ───────────────────────────────────────────────────────────────
# A Qt plugin image must not get LogosAPI-free consumer wrappers. Refused at
# EVAL, naming `codegen.consumer_api_style`, because the alternatives are a
# compile error deep inside generated code (the umbrella shapes disagree) or —
# worse, where they happen to agree — a module that builds green and then
# cannot authenticate a single outbound call.
(assertThrows "a legacy Qt plugin cannot ask for the lp consumer surface"
(mk { codegen = { consumer_api_style = "lp"; }; }).consumer_api_style)
(assertThrows "a legacy ui plugin cannot ask for the lp consumer surface"
(mk { type = "ui"; codegen = { consumer_api_style = "lp"; }; }).consumer_api_style)
# The one that is easy to get wrong: `interface: "universal"` looks
# cdylib-shaped, but a ui_qml backend is a Qt view object holding a LogosAPI.
(assertThrows "a universal ui_qml backend cannot ask for the lp consumer surface"
(mk { interface = "universal"; type = "ui_qml";
codegen = { consumer_api_style = "lp"; }; }).consumer_api_style)
# ── Value validation ───────────────────────────────────────────────────────
# An unrecognised value is refused rather than defaulted: defaulting a
# misspelling back to the derived surface is how a module silently keeps the
# binding its author was trying to change.
(assertThrows "an unknown consumer_api_style is refused"
(mk { interface = "cdylib"; codegen = { consumer_api_style = "Qt"; }; }).consumer_api_style)
(assertThrows "the --binding value is not a consumer_api_style value"
(mk { interface = "cdylib"; codegen = { consumer_api_style = "origin"; }; }).consumer_api_style)
(assertThrows "a non-string consumer_api_style is refused"
(mk { interface = "cdylib"; codegen = { consumer_api_style = true; }; }).consumer_api_style)
]