bc72ce39 (2026-08-23) -> 464a75d (2026-08-26), which also moves logos-cpp-sdk,
logos-plugin-core, logos-lidl and brings logos-plugin-qt to master f668ef21.
Not routine hygiene. The stale closure built a plugin with NO caller-identity
machinery at all, and that is invisible until you look:
bc72ce39: nm modules_state_plugin.dylib | grep -i caller -> nothing
probe: logos::currentCaller() -> kind=unknown
464a75d: nm ... -> logos::CallerScope, logos::currentInboundCallerJson,
LogosProviderBase::currentCallerJson, LogosAPI::currentCallerJson
probe: logos::currentCaller() -> kind=host
Measured with a temporary whoami() method called through logoscore, both halves
at master (logoscore 1ec36839). The probe is not committed.
WHY THIS MATTERS FOR THE INGEST GATE. The header says the authToken argument
exists because "LogosModuleContext exposes this module's own identity and
nothing about the CALLER", and that "if a caller-identity accessor lands later,
this gate should be rewritten to use it and the token retired". It has landed
and it reaches this module, so the structural check is available and the
LOGOS_MODULES_STATE_INGEST_TOKEN nonce -- plus the ModuleDescriptor.env plumbing
it would have needed across logos-container and logos-container-subprocess -- is
not required. The gate rewrite is a separate change; this only unblocks it.
THE TRAP, recorded because it cost a wrong conclusion. A stale pin degrades
caller identity to `unknown` SILENTLY: it compiles, it links, the probe method
reaches the generated API, nothing warns. A fail-closed gate on Unknown then
refuses every write -- inert, not secure, with no error to notice.
And the mechanism that made the wrong answer look structural: grepping for
`logos_module_set_call_caller` finds it ONLY in lidl_gen_cdylib_glue.cpp, which
reads as proof a Qt plugin cannot carry the identity. It is not. cdylib pushes
across the C ABI because host and impl are DIFFERENT images; a Qt plugin uses
logos::CallerScope in-image because they are the SAME image. An absent C-ABI
symbol here is expected, not evidence.
Unit tests 4/4 on the new closure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1403bf9 said the trigger was startup cost and left the mechanism explicitly
unestablished. It is now established, and it is a LIVENESS PROBE:
LogosAPIConsumer::requestObject
if (!m_transport->isConnected()) { qWarning(...); return nullptr; }
logos-protocol, cpp/logos_api_consumer.cpp:610
isConnected() probes for a listener on the module's socket. `load-module`
returns when the plugin is IN; the module PUBLISHES afterwards, and a
subscribe in between is refused. Cold runs simply widen that gap.
So it is deterministic, not a fluke, and there is a repro that needs no cold
machine: subscribe to a module that is not up yet. Fails 3/3 warm, every
time.
This also corrects be88d0d's "refused before the registry handshake", which
was the wrong mechanism, and retires the claim that it only reproduces cold.
Two traps recorded for anyone hand-rolling a subscribe:
* onEventWhenAvailable REFUSES an empty event name
(logos_api_consumer.cpp:558) while LogosObject::onEvent reads empty as
WILDCARD — so a naive swap silently kills the subscribe-to-all form.
* a refusal and a typo'd module name give the BYTE-IDENTICAL error, so a
failed subscribe cannot tell you which one you have.
Mechanism identified by a parallel session that caught the cold log; both
code references verified here against logos-protocol, and the deterministic
repro reproduced independently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
be88d0d claimed "measured on macOS: 2s fails, 6s succeeds" and prescribed
`sleep 6`. The threshold does not exist. That was one unreplicated
observation, and I stated it as a measurement.
Re-ran it properly: 24/24 consecutive subscribes SUCCEEDED, at delays of
0,1,2,3,4 and 6 seconds — including zero delay, subscribing the instant
load-module returns. 12 of those were zero-delay back-to-back.
The single failure was a cold first run: first load of the plugin, first
spawn of capability_module out of a freshly built store path. So the trigger
is startup cost, not a fixed window, and a sleep is the wrong shape of fix —
any constant is too short on a cold machine and wasted on a warm one.
Retry the subscribe instead, and check for WATCH_FAILED rather than assuming
it took. The invisible-failure point from be88d0d still stands and is the
reason this is guarded at all: `watch` does not retry itself, so an
unchecked failure lets every later line pass while observing nothing.
Still unverified, and NOT claimed here: the mechanism. be88d0d asserted this
was the one-shot-refused-before-handshake path the contract section warns
about. That was inference from timing plus the error string; I did not
locate any refusing code, and no daemon log line evidenced a refusal even
with QT_FORCE_STDERR_LOGGING=1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the README walkthrough end-to-end for the first time turned up two
races, both of which pass silently rather than failing loudly:
* `-D &` returns before the daemon is listening, so the `load-module` on
the next line races the socket. Poll `status` instead.
* `watch` is the one-shot subscription this README already warns about:
refused before the registry handshake, and never retried. Subscribing
~2s after load-module answers WATCH_FAILED, and because nothing retries,
every later line still succeeds while observing NOTHING. Measured on
macOS: 2s fails, 6s succeeds.
The second is the dangerous one — the walkthrough "passed" while its only
event assertion was dead. Neither is a module bug; the generated typed
wrapper uses onEventWhenAvailable and needs none of this.
Also record the verified answers, since the contract claims are now measured
rather than reasoned: module_record miss -> {"status":"ok","result":null},
a replayed seq -> "result":false, list_modules -> partial:true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Optional RETURNS are supported now (logos-qt-sdk lifted the gate), so the
contract can be the shape the draft specs describe instead of the shape the
generator allowed.
module_record(module: tstr) -> ? ModuleRecord. A miss is std::nullopt. The
old workaround — a record spelling the miss as state:"absent", seq:0 — is
gone, and so is the essay justifying it. Both of that essay's premises are
now false, and I checked rather than assumed: `?T` keeps its value type
(the generated consumer says std::optional<ModuleRecord>, not a bare
QVariant), and logoscore's core_service no longer turns a null result into
METHOD_FAILED — call_envelope.cpp asks the module for its method list on a
null return and answers METHOD_NOT_FOUND only when introspection proves the
method absent, otherwise status ok with null as the value. Verified by
running it: an unknown module answers {"status":"ok","result":null}.
`absent` was carrying two loads and only one survives. The miss answer is
now the empty optional. The MEMBERSHIP EDGES are the load-bearing half —
absent->unloaded on discovery, unloaded->absent on prune — and without them
a consumer is back to inferring lifecycle from package-install events plus
a 100ms settle, which is what this module exists to stop.
So absent is now EVENT-ONLY: expressible as old_state/new_state, never
carried by a record. A transition into absent ERASES the record, which
makes the never-absent invariant hold by construction rather than by a
filter someone can forget. That erase also destroys the seq the replay rule
compares against, so a seq tombstone keeps a stale delta from resurrecting
a pruned module — the same tombstone the absent record used to be, minus
the fake record.
This shrinks the divergence from logos-lips#317 from a sixth RECORD STATE
to one event-only transition target.
Adds tests/, and they are worth more than the count suggests. Each of the
three invariants was mutated — the erase removed, the tombstone lookup
dropped, the empty-state skip deleted — and each mutation was verified to
APPLY before being trusted, then seen to turn the suite red and back green.
The suite very nearly did not run at all. mkLogosModuleTests executes
`find . -executable \( -name "*_tests" -o -name "*_test" \)` and pipes it
into a while-read loop, so a binary named anything else is built, matched
by nothing, and never executed — while the check still goes green, because
compiling was all that happened. This suite was called
`modules_state_invariants` and passed all three mutations it should have
caught before that was noticed. The CMakeLists says so now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Optional RETURNS are supported now (the qt-generator gate was lifted in
logos-qt-sdk 133042b, and the cdylib backend already carried `-> ?Record`), so
the two workarounds that existed only because they were not supported go away together.
1. `module_record(module: tstr) -> ? ModuleRecord`. The miss is std::nullopt,
which is distinct from ModuleRecord{} — the thing a bare record could never
express. The header's justification for returning a plain record went with
it: a stale justification is worse than none. Both of its premises are also
now false — the Qt consumer keeps the value type
(`std::optional<ModuleRecord>`, not a bare QVariant), and logoscore's
core_service reads the error channel rather than the value, so an empty
optional round-trips the CLI as {"status":"ok","result":null}.
2. `absent` carried two loads and only one survives. The load-bearing one is
the MEMBERSHIP EDGES — absent->unloaded on discovery, unloaded->absent on
prune — without which a consumer is back to inferring lifecycle from
package-install events plus a settle timer. The other was module_record's
miss answer, and that is the empty optional now.
So `absent` is EVENT-ONLY: it may be old_state or new_state in
module_state_changed, and no record ever carries it. A transition into
`absent` erases the record and leaves a seq tombstone, so list_modules'
never-absent invariant holds by construction and the replay rule stays
total across a prune/re-add cycle. A snapshot record claiming `absent` is
contract-malformed and is skipped, which makes it mean exactly what
omitting it would have meant.
3. The divergence from the draft core specs (logos-lips#317,
spec-module-runtime.md §3.4: unloaded|loaded|ready|stopping|error) is now
one event-only transition target rather than a sixth record state. Said
plainly in the header, along with the one other divergence — `loading`,
which those drafts fold into `loaded` — so the two are not confused.
Toolchain: relocked logos-module-builder bdc9804 -> bc72ce3, which brings
logos-qt-sdk 4a1104c -> 4ab78a1 (the lifted gate) and logos-cpp-sdk 667990f ->
acea0d2 (the lossless Qt type mapping). Verified no input moved backwards:
node count is unchanged at 361, no node's lastModified regressed, and every
module-builder input matches that repo's own master lock exactly.
The qt-generator refuses `-> ?T` on the grounds that an empty optional is
indistinguishable from a failed call. That is no longer true: the generated
Qt consumer carries failure out-of-band in a logos::CallError, and lp_invoke
branches on callErr.ok() rather than on the value, so empty arrives as LP_OK
and failure as LP_ERR_UNAVAILABLE.
Justifying a good design with a false premise is how the premise spreads, so
state the real reason instead: `?T` is the one Qt mapping that loses the
value type, and a plain record keeps ModuleRecord typed while leaving the
miss expressible in the domain as state: "absent".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1: the standalone module. Carries the full contract (read surface,
authToken-gated ingest surface, module_state_changed event, seven-state
vocabulary) but is fed by nothing yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>