11 Commits
Author SHA1 Message Date
Dario Lipicar 2370bcb11a Merge pull request #1 from logos-co/feat/structural-ingest-gate
feat(contract)!: gate ingest on WHO the caller is, not what it knows
2026-08-26 22:36:14 -03:00
Dario Gabriel LipicarandClaude Opus 5 dbfbf65aef chore(deps): relock onto module-builder master — and it changes a design answer
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>
2026-08-26 16:16:19 -03:00
Dario Gabriel LipicarandClaude Opus 5 495d4f75f1 docs: name the actual mechanism behind a refused subscribe
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>
2026-08-26 14:35:39 -03:00
Dario Gabriel LipicarandClaude Opus 5 1403bf92b1 docs: correct the watch race — it is startup cost, not a 2s/6s window
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>
2026-08-26 14:05:21 -03:00
Dario Gabriel LipicarandClaude Opus 5 be88d0d32d docs: the walkthrough raced the handshake and silently watched nothing
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>
2026-08-26 13:50:32 -03:00
Dario Gabriel LipicarandClaude Opus 5 3ba7861978 feat(contract): module_record returns the optional; absent becomes event-only
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>
2026-08-22 23:10:52 -03:00
Dario Gabriel Lipicar de81c15cd7 feat: module_record returns ? ModuleRecord, and absent narrows to event-only
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.
2026-08-22 22:22:58 -03:00
Dario Gabriel LipicarandClaude Opus 5 bdc931a7d3 docs: the ?T refusal is stale — say why the record is right on its own merits
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>
2026-08-21 17:17:39 -03:00
Dario Gabriel LipicarandClaude Opus 5 4e3333bd72 chore: gitignore nix result symlinks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:25:26 -03:00
Dario Gabriel LipicarandClaude Opus 5 96997a69c6 docs: README, and clarify delta vs snapshot field semantics
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:25:18 -03:00
Dario Gabriel LipicarandClaude Opus 5 f50a071a5f feat: modules_state — read-only registry of module lifecycle state
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>
2026-08-21 15:12:07 -03:00